From 712b4986be564c43fa622d2f48aa66980508a028 Mon Sep 17 00:00:00 2001 From: purian23 Date: Wed, 20 Aug 2025 20:01:56 -0400 Subject: [PATCH 1/2] Initial hyprland workspace support --- Modules/TopBar/WorkspaceSwitcher.qml | 107 +++++++++++----- Services/CompositorService.qml | 11 +- Services/HyprlandService.qml | 176 +++++++++++++++++++++++++++ 3 files changed, 262 insertions(+), 32 deletions(-) create mode 100644 Services/HyprlandService.qml diff --git a/Modules/TopBar/WorkspaceSwitcher.qml b/Modules/TopBar/WorkspaceSwitcher.qml index 46339103..89376aba 100644 --- a/Modules/TopBar/WorkspaceSwitcher.qml +++ b/Modules/TopBar/WorkspaceSwitcher.qml @@ -24,35 +24,47 @@ Rectangle { } function getDisplayWorkspaces() { - if (!CompositorService.isNiri - || NiriService.allWorkspaces.length === 0) - return [1, 2] + if (CompositorService.isNiri) { + if (NiriService.allWorkspaces.length === 0) + return [1, 2] - if (!root.screenName) - return NiriService.getCurrentOutputWorkspaceNumbers() + if (!root.screenName) + return NiriService.getCurrentOutputWorkspaceNumbers() - var displayWorkspaces = [] - for (var i = 0; i < NiriService.allWorkspaces.length; i++) { - var ws = NiriService.allWorkspaces[i] - if (ws.output === root.screenName) - displayWorkspaces.push(ws.idx + 1) + var displayWorkspaces = [] + for (var i = 0; i < NiriService.allWorkspaces.length; i++) { + var ws = NiriService.allWorkspaces[i] + if (ws.output === root.screenName) + displayWorkspaces.push(ws.idx + 1) + } + return displayWorkspaces.length > 0 ? displayWorkspaces : [1, 2] + } else if (CompositorService.isHyprland) { + var workspaces = HyprlandService.getWorkspaceDisplayNumbers() + return workspaces.length > 0 ? workspaces : [1] } - return displayWorkspaces.length > 0 ? displayWorkspaces : [1, 2] + + return [1, 2] } function getDisplayActiveWorkspace() { - if (!CompositorService.isNiri - || NiriService.allWorkspaces.length === 0) + if (CompositorService.isNiri) { + if (NiriService.allWorkspaces.length === 0) + return 1 + + if (!root.screenName) + return NiriService.getCurrentWorkspaceNumber() + + for (var i = 0; i < NiriService.allWorkspaces.length; i++) { + var ws = NiriService.allWorkspaces[i] + if (ws.output === root.screenName && ws.is_active) + return ws.idx + 1 + } return 1 - - if (!root.screenName) - return NiriService.getCurrentWorkspaceNumber() - - for (var i = 0; i < NiriService.allWorkspaces.length; i++) { - var ws = NiriService.allWorkspaces[i] - if (ws.output === root.screenName && ws.is_active) - return ws.idx + 1 + } else if (CompositorService.isHyprland) { + var activeWs = HyprlandService.getCurrentWorkspaceNumber() + return activeWs } + return 1 } @@ -68,7 +80,7 @@ Rectangle { return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency) } - visible: CompositorService.isNiri + visible: CompositorService.isNiri || CompositorService.isHyprland Connections { function onAllWorkspacesChanged() { @@ -84,6 +96,28 @@ Rectangle { } target: NiriService + enabled: CompositorService.isNiri + } + + Connections { + function onWorkspacesUpdated() { + root.workspaceList + = SettingsData.showWorkspacePadding ? root.padWorkspaces( + root.getDisplayWorkspaces( + )) : root.getDisplayWorkspaces() + root.currentWorkspace = root.getDisplayActiveWorkspace() + } + + function onFocusedWorkspaceUpdated() { + root.currentWorkspace = root.getDisplayActiveWorkspace() + } + + function onFocusedMonitorUpdated() { + root.currentWorkspace = root.getDisplayActiveWorkspace() + } + + target: HyprlandService + enabled: CompositorService.isHyprland } @@ -112,12 +146,22 @@ Rectangle { property bool isHovered: mouseArea.containsMouse property int sequentialNumber: index + 1 property var workspaceData: { - if (isPlaceholder || !CompositorService.isNiri) + if (isPlaceholder) return null - for (var i = 0; i < NiriService.allWorkspaces.length; i++) { - var ws = NiriService.allWorkspaces[i] - if (ws.idx + 1 === modelData) - return ws + + if (CompositorService.isNiri) { + for (var i = 0; i < NiriService.allWorkspaces.length; i++) { + var ws = NiriService.allWorkspaces[i] + if (ws.idx + 1 === modelData) + return ws + } + } else if (CompositorService.isHyprland) { + var hyprWorkspaces = HyprlandService.getWorkspacesForMonitor(root.screenName) + for (var j = 0; j < hyprWorkspaces.length; j++) { + var hws = hyprWorkspaces[j] + if (hws.id === modelData) + return hws + } } return null } @@ -140,8 +184,13 @@ Rectangle { cursorShape: isPlaceholder ? Qt.ArrowCursor : Qt.PointingHandCursor enabled: !isPlaceholder onClicked: { - if (!isPlaceholder) - NiriService.switchToWorkspace(modelData - 1) + if (!isPlaceholder) { + if (CompositorService.isNiri) { + NiriService.switchToWorkspace(modelData - 1) + } else if (CompositorService.isHyprland) { + HyprlandService.switchToWorkspace(modelData) + } + } } } diff --git a/Services/CompositorService.qml b/Services/CompositorService.qml index 6e1f8042..7fff0cc8 100644 --- a/Services/CompositorService.qml +++ b/Services/CompositorService.qml @@ -19,6 +19,7 @@ Singleton { readonly property string niriSocket: Quickshell.env("NIRI_SOCKET") property bool useNiriSorting: isNiri && NiriService + property bool useHyprlandSorting: isHyprland && HyprlandService // Unified sorted toplevels - automatically chooses sorting based on compositor property var sortedToplevels: { @@ -31,12 +32,16 @@ Singleton { return NiriService.sortToplevels(ToplevelManager.toplevels.values) } - // For non-niri compositors or when niri isn't ready yet, return unsorted toplevels + // Use Hyprland sorting when both compositor is Hyprland AND hyprland service is ready + if (useHyprlandSorting) { + return HyprlandService.sortToplevels(ToplevelManager.toplevels.values) + } + + // For other compositors or when services aren't ready yet, return unsorted toplevels return ToplevelManager.toplevels.values } Component.onCompleted: { - console.log("CompositorService: Starting detection...") detectCompositor() } @@ -46,7 +51,7 @@ Singleton { isHyprland = true isNiri = false compositor = "hyprland" - console.log("CompositorService: Detected Hyprland with signature:", hyprlandSignature) + console.log("CompositorService: Detected Hyprland") return } diff --git a/Services/HyprlandService.qml b/Services/HyprlandService.qml new file mode 100644 index 00000000..c560a0ca --- /dev/null +++ b/Services/HyprlandService.qml @@ -0,0 +1,176 @@ +pragma Singleton + +pragma ComponentBehavior + +import QtQuick +import Quickshell +import Quickshell.Hyprland + +Singleton { + id: root + + property bool hyprlandAvailable: { + const signature = Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE") + return signature && signature.length > 0 + } + + property var allWorkspaces: hyprlandAvailable && Hyprland.workspaces ? Hyprland.workspaces.values : [] + property var focusedWorkspace: hyprlandAvailable ? Hyprland.focusedWorkspace : null + property var monitors: hyprlandAvailable ? Hyprland.monitors : [] + property var focusedMonitor: hyprlandAvailable ? Hyprland.focusedMonitor : null + + function getWorkspacesForMonitor(monitorName) { + if (!hyprlandAvailable) return [] + + const workspaces = Hyprland.workspaces ? Hyprland.workspaces.values : [] + if (!workspaces || workspaces.length === 0) return [] + + // If no monitor name specified, return all workspaces + if (!monitorName) { + const allWorkspacesCopy = [] + for (let i = 0; i < workspaces.length; i++) { + const workspace = workspaces[i] + if (workspace) { + allWorkspacesCopy.push(workspace) + } + } + allWorkspacesCopy.sort((a, b) => a.id - b.id) + return allWorkspacesCopy + } + + const filtered = [] + for (let i = 0; i < workspaces.length; i++) { + const workspace = workspaces[i] + if (workspace && workspace.monitor && workspace.monitor.name === monitorName) { + filtered.push(workspace) + } + } + + // Sort by workspace ID + filtered.sort((a, b) => a.id - b.id) + return filtered + } + + function getCurrentWorkspaceForMonitor(monitorName) { + if (!hyprlandAvailable) return null + + // If no monitor name specified, return the globally focused workspace + if (!monitorName) { + return focusedWorkspace + } + + if (focusedMonitor && focusedMonitor.name === monitorName) { + return focusedWorkspace + } + + const monitorWorkspaces = getWorkspacesForMonitor(monitorName) + for (let i = 0; i < monitorWorkspaces.length; i++) { + const ws = monitorWorkspaces[i] + if (ws && ws.active) { + return ws + } + } + return null + } + + function switchToWorkspace(workspaceId) { + if (!hyprlandAvailable) return + + Hyprland.dispatch(`workspace ${workspaceId}`) + } + + function switchToWorkspaceByName(workspaceName) { + if (!hyprlandAvailable) return + + Hyprland.dispatch(`workspace name:${workspaceName}`) + } + + function moveToWorkspace(workspaceId) { + if (!hyprlandAvailable) return + + Hyprland.dispatch(`movetoworkspace ${workspaceId}`) + } + + function createWorkspace(workspaceId) { + if (!hyprlandAvailable) return + + Hyprland.dispatch(`workspace ${workspaceId}`) + } + + function getWorkspaceDisplayNumbers() { + if (!hyprlandAvailable) return [1, 2, 3, 4] + + // Get all existing workspaces from Hyprland.workspaces.values + const workspaces = Hyprland.workspaces ? Hyprland.workspaces.values : [] + if (!workspaces || workspaces.length === 0) { + // If no workspaces detected, show at least current + a few more + const current = getCurrentWorkspaceNumber() + return [Math.max(1, current - 1), current, current + 1, current + 2].filter((ws, i, arr) => arr.indexOf(ws) === i && ws > 0) + } + + // Get workspace IDs and ensure we show a reasonable range + const numbers = [] + let maxId = 0 + + for (let i = 0; i < workspaces.length; i++) { + const ws = workspaces[i] + if (ws && ws.id > 0) { + numbers.push(ws.id) + maxId = Math.max(maxId, ws.id) + } + } + + // Always ensure we have at least one workspace beyond the highest + // to allow easy navigation to new workspaces + if (maxId > 0 && numbers.indexOf(maxId + 1) === -1) { + numbers.push(maxId + 1) + } + + return numbers.sort((a, b) => a - b) + } + + function getCurrentWorkspaceNumber() { + if (!hyprlandAvailable) return 1 + + // Use the focused workspace directly + const focused = Hyprland.focusedWorkspace + return focused ? focused.id : 1 + } + + function sortToplevels(toplevels) { + if (!hyprlandAvailable || !toplevels) return [] + + // Create a copy of the array since the original might be readonly + const sortedArray = Array.from(toplevels) + + return sortedArray.sort((a, b) => { + if (a.workspace && b.workspace) { + if (a.workspace.monitor && b.workspace.monitor) { + const monitorCompare = a.workspace.monitor.name.localeCompare(b.workspace.monitor.name) + if (monitorCompare !== 0) return monitorCompare + } + + const workspaceCompare = a.workspace.id - b.workspace.id + if (workspaceCompare !== 0) return workspaceCompare + } + + return 0 + }) + } + + // Signals for workspace changes that WorkspaceSwitcher can connect to + signal workspacesUpdated() + signal focusedWorkspaceUpdated() + signal focusedMonitorUpdated() + + // Monitor changes to properties and emit our signals + onAllWorkspacesChanged: workspacesUpdated() + onFocusedWorkspaceChanged: focusedWorkspaceUpdated() + onFocusedMonitorChanged: focusedMonitorUpdated() + + Component.onCompleted: { + if (hyprlandAvailable) { + console.log("HyprlandService: Initialized with Hyprland support") + } + } +} \ No newline at end of file From 88d4dad21d9b2eb60fe2f1df155e071d35878ff5 Mon Sep 17 00:00:00 2001 From: purian23 Date: Wed, 20 Aug 2025 21:02:34 -0400 Subject: [PATCH 2/2] feat: Complete core hyprland port --- Modals/PowerConfirmModal.qml | 2 +- Modules/Lock/LockScreenContent.qml | 2 +- Modules/Settings/AboutTab.qml | 19 ++++++++++++++++++- Services/CompositorService.qml | 9 +++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/Modals/PowerConfirmModal.qml b/Modals/PowerConfirmModal.qml index 57d62200..3ec00e12 100644 --- a/Modals/PowerConfirmModal.qml +++ b/Modals/PowerConfirmModal.qml @@ -23,7 +23,7 @@ DankModal { function executePowerAction(action) { switch (action) { case "logout": - NiriService.quit() + CompositorService.logout() break case "suspend": Quickshell.execDetached(["systemctl", "suspend"]) diff --git a/Modules/Lock/LockScreenContent.qml b/Modules/Lock/LockScreenContent.qml index bbff1dca..f700956a 100644 --- a/Modules/Lock/LockScreenContent.qml +++ b/Modules/Lock/LockScreenContent.qml @@ -1187,7 +1187,7 @@ Item { cursorShape: Qt.PointingHandCursor onClicked: { logoutDialog.close() - NiriService.quit() + CompositorService.logout() } } } diff --git a/Modules/Settings/AboutTab.qml b/Modules/Settings/AboutTab.qml index 9c8fbeca..36401f35 100644 --- a/Modules/Settings/AboutTab.qml +++ b/Modules/Settings/AboutTab.qml @@ -378,11 +378,28 @@ Item { } StyledText { - text: "(Hyprland Soon™)" + text: "&" font.pixelSize: Theme.fontSizeMedium color: Theme.surfaceVariantText anchors.verticalCenter: parent.verticalCenter } + + StyledText { + text: `hyprland` + font.pixelSize: Theme.fontSizeMedium + linkColor: Theme.primary + textFormat: Text.RichText + color: Theme.surfaceVariantText + onLinkActivated: url => Qt.openUrlExternally(url) + anchors.verticalCenter: parent.verticalCenter + + MouseArea { + anchors.fill: parent + cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor + acceptedButtons: Qt.NoButton + propagateComposedEvents: true + } + } } StyledText { diff --git a/Services/CompositorService.qml b/Services/CompositorService.qml index 7fff0cc8..9223a93e 100644 --- a/Services/CompositorService.qml +++ b/Services/CompositorService.qml @@ -6,6 +6,7 @@ import QtQuick import Quickshell import Quickshell.Io import Quickshell.Wayland +import Quickshell.Hyprland Singleton { id: root @@ -86,4 +87,12 @@ Singleton { } } } + + function logout() { + if (isNiri) { + NiriService.quit() + return + } + Hyprland.dispatch("exit") + } } \ No newline at end of file