diff --git a/Services/Compositor/CompositorService.qml b/Services/Compositor/CompositorService.qml index 069e468d..f410c2ea 100644 --- a/Services/Compositor/CompositorService.qml +++ b/Services/Compositor/CompositorService.qml @@ -13,6 +13,7 @@ Singleton { property bool isHyprland: false property bool isNiri: false property bool isSway: false + property bool isMango: false // Generic workspace and window data property ListModel workspaces: ListModel {} @@ -50,30 +51,47 @@ Singleton { const hyprlandSignature = Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE") const niriSocket = Quickshell.env("NIRI_SOCKET") const swaySock = Quickshell.env("SWAYSOCK") - if (niriSocket && niriSocket.length > 0) { + const currentDesktop = Quickshell.env("XDG_CURRENT_DESKTOP") + + // Check for MangoWC using XDG_CURRENT_DESKTOP environment variable + // MangoWC sets XDG_CURRENT_DESKTOP=mango + if (currentDesktop && currentDesktop.toLowerCase().includes("mango")) { + isHyprland = false + isNiri = false + isSway = false + isMango = true + backendLoader.sourceComponent = mangoComponent + Logger.i("CompositorService", "MangoWC detected via XDG_CURRENT_DESKTOP:", currentDesktop) + } else if (niriSocket && niriSocket.length > 0) { isHyprland = false isNiri = true isSway = false + isMango = false backendLoader.sourceComponent = niriComponent } else if (hyprlandSignature && hyprlandSignature.length > 0) { isHyprland = true isNiri = false isSway = false + isMango = false backendLoader.sourceComponent = hyprlandComponent } else if (swaySock && swaySock.length > 0) { isHyprland = false isNiri = false isSway = true + isMango = false backendLoader.sourceComponent = swayComponent } else { // Always fallback to Niri isHyprland = false isNiri = true isSway = false + isMango = false backendLoader.sourceComponent = niriComponent } } + + Loader { id: backendLoader onLoaded: { @@ -134,6 +152,14 @@ Singleton { } } + // Mango backend component + Component { + id: mangoComponent + MangoService { + id: mangoBackend + } + } + function setupBackendConnections() { if (!backend) return @@ -161,7 +187,7 @@ Singleton { windowListChanged() }) - // Property bindings + // Property bindings - use automatic property change signal backend.focusedWindowIndexChanged.connect(() => { focusedWindowIndex = backend.focusedWindowIndex }) diff --git a/Services/Compositor/MangoService.qml b/Services/Compositor/MangoService.qml new file mode 100644 index 00000000..2ad63e0f --- /dev/null +++ b/Services/Compositor/MangoService.qml @@ -0,0 +1,799 @@ +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", "Initializing MangoWC service...") + + 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] + + // 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] + 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] + 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] + 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] + 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] + 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, tagNum, state, clients, focused] = tagMatch + 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, clientCount] = clientsMatch + outputClients[outputName] = parseInt(clientCount) + } + + const tagsMatch = trimmed.match(/^(\S+)\s+tags\s+(\d+)\s+(\d+)\s+(\d+)$/) + if (tagsMatch) { + const [, outputName, occ, seltags, urg] = tagsMatch + + const occBits = occ.padStart(9, '0') + const selBits = seltags.padStart(9, '0') + const urgBits = urg.padStart(9, '0') + + for (let 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, // Will be determined by selected monitor + isUrgent: 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 + 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 + + for (const [outputName, data] of Object.entries(windowData)) { + if (data.title || data.appId) { + + const isFocused = (outputName === selectedMonitor) + + + let activeTagId = defaultWorkspaceId + for (const [key, tagData] of Object.entries(workspaceCache)) { + 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 = {} + for (const [outputName, data] of Object.entries(monitorCache)) { + 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() { + for (const [key, tagData] of Object.entries(workspaceCache)) { + if (tagData.isActive && tagData.output === selectedMonitor) { + return tagData.id + } + } + + for (const [key, tagData] of Object.entries(workspaceCache)) { + if (tagData.isActive) { + return tagData.id + } + } + return defaultWorkspaceId + } +}