import QtQuick import Quickshell import Quickshell.Io import qs.Commons import qs.Services.UI import qs.Services.Keyboard // MangoService integrates with MangoWC compositor using mmsg IPC commands // for real-time window management, workspace control, and state monitoring Item { id: root // Facade interface properties property ListModel workspaces: ListModel {} property var windows: [] property int focusedWindowIndex: -1 // Facade interface signals signal workspaceChanged signal activeWindowChanged signal windowListChanged signal displayScalesChanged // MangoWC-specific state property bool initialized: false property bool overviewActive: false property var workspaceCache: ({}) // Cache for workspace data to detect changes property var windowCache: ({}) // Cache for window data to detect changes property var monitorCache: ({}) // Cache for monitor/scale data property string currentLayout: "" // Current layout name property string currentLayoutSymbol: "" // Current layout symbol (e.g., 'S' for scroller) property string currentKeyboardLayout: "" // Current keyboard layout name property string selectedMonitor: "" // Currently selected/focused monitor // mmsg command templates for MangoWC IPC (mmsg is the MangoWC message interface) readonly property var mmsgCommands: ({ "query": { "workspaces": ["mmsg", "-g", "-t"], "windows": ["mmsg", "-g", "-c"], "layout": ["mmsg", "-g", "-l"], "keyboard": ["mmsg", "-g", "-k"], "outputs": ["mmsg", "-g", "-A"], "monitors": ["mmsg", "-g", "-o"], "eventStream": ["mmsg", "-w"] }, "action": { "view": ["mmsg", "-s", "-d", "view"], "tag": ["mmsg", "-s", "-t"], "focusMaster": ["mmsg", "-s", "-d", "focusmaster"], "killClient": ["mmsg", "-s", "-d", "killclient"], "toggleOverview": ["mmsg", "-s", "-d", "toggleoverview"], "setLayout": ["mmsg", "-s", "-d", "setlayout"], "quit": ["mmsg", "-s", "-q"] } }) readonly property string overviewLayoutSymbol: "󰃇" // Symbol representing overview layout readonly property int defaultWorkspaceId: 1 // Default workspace ID when none specified // Debounce timer for rapid state changes to avoid excessive updates Timer { id: updateTimer interval: 50 repeat: false onTriggered: safeUpdate() } // Event stream process for real-time MangoWC state monitoring using mmsg -w // Monitors events: workspace changes, window focus/movement, layout changes, monitor selection Process { id: eventStream running: false command: mmsgCommands.query.eventStream stdout: SplitParser { onRead: function (line) { try { handleEvent(line.trim()) } catch (e) { Logger.e("MangoService", "Event parsing error:", e, line) } } } onExited: function (exitCode) { if (exitCode !== 0) { Logger.e("MangoService", "Event stream exited, restarting...") restartTimer.start() } } } // Restart timer for event stream recovery on failure Timer { id: restartTimer interval: 1000 onTriggered: { if (initialized) { eventStream.running = true } } } // Process to query workspaces using mmsg -g -t Process { id: workspacesProcess running: false command: mmsgCommands.query.workspaces property string accumulatedOutput: "" stdout: SplitParser { onRead: function (line) { workspacesProcess.accumulatedOutput += line + "\n" } } onExited: function (exitCode) { if (exitCode === 0) { parseWorkspaces(accumulatedOutput) } else { Logger.e("MangoService", "Workspaces query failed:", exitCode) } accumulatedOutput = "" } } // Process to query windows using mmsg -g -c Process { id: windowsProcess running: false command: mmsgCommands.query.windows property string accumulatedOutput: "" property var currentWindow: ({}) onRunningChanged: { if (running) { windowsProcess.currentWindow = {} } } stdout: SplitParser { onRead: function (line) { const trimmed = line.trim() if (!trimmed) return const parts = trimmed.split(' ') if (parts.length >= 3) { const outputName = parts[0] const property = parts[1] const value = parts.slice(2).join(' ') if (!windowsProcess.currentWindow[outputName]) { windowsProcess.currentWindow[outputName] = { "id": outputName, "output": outputName } } switch (property) { case "title": windowsProcess.currentWindow[outputName].title = value break case "appid": windowsProcess.currentWindow[outputName].appId = value windowsProcess.currentWindow[outputName].class = value break case "fullscreen": windowsProcess.currentWindow[outputName].fullscreen = (value === "1") break case "floating": windowsProcess.currentWindow[outputName].floating = (value === "1") break case "x": windowsProcess.currentWindow[outputName].x = parseInt(value) break case "y": windowsProcess.currentWindow[outputName].y = parseInt(value) break case "width": windowsProcess.currentWindow[outputName].width = parseInt(value) break case "height": windowsProcess.currentWindow[outputName].height = parseInt(value) break } } } } onExited: function (exitCode) { if (exitCode === 0) { parseWindows(windowsProcess.currentWindow) } else { Logger.e("MangoService", "Windows query failed:", exitCode) } accumulatedOutput = "" windowsProcess.currentWindow = {} } } // Process to query current layout using mmsg -g -l Process { id: layoutProcess running: false command: mmsgCommands.query.layout stdout: SplitParser { onRead: function (line) { try { const parts = line.trim().split(/\s+/) if (parts.length >= 2) { const layoutSymbol = parts.slice(1).join(' ') handleLayoutChange(layoutSymbol) } } catch (e) { Logger.e("MangoService", "Layout parsing error:", e, line) } } } onExited: function (exitCode) { if (exitCode !== 0) { Logger.e("MangoService", "Layout query failed:", exitCode) } } } // Process to query keyboard layout using mmsg -g -k Process { id: keyboardProcess running: false command: mmsgCommands.query.keyboard stdout: SplitParser { onRead: function (line) { try { const parts = line.trim().split(/\s+/) if (parts.length >= 2 && parts[1] === "kb_layout") { const layoutName = parts.slice(2).join(' ') if (layoutName && layoutName !== currentKeyboardLayout) { currentKeyboardLayout = layoutName KeyboardLayoutService.setCurrentLayout(layoutName) } } } catch (e) { Logger.e("MangoService", "Keyboard layout parsing error:", e, line) } } } onExited: function (exitCode) { if (exitCode !== 0) { Logger.e("MangoService", "Keyboard query failed:", exitCode) } } } // Process to query output scales using mmsg -g -A Process { id: outputsProcess running: false command: mmsgCommands.query.outputs stdout: SplitParser { onRead: function (line) { try { const parts = line.trim().split(/\s+/) if (parts.length >= 3 && parts[1] === "scale_factor") { const outputName = parts[0] const scaleFactor = parseFloat(parts[2]) if (!monitorCache[outputName]) { monitorCache[outputName] = {} } monitorCache[outputName].scale = scaleFactor monitorCache[outputName].name = outputName } } catch (e) { Logger.e("MangoService", "Output parsing error:", e, line) } } } onExited: function (exitCode) { if (exitCode === 0) { updateDisplayScales() } else { Logger.e("MangoService", "Outputs query failed:", exitCode) } } } // Process to query monitor states using mmsg -g -o Process { id: monitorStateProcess running: false command: mmsgCommands.query.monitors stdout: SplitParser { onRead: function (line) { try { const parts = line.trim().split(/\s+/) if (parts.length >= 3 && parts[1] === "selmon") { const outputName = parts[0] const isSelected = parts[2] === "1" if (isSelected) { selectedMonitor = outputName Logger.d("MangoService", `Initial selected monitor: ${outputName}`) } } } catch (e) { Logger.e("MangoService", "Monitor state parsing error:", e, line) } } } onExited: function (exitCode) { if (exitCode !== 0) { Logger.e("MangoService", "Monitor state query failed:", exitCode) } } } // Process to enumerate available outputs using mmsg -g -O Process { id: outputEnumProcess running: false command: ["mmsg", "-g", "-O"] stdout: SplitParser { onRead: function (line) { try { const trimmed = line.trim() const outputName = trimmed.replace(/^\+\s*/, '') if (outputName && !monitorCache[outputName]) { monitorCache[outputName] = { "name": outputName, "scale": 1.0, "active": false, "focused": false } } } catch (e) { Logger.e("MangoService", "Output enumeration error:", e, line) } } } onExited: function (exitCode) { if (exitCode !== 0) { Logger.e("MangoService", "Output enumeration failed:", exitCode) } } } // Initialize MangoService and establish connection to MangoWC function initialize() { if (initialized) { Logger.w("MangoService", "Already initialized") return } try { Logger.i("MangoService", "Service started") queryOutputEnum() queryMonitorState() eventStream.running = true queryWorkspaces() queryWindows() queryLayout() queryKeyboard() queryOutputs() initialized = true Logger.i("MangoService", "Service initialized successfully") } catch (e) { Logger.e("MangoService", "Initialization failed:", e) eventStream.running = true } } // Switch to a specific workspace/tag function switchToWorkspace(workspace) { try { const tagId = workspace.idx || workspace.id || defaultWorkspaceId const outputName = workspace.output || selectedMonitor || "" let command = mmsgCommands.action.tag.slice() // Only add -o parameter for multi-monitor setups if (outputName && Object.keys(monitorCache).length > 1) { command.push("-o", outputName) } command.push(tagId.toString()) Quickshell.execDetached(command) } catch (e) { Logger.e("MangoService", "Failed to switch workspace:", e) } } // Focus a specific window on its workspace function focusWindow(window) { try { if (window && window.output) { let command = mmsgCommands.action.view.slice() const isMultiMonitor = Object.keys(monitorCache).length > 1 if (isMultiMonitor) { command.push("-o", window.output) } command.push(window.workspaceId.toString()) Quickshell.execDetached(command) Qt.callLater(() => { let focusCommand = mmsgCommands.action.focusMaster.slice() if (isMultiMonitor) { focusCommand.push("-o", window.output) } Quickshell.execDetached(focusCommand) }) } } catch (e) { Logger.e("MangoService", "Failed to focus window:", e) } } function closeWindow(window) { try { const command = mmsgCommands.action.killClient.slice() if (selectedMonitor && Object.keys(monitorCache).length > 1) { command.push("-o", selectedMonitor) } Quickshell.execDetached(command) } catch (e) { Logger.e("MangoService", "Failed to close window:", e) } } function toggleOverview() { try { const command = mmsgCommands.action.toggleOverview.slice() if (selectedMonitor && Object.keys(monitorCache).length > 1) { command.push("-o", selectedMonitor) } Quickshell.execDetached(command) } catch (e) { Logger.e("MangoService", "Failed to toggle overview:", e) } } function setLayout(layoutName) { try { const command = mmsgCommands.action.setLayout.slice() command.push(layoutName) Quickshell.execDetached(command) } catch (e) { Logger.e("MangoService", "Failed to set layout:", e) } } function logout() { try { Quickshell.execDetached(mmsgCommands.action.quit) } catch (e) { Logger.e("MangoService", "Failed to logout:", e) } } // Parse workspace data from mmsg -g -t output // Handles formats: tag details, tag masks, and binary states // State bits: bit 0 = active/selected, bit 1 = urgent function parseWorkspaces(output) { const lines = output.trim().split('\n') const workspacesList = [] const newWorkspaceCache = {} let outputClients = {} for (const line of lines) { const trimmed = line.trim() if (!trimmed) continue const tagMatch = trimmed.match(/^(\S+)\s+tag\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)$/) if (tagMatch) { const outputName = tagMatch[1] const tagNum = tagMatch[2] const state = tagMatch[3] const clients = tagMatch[4] const focused = tagMatch[5] const tagId = parseInt(tagNum) const isActive = (parseInt(state) & 1) !== 0 const isUrgent = (parseInt(state) & 2) !== 0 const isOccupied = parseInt(clients) > 0 const isFocused = isActive && parseInt(focused) === 1 if (!outputClients[outputName]) { outputClients[outputName] = 0 } const workspaceData = { "id": tagId, "idx": tagId, "name": tagId.toString(), "output": outputName, "isActive": isActive, "isFocused": isFocused || (isActive && (outputName === selectedMonitor)), "isUrgent": isUrgent, "isOccupied": isOccupied, "clients": parseInt(clients) } newWorkspaceCache[`${outputName}-${tagId}`] = workspaceData workspacesList.push(workspaceData) } const clientsMatch = trimmed.match(/^(\S+)\s+clients\s+(\d+)$/) if (clientsMatch) { const outputName = clientsMatch[1] const clientCount = clientsMatch[2] outputClients[outputName] = parseInt(clientCount) } const tagsMatch = trimmed.match(/^(\S+)\s+tags\s+(\d+)\s+(\d+)\s+(\d+)$/) if (tagsMatch) { const outputName = tagsMatch[1] const occ = tagsMatch[2] const seltags = tagsMatch[3] const urg = tagsMatch[4] const occBits = occ.padStart(9, '0') const selBits = seltags.padStart(9, '0') const urgBits = urg.padStart(9, '0') for (var i = 0; i < 9; i++) { const tagId = i + 1 const isActive = selBits[8 - i] === '1' const isUrgent = urgBits[8 - i] === '1' const isOccupied = occBits[8 - i] === '1' const workspaceData = { "id": tagId, "idx": tagId, "name": tagId.toString(), "output": outputName, "isActive": isActive, "isFocused": false, "isUrgent"// Will be determined by selected monitor : isUrgent, "isOccupied": isOccupied, "clients": 0 // Will be updated by tag-specific data } const key = `${outputName}-${tagId}` if (!newWorkspaceCache[key]) { newWorkspaceCache[key] = workspaceData workspacesList.push(workspaceData) } } } const layoutMatch = trimmed.match(/^(\S+)\s+layout\s+(\S+)$/) if (layoutMatch) { const layoutSymbol = layoutMatch[2] handleLayoutChange(layoutSymbol) } } if (JSON.stringify(newWorkspaceCache) !== JSON.stringify(workspaceCache)) { workspaceCache = newWorkspaceCache workspacesList.sort((a, b) => { if (a.id !== b.id) return a.id - b.id return a.output.localeCompare(b.output) }) workspaces.clear() for (var i = 0; i < workspacesList.length; i++) { workspaces.append(workspacesList[i]) } workspaceChanged() } } // Parse window data from mmsg -g -c output into window list function parseWindows(windowData) { const windowsList = [] const newWindowCache = {} let newFocusedIndex = -1 const windowEntries = Object.entries(windowData) for (var i = 0; i < windowEntries.length; i++) { const outputName = windowEntries[i][0] const data = windowEntries[i][1] if (data.title || data.appId) { const isFocused = (outputName === selectedMonitor) let activeTagId = defaultWorkspaceId const workspaceEntries = Object.entries(workspaceCache) for (var j = 0; j < workspaceEntries.length; j++) { const key = workspaceEntries[j][0] const tagData = workspaceEntries[j][1] if (tagData.output === outputName && tagData.isActive) { activeTagId = tagData.id break } } const windowInfo = { "id": `${outputName}-${data.appId || 'unknown'}`, "title": data.title || "", "appId": data.appId || "", "class": data.appId || "", "workspaceId": activeTagId, "isFocused": isFocused, "output": outputName, "fullscreen": data.fullscreen || false, "floating": data.floating || false, "x": data.x || 0, "y": data.y || 0, "width": data.width || 0, "height": data.height || 0, "geometry": { "x": data.x || 0, "y": data.y || 0, "width": data.width || 0, "height": data.height || 0 } } windowsList.push(windowInfo) newWindowCache[windowInfo.id] = windowInfo if (isFocused) { newFocusedIndex = windowsList.length - 1 Logger.d("MangoService", `Focused window detected: ${data.title} on ${outputName}`) } } } if (JSON.stringify(newWindowCache) !== JSON.stringify(windowCache)) { windowCache = newWindowCache windows = windowsList if (newFocusedIndex !== focusedWindowIndex) { focusedWindowIndex = newFocusedIndex activeWindowChanged() } windowListChanged() } } // Handle layout change events and update overview state function handleLayoutChange(layoutSymbol) { const wasOverview = overviewActive const isOverview = (layoutSymbol === overviewLayoutSymbol) if (wasOverview !== isOverview) { overviewActive = isOverview Logger.d("MangoService", `Overview mode: ${overviewActive}`) } if (layoutSymbol !== currentLayoutSymbol) { currentLayoutSymbol = layoutSymbol currentLayout = layoutSymbol } } // Update display scales and notify CompositorService function updateDisplayScales() { const scales = {} const monitorEntries = Object.entries(monitorCache) for (var i = 0; i < monitorEntries.length; i++) { const outputName = monitorEntries[i][0] const data = monitorEntries[i][1] scales[outputName] = { "name": data.name || outputName, "scale": data.scale || 1.0, "width": data.width || 0, "height": data.height || 0, "refresh_rate": data.refresh_rate || 0, "x": data.x || 0, "y": data.y || 0, "active": data.active || false, "focused": data.focused || false } } if (CompositorService && CompositorService.onDisplayScalesUpdated) { CompositorService.onDisplayScalesUpdated(scales) } displayScalesChanged() } // Handle real-time events from mmsg -w event stream and trigger updates function handleEvent(eventLine) { const parts = eventLine.trim().split(/\s+/) if (parts.length < 2) return const eventType = parts[1] switch (eventType) { case "selmon": if (parts.length >= 3) { const monitorName = parts[0] const isSelected = parts[2] === "1" if (isSelected) { selectedMonitor = monitorName Logger.d("MangoService", `Selected monitor changed to: ${monitorName}`) } } updateTimer.restart() break case "tag": case "title": case "appid": case "fullscreen": case "floating": case "layout": case "kb_layout": case "scale_factor": case "toggle": case "last_layer": case "keymode": case "clients": case "tags": updateTimer.restart() break } } // Start workspace query process function queryWorkspaces() { workspacesProcess.running = true } // Start window query process function queryWindows() { windowsProcess.running = true } // Start layout query process function queryLayout() { layoutProcess.running = true } // Start keyboard layout query process function queryKeyboard() { keyboardProcess.running = true } // Start output scales query process function queryOutputs() { outputsProcess.running = true } // Query display scales (alias for queryOutputs) function queryDisplayScales() { queryOutputs() } // Start output enumeration process function queryOutputEnum() { outputEnumProcess.running = true } // Start monitor state query process function queryMonitorState() { monitorStateProcess.running = true } // Safely update all state by querying workspaces, windows, and monitor state function safeUpdate() { try { queryWorkspaces() queryWindows() queryMonitorState() } catch (e) { Logger.e("MangoService", "Safe update failed:", e) } } // Get the ID of the currently active workspace/tag function getCurrentActiveTagId() { const workspaceEntries1 = Object.entries(workspaceCache) for (var i = 0; i < workspaceEntries1.length; i++) { const key = workspaceEntries1[i][0] const tagData = workspaceEntries1[i][1] if (tagData.isActive && tagData.output === selectedMonitor) { return tagData.id } } const workspaceEntries2 = Object.entries(workspaceCache) for (var i = 0; i < workspaceEntries2.length; i++) { const key = workspaceEntries2[i][0] const tagData = workspaceEntries2[i][1] if (tagData.isActive) { return tagData.id } } return defaultWorkspaceId } }