pragma Singleton pragma ComponentBehavior: Bound import QtCore import QtQuick import Quickshell import Quickshell.Io import qs.Common import qs.Services // Native MangoWM IPC client. mango advertises a JSON-over-Unix-socket protocol // via MANGO_INSTANCE_SIGNATURE; each connection issues one `watch ` verb // and gets a full JSON snapshot followed by newline-delimited updates. Replaces // the legacy dwl-ipc-v2 path (DwlService) for mango, exposing a // DwlService-compatible tag API plus a per-client window list. Singleton { id: root readonly property var log: Log.scoped("MangoService") readonly property string socketPath: Quickshell.env("MANGO_INSTANCE_SIGNATURE") readonly property bool available: socketPath.length > 0 readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) readonly property string configPath: configDir + "/mango/config.conf" readonly property string mangoDmsDir: configDir + "/mango/dms" readonly property string bindsPath: mangoDmsDir + "/binds.conf" readonly property string colorsPath: mangoDmsDir + "/colors.conf" readonly property string outputsPath: mangoDmsDir + "/outputs.conf" readonly property string layoutPath: mangoDmsDir + "/layout.conf" readonly property string cursorPath: mangoDmsDir + "/cursor.conf" readonly property string windowRulesPath: mangoDmsDir + "/windowrules.conf" property int _lastGapValue: -1 property real _ignoreWatchedReloadUntil: 0 property real _lastWatchedReloadAt: 0 // name -> { name, active, x, y, width, height, scale, layoutIndex, // layoutSymbol, lastOpenSurface, kbLayout, keymode, // tags: [{ tag, state, clients, focused, urgent, layout }] } property var outputs: ({}) property string activeOutput: "" readonly property bool inOverview: isOutputInOverview(activeOutput) property int tagCount: 9 property var displayScales: ({}) property string currentKeyboardLayout: "" // Rich client list from `watch all-clients` (mango "clients"). property var windows: [] // windowsChanged is auto-generated by the `windows` property's change signal. signal stateChanged // ── State sockets ────────────────────────────────────────────────────── // One connection per watch target; mango streams a fresh full snapshot on // every change, so each line is treated as the complete state. FileView { id: mangoConfigWatcher path: CompositorService.isMango ? root.configPath : "" watchChanges: CompositorService.isMango onFileChanged: root.handleWatchedConfigChanged() } FileView { id: mangoBindsWatcher path: CompositorService.isMango ? root.bindsPath : "" watchChanges: CompositorService.isMango onFileChanged: root.handleWatchedConfigChanged() } FileView { id: mangoColorsWatcher path: CompositorService.isMango ? root.colorsPath : "" watchChanges: CompositorService.isMango onFileChanged: root.handleWatchedConfigChanged() } FileView { id: mangoLayoutWatcher path: CompositorService.isMango ? root.layoutPath : "" watchChanges: CompositorService.isMango onFileChanged: root.handleWatchedConfigChanged() } FileView { id: mangoCursorWatcher path: CompositorService.isMango ? root.cursorPath : "" watchChanges: CompositorService.isMango onFileChanged: root.handleWatchedConfigChanged() } FileView { id: mangoOutputsWatcher path: CompositorService.isMango ? root.outputsPath : "" watchChanges: CompositorService.isMango onFileChanged: root.handleWatchedConfigChanged() } FileView { id: mangoWindowRulesWatcher path: CompositorService.isMango ? root.windowRulesPath : "" watchChanges: CompositorService.isMango onFileChanged: root.handleWatchedConfigChanged() } DankSocket { id: monitorsSocket path: root.socketPath connected: root.available onConnectionStateChanged: { if (connected) send("watch all-monitors"); } parser: SplitParser { onRead: line => root._handleMonitors(line) } } DankSocket { id: clientsSocket path: root.socketPath connected: root.available onConnectionStateChanged: { if (connected) send("watch all-clients"); } parser: SplitParser { onRead: line => root._handleClients(line) } } function _handleMonitors(line) { if (!line || !line.trim()) return; let data; try { data = JSON.parse(line); } catch (e) { log.warn("Failed to parse all-monitors:", e); return; } const monitors = data.monitors; if (!Array.isArray(monitors)) return; const newOutputs = {}; const newScales = {}; let newActive = ""; let newTagCount = root.tagCount; let newKbLayout = root.currentKeyboardLayout; for (const m of monitors) { if (!m.name) continue; const activeTags = m.active_tags || []; const inOverview = activeTags.length === 0 || activeTags.every(t => t === 0); const tags = (m.tags || []).map(t => ({ // 0-based to match the legacy dwl tag model used by consumers "tag": (t.index ?? 1) - 1, "state": t.is_urgent ? 2 : (!inOverview && t.is_active ? 1 : 0), "clients": t.client_count ?? 0, "focused": !inOverview && !!t.is_active, "urgent": !!t.is_urgent, "layout": t.layout ?? "" })); newOutputs[m.name] = { "name": m.name, "active": !!m.active, "x": m.x ?? 0, "y": m.y ?? 0, "width": m.width ?? 0, "height": m.height ?? 0, "scale": m.scale ?? 1.0, "layoutIndex": m.layout_index ?? 0, "layout": m.layout_index ?? 0, "activeTags": activeTags, "inOverview": inOverview, "layoutSymbol": m.layout_symbol ?? "", "lastOpenSurface": m.last_open_surface ?? "", "keymode": m.keymode ?? "", "kbLayout": m.keyboardlayout ?? "", "tags": tags }; if (typeof m.scale === "number" && m.scale > 0) newScales[m.name] = m.scale; if (m.active) { newActive = m.name; if (m.keyboardlayout) newKbLayout = m.keyboardlayout; } if (tags.length > 0) newTagCount = tags.length; } root.outputs = newOutputs; root.displayScales = newScales; root.tagCount = newTagCount; if (newActive) root.activeOutput = newActive; root.currentKeyboardLayout = newKbLayout; root.stateChanged(); } function _handleClients(line) { if (!line || !line.trim()) return; let data; try { data = JSON.parse(line); } catch (e) { log.warn("Failed to parse all-clients:", e); return; } if (!Array.isArray(data.clients)) return; root.windows = data.clients; } // ── DwlService-compatible tag API ────────────────────────────────────── function getOutputState(outputName) { return (outputs && outputs[outputName]) ? outputs[outputName] : null; } function getActiveTags(outputName) { const output = getOutputState(outputName); if (!output || !output.tags) return []; return output.tags.filter(tag => tag.state === 1).map(tag => tag.tag); } // mango reports active_tags=[0] (no real tag selected) while the overview is open. function isOutputInOverview(outputName) { const output = getOutputState(outputName); if (!output) return false; if (output.inOverview !== undefined) return output.inOverview; const at = output.activeTags || []; return at.length === 0 || at.every(t => t === 0); } function getTagsWithClients(outputName) { const output = getOutputState(outputName); if (!output || !output.tags) return []; return output.tags.filter(tag => tag.clients > 0).map(tag => tag.tag); } function getUrgentTags(outputName) { const output = getOutputState(outputName); if (!output || !output.tags) return []; return output.tags.filter(tag => tag.state === 2).map(tag => tag.tag); } function getVisibleTags(outputName) { const output = getOutputState(outputName); if (!output || !output.tags) return []; if (isOutputInOverview(outputName)) return []; const visibleTags = new Set(); output.tags.forEach(tag => { if (tag.state === 1 || tag.clients > 0) visibleTags.add(tag.tag); }); return Array.from(visibleTags).sort((a, b) => a - b); } function getOutputScale(outputName) { return displayScales[outputName]; } // ── Window list ↔ wlr toplevel matching (per-tag sort/filter) ────────── // Match mango clients to wlr foreign-toplevels by appId+title to enrich them // with owning tags/monitor for per-tag filtering and stable ordering. function _screenName(screenOrName) { return (typeof screenOrName === "string") ? screenOrName : (screenOrName?.name ?? ""); } function _orderedClients() { const list = (windows || []).slice(); list.sort((a, b) => { const ma = outputs[a.monitor], mb = outputs[b.monitor]; const ax = ma?.x ?? 1e9, ay = ma?.y ?? 1e9; const bx = mb?.x ?? 1e9, by = mb?.y ?? 1e9; if (ax !== bx) return ax - bx; if (ay !== by) return ay - by; if ((a.y ?? 0) !== (b.y ?? 0)) return (a.y ?? 0) - (b.y ?? 0); if ((a.x ?? 0) !== (b.x ?? 0)) return (a.x ?? 0) - (b.x ?? 0); return (a.id ?? 0) - (b.id ?? 0); }); return list; } function _matchAndEnrich(toplevels, clients) { const used = new Set(); const result = []; for (const client of clients) { let bestMatch = null; let bestScore = -1; for (const toplevel of toplevels) { if (used.has(toplevel)) continue; if (toplevel.appId !== client.appid) continue; let score = 1; if (client.title && toplevel.title) { if (toplevel.title === client.title) score = 3; else if (toplevel.title.includes(client.title) || client.title.includes(toplevel.title)) score = 2; } if (score > bestScore) { bestScore = score; bestMatch = toplevel; if (score === 3) break; } } if (!bestMatch) continue; used.add(bestMatch); const enriched = { "appId": bestMatch.appId, "title": bestMatch.title, "activated": !!client.is_focused, "mangoWindowId": client.id, "mangoTags": client.tags || [], "mangoMonitor": client.monitor }; for (let prop in bestMatch) { if (!(prop in enriched)) enriched[prop] = bestMatch[prop]; } result.push(enriched); } return result; } function sortToplevels(toplevels) { if (!toplevels || toplevels.length === 0 || windows.length === 0) return [...toplevels]; const enriched = _matchAndEnrich(toplevels, _orderedClients()); const used = new Set(enriched.map(e => e.mangoWindowId)); // Append wlr toplevels that had no mango client match (rare). const matchedTitles = new Set(enriched.map(e => e.title + "\u0000" + e.appId)); for (const t of toplevels) { if (!matchedTitles.has((t.title || "") + "\u0000" + (t.appId || ""))) enriched.push(t); } return enriched; } function _activeTagSet(screenName) { const out = outputs[screenName]; return new Set((out?.activeTags) || []); } function filterCurrentWorkspace(toplevels, screenOrName) { const screenName = _screenName(screenOrName); if (!screenName) return toplevels; const active = _activeTagSet(screenName); if (active.size === 0) return toplevels; const onActive = tags => (tags || []).some(t => active.has(t)); if (toplevels.length > 0 && toplevels[0].mangoTags !== undefined) return toplevels.filter(t => t.mangoMonitor === screenName && onActive(t.mangoTags)); const clients = (windows || []).filter(c => c.monitor === screenName && onActive(c.tags)); return _matchAndEnrich(toplevels, clients); } function filterCurrentDisplay(toplevels, screenOrName) { const screenName = _screenName(screenOrName); if (!toplevels || toplevels.length === 0 || !screenName) return toplevels; if (toplevels.length > 0 && toplevels[0].mangoMonitor !== undefined) return toplevels.filter(t => t.mangoMonitor === screenName); const clients = (windows || []).filter(c => c.monitor === screenName); return _matchAndEnrich(toplevels, clients); } // ── Commands (mango verb IPC: mmsg dispatch ,) ───────────── function suppressWatchedConfigReloads(ms) { root._ignoreWatchedReloadUntil = Math.max(root._ignoreWatchedReloadUntil, Date.now() + (ms || 1500)); } function handleWatchedConfigChanged() { if (!CompositorService.isMango || !root.available) return; const now = Date.now(); if (now < root._ignoreWatchedReloadUntil) return; if (now - root._lastWatchedReloadAt < 700) return; root._lastWatchedReloadAt = now; root.reloadConfig(true, false); } function reloadConfig(showToast, suppressWatch) { const shouldShowToast = showToast !== false; const shouldSuppressWatch = suppressWatch !== false; if (shouldSuppressWatch) suppressWatchedConfigReloads(1500); Proc.runCommand("mango-reload", ["mmsg", "dispatch", "reload_config"], (output, exitCode) => { if (exitCode !== 0) { log.warn("mmsg reload_config failed:", output); if (shouldShowToast) ToastService.showError(I18n.tr("mango: failed to reload config"), output || "", "", "mango-config"); return; } if (shouldShowToast) ToastService.showInfo(I18n.tr("mango: config reloaded"), "", "", "mango-config"); }); } function quit() { Quickshell.execDetached(["mmsg", "dispatch", "quit"]); } // mango tag dispatches act on the focused monitor; tagIndex is 0-based // (dwl model), mango `view`/`toggleview` take a 1-based tag number. function switchToTag(outputName, tagIndex) { Quickshell.execDetached(["mmsg", "dispatch", "view," + (tagIndex + 1)]); } function toggleTag(outputName, tagIndex) { Quickshell.execDetached(["mmsg", "dispatch", "toggleview," + (tagIndex + 1)]); } // mango's tiling layouts are a fixed compiled-in set the IPC doesn't expose, // so mirror it here in mango's layouts[] order (layout_index aligns). The // parallel name list exists because `setlayout` dispatches by name, not index. readonly property var layouts: ["T", "S", "G", "M", "K", "CT", "RT", "VS", "VT", "VG", "VK", "DW", "F", "VF"] readonly property var _layoutNames: ["tile", "scroller", "grid", "monocle", "deck", "center_tile", "right_tile", "vertical_scroller", "vertical_tile", "vertical_grid", "vertical_deck", "dwindle", "fair", "vertical_fair"] function setLayout(outputName, index) { const name = _layoutNames[index]; if (name) Quickshell.execDetached(["mmsg", "dispatch", "setlayout," + name]); } function cycleKeyboardLayout() { Quickshell.execDetached(["mmsg", "dispatch", "switch_keyboard_layout"]); } function powerOffMonitors() { const screens = Quickshell.screens || []; for (let i = 0; i < screens.length; i++) { if (screens[i] && screens[i].name) Quickshell.execDetached(["mmsg", "dispatch", "disable_monitor," + screens[i].name]); } } function powerOnMonitors() { const screens = Quickshell.screens || []; for (let i = 0; i < screens.length; i++) { if (screens[i] && screens[i].name) Quickshell.execDetached(["mmsg", "dispatch", "enable_monitor," + screens[i].name]); } } // ── Config generation (mango config fragments under ~/.config/mango/dms) ─ Connections { target: SettingsData function onBarConfigsChanged() { if (!CompositorService.isMango) return; const newGaps = Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)); if (newGaps === root._lastGapValue) return; root._lastGapValue = newGaps; generateLayoutConfig(); } } Connections { target: CompositorService function onIsMangoChanged() { if (CompositorService.isMango) generateLayoutConfig(); } } function transformToMango(transform) { switch (transform) { case "Normal": return 0; case "90": return 1; case "180": return 2; case "270": return 3; case "Flipped": return 4; case "Flipped90": return 5; case "Flipped180": return 6; case "Flipped270": return 7; default: return 0; } } function generateOutputsConfig(outputsData, callback) { if (!outputsData || Object.keys(outputsData).length === 0) { if (callback) callback(false); return; } let lines = ["# Auto-generated by DMS - do not edit manually", ""]; for (const outputName in outputsData) { const output = outputsData[outputName]; if (!output) continue; let width = 1920; let height = 1080; let refreshRate = 60; if (output.modes && output.current_mode !== undefined) { const mode = output.modes[output.current_mode]; if (mode) { width = mode.width || 1920; height = mode.height || 1080; refreshRate = Math.round((mode.refresh_rate || 60000) / 1000); } } const x = output.logical?.x ?? 0; const y = output.logical?.y ?? 0; const scale = output.logical?.scale ?? 1.0; const transform = transformToMango(output.logical?.transform ?? "Normal"); const vrr = output.vrr_enabled ? 1 : 0; // Anchor the name regex: mango matches `name:` unanchored (first-match // wins), so a bare "DP-1" would also match "eDP-1" and collapse outputs. const rule = ["name:^" + outputName + "$", "width:" + width, "height:" + height, "refresh:" + refreshRate, "x:" + x, "y:" + y, "scale:" + scale, "rr:" + transform, "vrr:" + vrr].join(","); lines.push("monitorrule=" + rule); } lines.push(""); const content = lines.join("\n"); Proc.runCommand("mango-write-outputs", ["sh", "-c", `mkdir -p "${mangoDmsDir}" && cat > "${outputsPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => { if (exitCode !== 0) { log.warn("Failed to write outputs config:", output); if (callback) callback(false); return; } log.info("Generated outputs config at", outputsPath); if (CompositorService.isMango) reloadConfig(); if (callback) callback(true); }); } function generateLayoutConfig() { if (!CompositorService.isMango) return; const defaultRadius = typeof SettingsData !== "undefined" ? SettingsData.cornerRadius : 12; const defaultGaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)) : 4; const defaultBorderSize = 2; const cornerRadius = (typeof SettingsData !== "undefined" && SettingsData.mangoLayoutRadiusOverride >= 0) ? SettingsData.mangoLayoutRadiusOverride : defaultRadius; const gaps = (typeof SettingsData !== "undefined" && SettingsData.mangoLayoutGapsOverride >= 0) ? SettingsData.mangoLayoutGapsOverride : defaultGaps; const borderSize = (typeof SettingsData !== "undefined" && SettingsData.mangoLayoutBorderSize >= 0) ? SettingsData.mangoLayoutBorderSize : defaultBorderSize; let content = `# Auto-generated by DMS - do not edit manually border_radius=${cornerRadius} gappih=${gaps} gappiv=${gaps} gappoh=${gaps} gappov=${gaps} borderpx=${borderSize} `; Proc.runCommand("mango-write-layout", ["sh", "-c", `mkdir -p "${mangoDmsDir}" && cat > "${layoutPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => { if (exitCode !== 0) { log.warn("Failed to write layout config:", output); return; } log.info("Generated layout config at", layoutPath); reloadConfig(); }); } function generateCursorConfig() { if (!CompositorService.isMango) return; const settings = typeof SettingsData !== "undefined" ? SettingsData.cursorSettings : null; if (!settings) { Proc.runCommand("mango-write-cursor", ["sh", "-c", `mkdir -p "${mangoDmsDir}" && : > "${cursorPath}"`], (output, exitCode) => { if (exitCode !== 0) log.warn("Failed to write cursor config:", output); }); return; } const themeName = settings.theme === "System Default" ? (SettingsData.systemDefaultCursorTheme || "") : settings.theme; const size = settings.size || 24; const hideTimeout = settings.mango?.cursorHideTimeout || 0; const naturalScrolling = SettingsData.mangoTrackpadNaturalScrolling ? 1 : 0; let content = `# Auto-generated by DMS - do not edit manually trackpad_natural_scrolling=${naturalScrolling} cursor_size=${size}`; if (themeName) content += `\ncursor_theme=${themeName}`; if (hideTimeout > 0) content += `\ncursor_hide_timeout=${hideTimeout}`; content += `\n`; Proc.runCommand("mango-write-cursor", ["sh", "-c", `mkdir -p "${mangoDmsDir}" && cat > "${cursorPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => { if (exitCode !== 0) { log.warn("Failed to write cursor config:", output); return; } log.info("Generated cursor config at", cursorPath); reloadConfig(); }); } }