pragma Singleton pragma ComponentBehavior: Bound import QtQuick import Quickshell import Quickshell.Wayland import Quickshell.Hyprland import qs.Common Singleton { id: root property bool isHyprland: false property bool isNiri: false property bool isDwl: false property bool isSway: false property string compositor: "unknown" readonly property string hyprlandSignature: Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE") readonly property string niriSocket: Quickshell.env("NIRI_SOCKET") readonly property string swaySocket: Quickshell.env("SWAYSOCK") property bool useNiriSorting: isNiri && NiriService property var sortedToplevels: sortedToplevelsCache property var sortedToplevelsCache: [] property bool _sortScheduled: false property bool _refreshScheduled: false property bool _hasRefreshedOnce: false property var _coordCache: ({}) property int _refreshCount: 0 property real _refreshWindowStart: 0 readonly property int _maxRefreshesPerSecond: 3 function getScreenScale(screen) { if (!screen) return 1 if (isNiri && screen) { const niriScale = NiriService.displayScales[screen.name] if (niriScale !== undefined) return niriScale } if (isHyprland && screen) { const hyprlandMonitor = Hyprland.monitors.values.find(m => m.name === screen.name) if (hyprlandMonitor?.scale !== undefined) return hyprlandMonitor.scale } if (isDwl && screen) { const dwlScale = DwlService.getOutputScale(screen.name) if (dwlScale !== undefined && dwlScale > 0) return dwlScale } return screen?.devicePixelRatio || 1 } Timer { id: refreshTimer interval: 40 repeat: false onTriggered: { try { Hyprland.refreshToplevels() } catch(e) {} _refreshScheduled = false _hasRefreshedOnce = true scheduleSort() } } function scheduleSort() { if (_sortScheduled) return _sortScheduled = true Qt.callLater(function() { _sortScheduled = false sortedToplevelsCache = computeSortedToplevels() }) } function scheduleRefresh() { if (!isHyprland) return if (_refreshScheduled) return const now = Date.now() if (now - _refreshWindowStart > 1000) { _refreshCount = 0 _refreshWindowStart = now } if (_refreshCount >= _maxRefreshesPerSecond) { console.warn("CompositorService: Refresh rate limit exceeded, skipping refresh") return } _refreshCount++ _refreshScheduled = true refreshTimer.restart() } Connections { target: ToplevelManager.toplevels function onValuesChanged() { root.scheduleSort() } } Connections { target: Hyprland.toplevels function onValuesChanged() { root._hasRefreshedOnce = false root.scheduleSort() } } Connections { target: Hyprland.workspaces function onValuesChanged() { root.scheduleSort() } } Connections { target: Hyprland function onFocusedWorkspaceChanged() { root.scheduleSort() } } Connections { target: NiriService function onWindowsChanged() { root.scheduleSort() } } Component.onCompleted: { detectCompositor() scheduleSort() Qt.callLater(() => NiriService.generateNiriLayoutConfig()) } Connections { target: DwlService function onStateChanged() { if (isDwl && !isHyprland && !isNiri) { scheduleSort() } } } function computeSortedToplevels() { if (!ToplevelManager.toplevels || !ToplevelManager.toplevels.values) return [] if (useNiriSorting) return NiriService.sortToplevels(ToplevelManager.toplevels.values) if (isHyprland) return sortHyprlandToplevelsSafe() return Array.from(ToplevelManager.toplevels.values) } function _get(o, path, fallback) { try { let v = o for (let i = 0; i < path.length; i++) { if (v === null || v === undefined) return fallback v = v[path[i]] } return (v === undefined || v === null) ? fallback : v } catch (e) { return fallback } } function sortHyprlandToplevelsSafe() { if (!Hyprland.toplevels || !Hyprland.toplevels.values) return [] if (_refreshScheduled) return sortedToplevelsCache const items = Array.from(Hyprland.toplevels.values) function _get(o, path, fb) { try { let v = o for (let k of path) { if (v == null) return fb; v = v[k] } return (v == null) ? fb : v } catch(e) { return fb } } let currentAddresses = new Set() for (let i = 0; i < items.length; i++) { const addr = items[i]?.address if (addr) currentAddresses.add(addr) } for (let cachedAddr in _coordCache) { if (!currentAddresses.has(cachedAddr)) { delete _coordCache[cachedAddr] } } let snap = [] let missingAnyPosition = false let hasNewWindow = false for (let i = 0; i < items.length; i++) { const t = items[i] if (!t) continue const addr = t.address || "" const li = t.lastIpcObject || null const monName = _get(li, ["monitor"], null) ?? _get(t, ["monitor", "name"], "") const monX = _get(t, ["monitor", "x"], Number.MAX_SAFE_INTEGER) const monY = _get(t, ["monitor", "y"], Number.MAX_SAFE_INTEGER) const wsId = _get(li, ["workspace", "id"], null) ?? _get(t, ["workspace", "id"], Number.MAX_SAFE_INTEGER) const at = _get(li, ["at"], null) let atX = (at !== null && at !== undefined && typeof at[0] === "number") ? at[0] : NaN let atY = (at !== null && at !== undefined && typeof at[1] === "number") ? at[1] : NaN if (!(atX === atX) || !(atY === atY)) { const cached = _coordCache[addr] if (cached) { atX = cached.x atY = cached.y } else { if (addr) hasNewWindow = true missingAnyPosition = true atX = 1e9 atY = 1e9 } } else if (addr) { _coordCache[addr] = { x: atX, y: atY } } const relX = Number.isFinite(monX) ? (atX - monX) : atX const relY = Number.isFinite(monY) ? (atY - monY) : atY snap.push({ monKey: String(monName), monOrderX: Number.isFinite(monX) ? monX : Number.MAX_SAFE_INTEGER, monOrderY: Number.isFinite(monY) ? monY : Number.MAX_SAFE_INTEGER, wsId: (typeof wsId === "number") ? wsId : Number.MAX_SAFE_INTEGER, x: relX, y: relY, title: t.title || "", address: addr, wayland: t.wayland }) } if (missingAnyPosition && hasNewWindow) { _hasRefreshedOnce = false scheduleRefresh() } const groups = new Map() for (const it of snap) { const key = it.monKey + "::" + it.wsId if (!groups.has(key)) groups.set(key, []) groups.get(key).push(it) } let groupList = [] for (const [key, arr] of groups) { const repr = arr[0] groupList.push({ key, monKey: repr.monKey, monOrderX: repr.monOrderX, monOrderY: repr.monOrderY, wsId: repr.wsId, items: arr }) } groupList.sort((a, b) => { if (a.monOrderX !== b.monOrderX) return a.monOrderX - b.monOrderX if (a.monOrderY !== b.monOrderY) return a.monOrderY - b.monOrderY if (a.monKey !== b.monKey) return a.monKey.localeCompare(b.monKey) if (a.wsId !== b.wsId) return a.wsId - b.wsId return 0 }) const COLUMN_THRESHOLD = 48 const JITTER_Y = 6 let ordered = [] for (const g of groupList) { const arr = g.items const xs = arr.map(it => it.x).filter(x => Number.isFinite(x)).sort((a, b) => a - b) let colCenters = [] if (xs.length > 0) { for (const x of xs) { if (colCenters.length === 0) { colCenters.push(x) } else { const last = colCenters[colCenters.length - 1] if (x - last >= COLUMN_THRESHOLD) { colCenters.push(x) } } } } else { colCenters = [0] } for (const it of arr) { let bestCol = 0 let bestDist = Number.POSITIVE_INFINITY for (let ci = 0; ci < colCenters.length; ci++) { const d = Math.abs(it.x - colCenters[ci]) if (d < bestDist) { bestDist = d bestCol = ci } } it._col = bestCol } arr.sort((a, b) => { if (a._col !== b._col) return a._col - b._col const dy = a.y - b.y if (Math.abs(dy) > JITTER_Y) return dy if (a.title !== b.title) return a.title.localeCompare(b.title) if (a.address !== b.address) return a.address.localeCompare(b.address) return 0 }) ordered.push.apply(ordered, arr) } return ordered.map(x => x.wayland).filter(w => w !== null && w !== undefined) } function filterCurrentWorkspace(toplevels, screen) { if (useNiriSorting) return NiriService.filterCurrentWorkspace(toplevels, screen) if (isHyprland) return filterHyprlandCurrentWorkspaceSafe(toplevels, screen) return toplevels } function filterHyprlandCurrentWorkspaceSafe(toplevels, screenName) { if (!toplevels || toplevels.length === 0 || !Hyprland.toplevels) return toplevels let currentWorkspaceId = null try { const hy = Array.from(Hyprland.toplevels.values) for (const t of hy) { const mon = _get(t, ["monitor", "name"], "") const wsId = _get(t, ["workspace", "id"], null) const active = !!_get(t, ["activated"], false) if (mon === screenName && wsId !== null) { if (active) { currentWorkspaceId = wsId; break } if (currentWorkspaceId === null) currentWorkspaceId = wsId } } if (currentWorkspaceId === null && Hyprland.workspaces) { const wss = Array.from(Hyprland.workspaces.values) const focusedId = _get(Hyprland, ["focusedWorkspace", "id"], null) for (const ws of wss) { const monName = _get(ws, ["monitor"], "") const wsId = _get(ws, ["id"], null) if (monName === screenName && wsId !== null) { if (focusedId !== null && wsId === focusedId) { currentWorkspaceId = wsId; break } if (currentWorkspaceId === null) currentWorkspaceId = wsId } } } } catch (e) { console.warn("CompositorService: workspace snapshot failed:", e) } if (currentWorkspaceId === null) return toplevels // Map wayland → wsId snapshot let map = new Map() try { const hy = Array.from(Hyprland.toplevels.values) for (const t of hy) { const wsId = _get(t, ["workspace", "id"], null) if (t && t.wayland && wsId !== null) map.set(t.wayland, wsId) } } catch (e) {} return toplevels.filter(w => map.get(w) === currentWorkspaceId) } Timer { id: compositorInitTimer interval: 100 running: true repeat: false onTriggered: { detectCompositor() Qt.callLater(() => NiriService.generateNiriLayoutConfig()) } } function detectCompositor() { if (hyprlandSignature && hyprlandSignature.length > 0) { isHyprland = true isNiri = false isDwl = false isSway = false compositor = "hyprland" console.info("CompositorService: Detected Hyprland") try { Hyprland.refreshToplevels() } catch(e) {} return } if (niriSocket && niriSocket.length > 0) { Proc.runCommand("niriSocketCheck", ["test", "-S", niriSocket], (output, exitCode) => { if (exitCode === 0) { isNiri = true isHyprland = false isDwl = false isSway = false compositor = "niri" console.info("CompositorService: Detected Niri with socket:", niriSocket) NiriService.generateNiriBinds() NiriService.generateNiriBlurrule() } }, 0) return } if (swaySocket && swaySocket.length > 0) { Proc.runCommand("swaySocketCheck", ["test", "-S", swaySocket], (output, exitCode) => { if (exitCode === 0) { isNiri = false isHyprland = false isDwl = false isSway = true compositor = "sway" console.info("CompositorService: Detected Sway with socket:", swaySocket) } }, 0) return } if (DMSService.dmsAvailable) { Qt.callLater(checkForDwl) } else { isHyprland = false isNiri = false isDwl = false isSway = false compositor = "unknown" console.warn("CompositorService: No compositor detected") } } Connections { target: DMSService function onCapabilitiesReceived() { if (!isHyprland && !isNiri && !isDwl) { checkForDwl() } } } function checkForDwl() { if (DMSService.apiVersion >= 12 && DMSService.capabilities.includes("dwl")) { isHyprland = false isNiri = false isDwl = true compositor = "dwl" console.info("CompositorService: Detected DWL via DMS capability") } } function powerOffMonitors() { if (isNiri) return NiriService.powerOffMonitors() if (isHyprland) return Hyprland.dispatch("dpms off") if (isDwl) return _dwlPowerOffMonitors() if (isSway) { try { I3.dispatch("output * dpms off") } catch(_){} return } console.warn("CompositorService: Cannot power off monitors, unknown compositor") } function powerOnMonitors() { if (isNiri) return NiriService.powerOnMonitors() if (isHyprland) return Hyprland.dispatch("dpms on") if (isDwl) return _dwlPowerOnMonitors() if (isSway) { try { I3.dispatch("output * dpms on") } catch(_){} return } console.warn("CompositorService: Cannot power on monitors, unknown compositor") } function _dwlPowerOffMonitors() { if (!Quickshell.screens || Quickshell.screens.length === 0) { console.warn("CompositorService: No screens available for DWL power off") return } for (let i = 0; i < Quickshell.screens.length; i++) { const screen = Quickshell.screens[i] if (screen && screen.name) { Quickshell.execDetached(["mmsg", "-d", "disable_monitor," + screen.name]) } } } function _dwlPowerOnMonitors() { if (!Quickshell.screens || Quickshell.screens.length === 0) { console.warn("CompositorService: No screens available for DWL power on") return } for (let i = 0; i < Quickshell.screens.length; i++) { const screen = Quickshell.screens[i] if (screen && screen.name) { Quickshell.execDetached(["mmsg", "-d", "enable_monitor," + screen.name]) } } } }