pragma Singleton import QtQuick import Quickshell import Quickshell.Io import Quickshell.Hyprland import qs.Commons import qs.Services Singleton { id: root // Generic compositor properties property string compositorType: "unknown" // "hyprland", "niri", or "unknown" property bool isHyprland: false property bool isNiri: false readonly property string hyprlandSignature: Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE") // Generic workspace and window data property ListModel workspaces: ListModel {} property var windows: [] property int focusedWindowIndex: -1 property string focusedWindowTitle: "n/a" property bool inOverview: false // Generic events signal workspaceChanged signal activeWindowChanged signal overviewStateChanged signal windowListChanged signal windowTitleChanged // Debounce timer for updates property Timer updateTimer: Timer { interval: 50 // 50ms debounce repeat: false onTriggered: { try { updateHyprlandWindows() updateHyprlandWorkspaces() windowListChanged() } catch (e) { Logger.error("Compositor", "Error in debounced update:", e) } } } // Compositor detection Component.onCompleted: { detectCompositor() } // Hyprland connections Loader { active: isHyprland sourceComponent: Component { Item { Connections { target: Hyprland.workspaces enabled: isHyprland function onValuesChanged() { try { updateHyprlandWorkspaces() workspaceChanged() } catch (e) { Logger.error("Compositor", "Error in workspaces onValuesChanged:", e) } } } Connections { target: Hyprland.toplevels enabled: isHyprland function onValuesChanged() { try { // Use debounced update to prevent too frequent calls updateTimer.restart() } catch (e) { Logger.error("Compositor", "Error in toplevels onValuesChanged:", e) } } } Connections { target: Hyprland enabled: isHyprland function onRawEvent(event) { try { updateHyprlandWorkspaces() workspaceChanged() updateTimer.restart() } catch (e) { Logger.error("Compositor", "Error in rawEvent:", e) } } } } } } function detectCompositor() { try { // Try Hyprland first if (hyprlandSignature && hyprlandSignature.length > 0) { compositorType = "hyprland" isHyprland = true isNiri = false initHyprland() return } } catch (e) { // Hyprland not available } // Try Niri (always available since we handle it directly) compositorType = "niri" isHyprland = false isNiri = true initNiri() return // No supported compositor found compositorType = "unknown" isHyprland = false isNiri = false Logger.warn("Compositor", "No supported compositor detected") } // Hyprland integration function initHyprland() { try { Hyprland.refreshWorkspaces() Hyprland.refreshToplevels() updateHyprlandWorkspaces() updateHyprlandWindows() setupHyprlandConnections() Logger.log("Compositor", "Hyprland initialized successfully") } catch (e) { Logger.error("Compositor", "Error initializing Hyprland:", e) compositorType = "unknown" isHyprland = false } } function setupHyprlandConnections() {// Connections are set up at the top level, this function just marks that Hyprland is ready } function updateHyprlandWorkspaces() { if (!isHyprland) return workspaces.clear() try { const hlWorkspaces = Hyprland.workspaces.values // Determine occupied workspace ids from current toplevels const occupiedIds = {} try { const hlToplevels = Hyprland.toplevels.values for (var t = 0; t < hlToplevels.length; t++) { const toplevel = hlToplevels[t] if (toplevel) { try { const tws = toplevel.workspace?.id if (tws !== undefined && tws !== null) { occupiedIds[tws] = true } } catch (toplevelError) { // Ignore errors from individual toplevels continue } } } } catch (e2) { // ignore occupancy errors; fall back to false } for (var i = 0; i < hlWorkspaces.length; i++) { const ws = hlWorkspaces[i] if (!ws) continue try { // Only append workspaces with id >= 1 if (ws.id >= 1) { workspaces.append({ "id": i, "idx": ws.id, "name": ws.name || "", "output": ws.monitor?.name || "", "isActive": ws.active === true, "isFocused": ws.focused === true, "isUrgent": ws.urgent === true, "isOccupied": occupiedIds[ws.id] === true }) } } catch (workspaceError) { Logger.warn("Compositor", "Error processing workspace at index", i, ":", workspaceError) continue } } } catch (e) { Logger.error("Compositor", "Error updating Hyprland workspaces:", e) } } function updateHyprlandWindows() { if (!isHyprland) return try { const hlToplevels = Hyprland.toplevels.values const windowsList = [] for (var i = 0; i < hlToplevels.length; i++) { const toplevel = hlToplevels[i] // Skip if toplevel is null or invalid if (!toplevel) { continue } try { // Try to get appId from various sources with proper null checks let appId = "" // First try the direct properties with null/undefined checks try { if (toplevel.class !== undefined && toplevel.class !== null) { appId = String(toplevel.class) } else if (toplevel.initialClass !== undefined && toplevel.initialClass !== null) { appId = String(toplevel.initialClass) } else if (toplevel.appId !== undefined && toplevel.appId !== null) { appId = String(toplevel.appId) } } catch (propertyError) { // Ignore property access errors and continue with empty appId } // If still no appId, try to get it from the lastIpcObject if (!appId) { try { const ipcData = toplevel.lastIpcObject if (ipcData) { appId = String(ipcData.class || ipcData.initialClass || ipcData.appId || ipcData.wm_class || "") } } catch (ipcError) { // Ignore errors when accessing lastIpcObject } } // Safely get other properties with fallbacks let windowId = "" let windowTitle = "" let workspaceId = null let isActivated = false try { windowId = (toplevel.address !== undefined && toplevel.address !== null) ? String(toplevel.address) : "" } catch (e) { windowId = "" } try { windowTitle = (toplevel.title !== undefined && toplevel.title !== null) ? String(toplevel.title) : "" } catch (e) { windowTitle = "" } try { workspaceId = toplevel.workspace?.id || null } catch (e) { workspaceId = null } try { isActivated = toplevel.activated === true } catch (e) { isActivated = false } windowsList.push({ "id": windowId, "title": windowTitle, "appId": appId, "workspaceId": workspaceId, "isFocused": isActivated }) } catch (toplevelError) { // Log the error but continue processing other toplevels Logger.warn("Compositor", "Error processing toplevel at index", i, ":", toplevelError) continue } } windows = windowsList // Update focused window index focusedWindowIndex = -1 for (var j = 0; j < windowsList.length; j++) { if (windowsList[j].isFocused) { focusedWindowIndex = j break } } updateFocusedWindowTitle() activeWindowChanged() } catch (e) { Logger.error("Compositor", "Error updating Hyprland windows:", e) // Don't crash, just keep the previous windows list } } // Niri integration function initNiri() { try { // Start the event stream to receive Niri events niriEventStream.running = true // Initial load of workspaces and windows updateNiriWorkspaces() updateNiriWindows() Logger.log("Compositor", "Niri initialized successfully") } catch (e) { Logger.error("Compositor", "Error initializing Niri:", e) compositorType = "unknown" isNiri = false } } function updateNiriWorkspaces() { if (!isNiri) return // Get workspaces from the Niri process niriWorkspaceProcess.running = true } function updateNiriWindows() { if (!isNiri) return // Get windows from the Niri process niriWindowsProcess.running = true } // Niri workspace process Process { id: niriWorkspaceProcess running: false command: ["niri", "msg", "--json", "workspaces"] stdout: SplitParser { onRead: function (line) { try { const workspacesData = JSON.parse(line) const workspacesList = [] for (const ws of workspacesData) { workspacesList.push({ "id": ws.id, "idx": ws.idx, "name": ws.name || "", "output": ws.output || "", "isFocused": ws.is_focused === true, "isActive": ws.is_active === true, "isUrgent": ws.is_urgent === true, "isOccupied": ws.active_window_id ? true : false }) } workspacesList.sort((a, b) => { if (a.output !== b.output) { return a.output.localeCompare(b.output) } return a.idx - b.idx }) // Update the workspaces ListModel workspaces.clear() for (var i = 0; i < workspacesList.length; i++) { workspaces.append(workspacesList[i]) } workspaceChanged() } catch (e) { Logger.error("Compositor", "Failed to parse workspaces:", e, line) } } } } // Niri event stream process Process { id: niriEventStream running: false command: ["niri", "msg", "--json", "event-stream"] stdout: SplitParser { onRead: data => { try { const event = JSON.parse(data.trim()) if (event.WorkspacesChanged) { niriWorkspaceProcess.running = true } else if (event.WindowOpenedOrChanged) { try { const windowData = event.WindowOpenedOrChanged.window // Find if this window already exists const existingIndex = windows.findIndex(w => w.id === windowData.id) const newWindow = { "id": windowData.id, "title": windowData.title || "", "appId": windowData.app_id || "", "workspaceId": windowData.workspace_id || null, "isFocused": windowData.is_focused === true } if (existingIndex >= 0) { // Update existing window windows[existingIndex] = newWindow } else { // Add new window windows.push(newWindow) windows.sort((a, b) => a.id - b.id) } // Update focused window index if this window is focused if (newWindow.isFocused) { const oldFocusedIndex = focusedWindowIndex focusedWindowIndex = windows.findIndex(w => w.id === windowData.id) updateFocusedWindowTitle() // Only emit activeWindowChanged if the focused window actually changed if (oldFocusedIndex !== focusedWindowIndex) { activeWindowChanged() } } else if (existingIndex >= 0 && existingIndex === focusedWindowIndex) { // If this is the currently focused window (but not newly focused), // still update the title in case it changed, but don't emit activeWindowChanged updateFocusedWindowTitle() } windowListChanged() } catch (e) { Logger.error("Compositor", "Error parsing WindowOpenedOrChanged event:", e) } } else if (event.WindowClosed) { try { const windowId = event.WindowClosed.id // Remove the window from the list const windowIndex = windows.findIndex(w => w.id === windowId) if (windowIndex >= 0) { // If this was the focused window, clear focus if (windowIndex === focusedWindowIndex) { focusedWindowIndex = -1 updateFocusedWindowTitle() activeWindowChanged() } // Remove the window windows.splice(windowIndex, 1) // Adjust focused window index if needed if (focusedWindowIndex > windowIndex) { focusedWindowIndex-- } windowListChanged() } } catch (e) { Logger.error("Compositor", "Error parsing WindowClosed event:", e) } } else if (event.WindowsChanged) { try { const windowsData = event.WindowsChanged.windows const windowsList = [] for (const win of windowsData) { windowsList.push({ "id": win.id, "title": win.title || "", "appId": win.app_id || "", "workspaceId": win.workspace_id || null, "isFocused": win.is_focused === true }) } windowsList.sort((a, b) => a.id - b.id) windows = windowsList windowListChanged() // Update focused window index for (var i = 0; i < windowsList.length; i++) { if (windowsList[i].isFocused) { focusedWindowIndex = i break } } updateFocusedWindowTitle() activeWindowChanged() } catch (e) { Logger.error("Compositor", "Error parsing windows event:", e) } } else if (event.WorkspaceActivated) { niriWorkspaceProcess.running = true } else if (event.WindowFocusChanged) { try { const focusedId = event.WindowFocusChanged.id if (focusedId) { focusedWindowIndex = windows.findIndex(w => w.id === focusedId) if (focusedWindowIndex < 0) { focusedWindowIndex = 0 } } else { focusedWindowIndex = -1 } updateFocusedWindowTitle() activeWindowChanged() } catch (e) { Logger.error("Compositor", "Error parsing window focus event:", e) } } else if (event.OverviewOpenedOrClosed) { try { inOverview = event.OverviewOpenedOrClosed.is_open === true overviewStateChanged() } catch (e) { Logger.error("Compositor", "Error parsing overview state:", e) } } } catch (e) { Logger.error("Compositor", "Error parsing event stream:", e, data) } } } } // Niri windows process (for initial load) Process { id: niriWindowsProcess running: false command: ["niri", "msg", "--json", "windows"] stdout: SplitParser { onRead: function (line) { try { const windowsData = JSON.parse(line) const windowsList = [] for (const win of windowsData) { windowsList.push({ "id": win.id, "title": win.title || "", "appId": win.app_id || "", "workspaceId": win.workspace_id || null, "isFocused": win.is_focused === true }) } windowsList.sort((a, b) => a.id - b.id) windows = windowsList windowListChanged() // Update focused window index for (var i = 0; i < windowsList.length; i++) { if (windowsList[i].isFocused) { focusedWindowIndex = i break } } updateFocusedWindowTitle() activeWindowChanged() } catch (e) { Logger.error("Compositor", "Failed to parse windows:", e, line) } } } } function updateFocusedWindowTitle() { const oldTitle = focusedWindowTitle if (focusedWindowIndex >= 0 && focusedWindowIndex < windows.length) { focusedWindowTitle = windows[focusedWindowIndex].title || "(Unnamed window)" } else { focusedWindowTitle = "(No active window)" } // Emit signal if title actually changed if (oldTitle !== focusedWindowTitle) { windowTitleChanged() } } // Generic workspace switching function switchToWorkspace(workspaceId) { if (isHyprland) { try { Hyprland.dispatch(`workspace ${workspaceId}`) } catch (e) { Logger.error("Compositor", "Error switching Hyprland workspace:", e) } } else if (isNiri) { try { Quickshell.execDetached(["niri", "msg", "action", "focus-workspace", workspaceId.toString()]) } catch (e) { Logger.error("Compositor", "Error switching Niri workspace:", e) } } else { Logger.warn("Compositor", "No supported compositor detected for workspace switching") } } // Get current workspace function getCurrentWorkspace() { for (var i = 0; i < workspaces.count; i++) { const ws = workspaces.get(i) if (ws.isFocused) { return ws } } return null } // Get focused window function getFocusedWindow() { if (focusedWindowIndex >= 0 && focusedWindowIndex < windows.length) { return windows[focusedWindowIndex] } return null } // Generic logout/shutdown commands function logout() { if (isHyprland) { try { Quickshell.execDetached(["hyprctl", "dispatch", "exit"]) } catch (e) { Logger.error("Compositor", "Error logging out from Hyprland:", e) } } else if (isNiri) { try { Quickshell.execDetached(["niri", "msg", "action", "quit", "--skip-confirmation"]) } catch (e) { Logger.error("Compositor", "Error logging out from Niri:", e) } } else { Logger.warn("Compositor", "No supported compositor detected for logout") } } function shutdown() { Quickshell.execDetached(["shutdown", "-h", "now"]) } function reboot() { Quickshell.execDetached(["reboot"]) } function suspend() { Quickshell.execDetached(["systemctl", "suspend"]) } }