Merge branch 'compositor-service'

This commit is contained in:
LemmyCook
2025-09-19 14:42:31 -04:00
6 changed files with 682 additions and 662 deletions

View File

@@ -30,6 +30,9 @@ Item {
return {}
}
readonly property string windowTitle: CompositorService.getFocusedWindowTitle()
readonly property bool showIcon: (widgetSettings.showIcon !== undefined) ? widgetSettings.showIcon : widgetMetadata.showIcon
// 6% of total width
@@ -40,26 +43,19 @@ Item {
readonly property bool isVertical: barPosition === "left" || barPosition === "right"
readonly property bool compact: (Settings.data.bar.density === "compact")
implicitHeight: (barPosition === "left" || barPosition === "right") ? calculatedVerticalHeight() : Math.round(Style.barHeight * scaling)
implicitWidth: (barPosition === "left" || barPosition === "right") ? Math.round(Style.capsuleHeight * 0.8 * scaling) : (horizontalLayout.implicitWidth + Style.marginM * 2 * scaling)
readonly property real textSize: {
readonly property real textSize: {
var base = isVertical ? width : height
return Math.max(1, compact ? base * 0.43 : base * 0.33)
}
readonly property real iconSize: textSize * 1.25
function getTitle() {
try {
return CompositorService.focusedWindowTitle !== "(No active window)" ? CompositorService.focusedWindowTitle : ""
} catch (e) {
Logger.warn("ActiveWindow", "Error getting title:", e)
return ""
}
}
implicitHeight: (barPosition === "left" || barPosition === "right") ? calculatedVerticalHeight() : Math.round(Style.barHeight * scaling)
implicitWidth: (barPosition === "left" || barPosition === "right") ? Math.round(Style.capsuleHeight * 0.8 * scaling) : (horizontalLayout.implicitWidth + Style.marginM * 2 * scaling)
visible: getTitle() !== ""
visible: windowTitle !== ""
function calculatedVerticalHeight() {
// Use standard widget height like other widgets
@@ -74,11 +70,10 @@ Item {
}
// Calculate actual text width more accurately
const title = getTitle()
if (title !== "") {
if (windowTitle !== "") {
// Estimate text width: average character width * number of characters
const avgCharWidth = Style.fontSizeS * scaling * 0.6 // rough estimate
const titleWidth = Math.min(title.length * avgCharWidth, 80 * scaling)
const titleWidth = Math.min(windowTitle.length * avgCharWidth, 80 * scaling)
total += titleWidth
}
@@ -131,7 +126,7 @@ Item {
NText {
id: fullTitleMetrics
visible: false
text: getTitle()
text: windowTitle
font.pointSize: Style.fontSizeS * scaling
font.weight: Style.fontWeightMedium
}
@@ -167,7 +162,7 @@ Item {
Layout.preferredWidth: Style.capsuleHeight * 0.75 * scaling
Layout.preferredHeight: Style.capsuleHeight * 0.75 * scaling
Layout.alignment: Qt.AlignVCenter
visible: getTitle() !== "" && showIcon
visible: windowTitle !== "" && showIcon
IconImage {
id: windowIcon
@@ -202,7 +197,7 @@ Item {
}
Layout.alignment: Qt.AlignVCenter
horizontalAlignment: Text.AlignLeft
text: getTitle()
text: windowTitle
font.pointSize: Style.fontSizeS * scaling
font.weight: Style.fontWeightMedium
elide: mouseArea.containsMouse ? Text.ElideNone : Text.ElideRight
@@ -273,7 +268,7 @@ Item {
NTooltip {
id: tooltip
target: verticalLayout
text: getTitle()
text: windowTitle
positionLeft: barPosition === "right"
positionRight: barPosition === "left"
delay: 500

View File

@@ -111,7 +111,7 @@ Item {
onHideUnoccupiedChanged: refreshWorkspaces()
Connections {
target: WorkspaceService
target: CompositorService
function onWorkspacesChanged() {
refreshWorkspaces()
}
@@ -120,8 +120,8 @@ Item {
function refreshWorkspaces() {
localWorkspaces.clear()
if (screen !== null) {
for (var i = 0; i < WorkspaceService.workspaces.count; i++) {
const ws = WorkspaceService.workspaces.get(i)
for (var i = 0; i < CompositorService.workspaces.count; i++) {
const ws = CompositorService.workspaces.get(i)
if (ws.output.toLowerCase() === screen.name.toLowerCase()) {
if (hideUnoccupied && !ws.isOccupied && !ws.isFocused) {
continue
@@ -260,7 +260,7 @@ Item {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
WorkspaceService.switchToWorkspace(model.idx)
CompositorService.switchToWorkspace(model.idx)
}
hoverEnabled: true
}
@@ -404,7 +404,7 @@ Item {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
WorkspaceService.switchToWorkspace(model.idx)
CompositorService.switchToWorkspace(model.idx)
}
hoverEnabled: true
}

View File

@@ -2,616 +2,128 @@ 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"
// Compositor detection
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)
}
}
}
// Backend service loader
property var backend: null
// 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"
const hyprlandSignature = Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE")
if (hyprlandSignature && hyprlandSignature.length > 0) {
isHyprland = true
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)"
backendLoader.sourceComponent = hyprlandComponent
} else {
focusedWindowTitle = "(No active window)"
// Default to Niri
isHyprland = false
isNiri = true
backendLoader.sourceComponent = niriComponent
}
}
// Emit signal if title actually changed
if (oldTitle !== focusedWindowTitle) {
windowTitleChanged()
Loader {
id: backendLoader
onLoaded: {
if (item) {
root.backend = item
setupBackendConnections()
backend.initialize()
}
}
}
// Hyprland backend component
Component {
id: hyprlandComponent
HyprlandService {
id: hyprlandBackend
}
}
// Niri backend component
Component {
id: niriComponent
NiriService {
id: niriBackend
}
}
function setupBackendConnections() {
if (!backend) return
// Connect backend signals to facade signals
backend.workspaceChanged.connect(() => {
// Sync workspaces when they change
syncWorkspaces()
// Forward the signal
workspaceChanged()
})
backend.activeWindowChanged.connect(activeWindowChanged)
backend.windowListChanged.connect(() => {
// Sync windows when they change
windows = backend.windows
// Forward the signal
windowListChanged()
})
// Property bindings
backend.focusedWindowIndexChanged.connect(() => {
focusedWindowIndex = backend.focusedWindowIndex
})
// Initial sync
syncWorkspaces()
windows = backend.windows
focusedWindowIndex = backend.focusedWindowIndex
}
function syncWorkspaces() {
workspaces.clear()
const ws = backend.workspaces
for (var i = 0; i < ws.count; i++) {
workspaces.append(ws.get(i))
}
// Emit signal to notify listeners that workspace list has been updated
workspacesChanged()
}
// Get window title for focused window
function getFocusedWindowTitle() {
if (focusedWindowIndex >= 0 && focusedWindowIndex < windows.length) {
return windows[focusedWindowIndex].title || "(Unnamed window)"
}
return "(No active window)"
}
// 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)
}
if (backend && backend.switchToWorkspace) {
backend.switchToWorkspace(workspaceId)
} else {
Logger.warn("Compositor", "No supported compositor detected for workspace switching")
Logger.warn("Compositor", "No backend available for workspace switching")
}
}
@@ -634,22 +146,12 @@ Singleton {
return null
}
// Generic logout/shutdown commands
// Session management
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)
}
if (backend && backend.logout) {
backend.logout()
} else {
Logger.warn("Compositor", "No supported compositor detected for logout")
Logger.warn("Compositor", "No backend available for logout")
}
}
@@ -664,4 +166,4 @@ Singleton {
function suspend() {
Quickshell.execDetached(["systemctl", "suspend"])
}
}
}

View File

@@ -0,0 +1,275 @@
import QtQuick
import Quickshell
import Quickshell.Hyprland
import qs.Commons
Item {
id: root
// Properties that match the facade interface
property ListModel workspaces: ListModel {}
property var windows: []
property int focusedWindowIndex: -1
// Signals that match the facade interface
signal workspaceChanged
signal activeWindowChanged
signal windowListChanged
// Hyprland-specific properties
property bool initialized: false
property var workspaceCache: ({})
property var windowCache: ({})
// Debounce timer for updates
Timer {
id: updateTimer
interval: 50
repeat: false
onTriggered: safeUpdate()
}
// Initialization
function initialize() {
if (initialized) return
try {
Hyprland.refreshWorkspaces()
Hyprland.refreshToplevels()
Qt.callLater(() => {
safeUpdateWorkspaces()
safeUpdateWindows()
})
initialized = true
Logger.log("HyprlandService", "Initialized successfully")
} catch (e) {
Logger.error("HyprlandService", "Failed to initialize:", e)
}
}
// Safe update wrapper
function safeUpdate() {
safeUpdateWindows()
safeUpdateWorkspaces()
windowListChanged()
}
// Safe workspace update
function safeUpdateWorkspaces() {
try {
workspaces.clear()
workspaceCache = {}
if (!Hyprland.workspaces || !Hyprland.workspaces.values) {
return
}
const hlWorkspaces = Hyprland.workspaces.values
const occupiedIds = getOccupiedWorkspaceIds()
for (var i = 0; i < hlWorkspaces.length; i++) {
const ws = hlWorkspaces[i]
if (!ws || ws.id < 1) continue
const wsData = {
"id": i,
"idx": ws.id,
"name": ws.name || "",
"output": (ws.monitor && ws.monitor.name) ? ws.monitor.name : "",
"isActive": ws.active === true,
"isFocused": ws.focused === true,
"isUrgent": ws.urgent === true,
"isOccupied": occupiedIds[ws.id] === true
}
workspaceCache[ws.id] = wsData
workspaces.append(wsData)
}
} catch (e) {
Logger.error("HyprlandService", "Error updating workspaces:", e)
}
}
// Get occupied workspace IDs safely
function getOccupiedWorkspaceIds() {
const occupiedIds = {}
try {
if (!Hyprland.toplevels || !Hyprland.toplevels.values) {
return occupiedIds
}
const hlToplevels = Hyprland.toplevels.values
for (var i = 0; i < hlToplevels.length; i++) {
const toplevel = hlToplevels[i]
if (!toplevel) continue
try {
const wsId = toplevel.workspace ? toplevel.workspace.id : null
if (wsId !== null && wsId !== undefined) {
occupiedIds[wsId] = true
}
} catch (e) {
// Ignore individual toplevel errors
}
}
} catch (e) {
// Return empty if we can't determine occupancy
}
return occupiedIds
}
// Safe window update
function safeUpdateWindows() {
try {
const windowsList = []
windowCache = {}
if (!Hyprland.toplevels || !Hyprland.toplevels.values) {
windows = []
focusedWindowIndex = -1
return
}
const hlToplevels = Hyprland.toplevels.values
let newFocusedIndex = -1
for (var i = 0; i < hlToplevels.length; i++) {
const toplevel = hlToplevels[i]
if (!toplevel) continue
const windowData = extractWindowData(toplevel)
if (windowData) {
windowsList.push(windowData)
windowCache[windowData.id] = windowData
if (windowData.isFocused) {
newFocusedIndex = windowsList.length - 1
}
}
}
windows = windowsList
if (newFocusedIndex !== focusedWindowIndex) {
focusedWindowIndex = newFocusedIndex
activeWindowChanged()
}
} catch (e) {
Logger.error("HyprlandService", "Error updating windows:", e)
}
}
// Extract window data safely from a toplevel
function extractWindowData(toplevel) {
if (!toplevel) return null
try {
// Safely extract properties
const windowId = safeGetProperty(toplevel, "address", "")
if (!windowId) return null
const appId = extractAppId(toplevel)
const title = safeGetProperty(toplevel, "title", "")
const wsId = toplevel.workspace ? toplevel.workspace.id : null
const focused = toplevel.activated === true
return {
"id": windowId,
"title": title,
"appId": appId,
"workspaceId": wsId,
"isFocused": focused
}
} catch (e) {
return null
}
}
// Extract app ID from various possible sources
function extractAppId(toplevel) {
if (!toplevel) return ""
// Try direct properties
var appId = safeGetProperty(toplevel, "class", "")
if (appId) return appId
appId = safeGetProperty(toplevel, "initialClass", "")
if (appId) return appId
appId = safeGetProperty(toplevel, "appId", "")
if (appId) return appId
// Try lastIpcObject
try {
const ipcData = toplevel.lastIpcObject
if (ipcData) {
return String(ipcData.class || ipcData.initialClass ||
ipcData.appId || ipcData.wm_class || "")
}
} catch (e) {
// Ignore IPC errors
}
return ""
}
// Safe property getter
function safeGetProperty(obj, prop, defaultValue) {
try {
const value = obj[prop]
if (value !== undefined && value !== null) {
return String(value)
}
} catch (e) {
// Property access failed
}
return defaultValue
}
// Connections to Hyprland
Connections {
target: Hyprland.workspaces
enabled: initialized
function onValuesChanged() {
safeUpdateWorkspaces()
workspaceChanged()
}
}
Connections {
target: Hyprland.toplevels
enabled: initialized
function onValuesChanged() {
updateTimer.restart()
}
}
Connections {
target: Hyprland
enabled: initialized
function onRawEvent(event) {
safeUpdateWorkspaces()
workspaceChanged()
updateTimer.restart()
}
}
// Public functions
function switchToWorkspace(workspaceId) {
try {
Hyprland.dispatch(`workspace ${workspaceId}`)
} catch (e) {
Logger.error("HyprlandService", "Failed to switch workspace:", e)
}
}
function logout() {
try {
Quickshell.execDetached(["hyprctl", "dispatch", "exit"])
} catch (e) {
Logger.error("HyprlandService", "Failed to logout:", e)
}
}
}

295
Services/NiriService.qml Normal file
View File

@@ -0,0 +1,295 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
Item {
id: root
// Properties that match the facade interface
property ListModel workspaces: ListModel {}
property var windows: []
property int focusedWindowIndex: -1
// Signals that match the facade interface
signal workspaceChanged
signal activeWindowChanged
signal windowListChanged
// Initialization
function initialize() {
niriEventStream.running = true
updateWorkspaces()
updateWindows()
Logger.log("NiriService", "Initialized successfully")
}
// Update workspaces
function updateWorkspaces() {
niriWorkspaceProcess.running = true
}
// Update windows
function updateWindows() {
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
})
}
// Sort workspaces by output, then by index
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("NiriService", "Failed to parse workspaces:", e, line)
}
}
}
}
// 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
focusedWindowIndex = -1
for (var i = 0; i < windowsList.length; i++) {
if (windowsList[i].isFocused) {
focusedWindowIndex = i
break
}
}
activeWindowChanged()
} catch (e) {
Logger.error("NiriService", "Failed to parse windows:", 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) {
updateWorkspaces()
}
else if (event.WindowOpenedOrChanged) {
handleWindowOpenedOrChanged(event.WindowOpenedOrChanged)
}
else if (event.WindowClosed) {
handleWindowClosed(event.WindowClosed)
}
else if (event.WindowsChanged) {
handleWindowsChanged(event.WindowsChanged)
}
else if (event.WorkspaceActivated) {
updateWorkspaces()
}
else if (event.WindowFocusChanged) {
handleWindowFocusChanged(event.WindowFocusChanged)
}
// Removed OverviewOpenedOrClosed handling
} catch (e) {
Logger.error("NiriService", "Error parsing event stream:", e, data)
}
}
}
}
// Event handlers
function handleWindowOpenedOrChanged(eventData) {
try {
const windowData = eventData.window
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)
// Only emit activeWindowChanged if the focused window actually changed
if (oldFocusedIndex !== focusedWindowIndex) {
activeWindowChanged()
}
}
windowListChanged()
} catch (e) {
Logger.error("NiriService", "Error handling WindowOpenedOrChanged:", e)
}
}
function handleWindowClosed(eventData) {
try {
const windowId = eventData.id
const windowIndex = windows.findIndex(w => w.id === windowId)
if (windowIndex >= 0) {
// If this was the focused window, clear focus
if (windowIndex === focusedWindowIndex) {
focusedWindowIndex = -1
activeWindowChanged()
} else if (focusedWindowIndex > windowIndex) {
// Adjust focused window index if needed
focusedWindowIndex--
}
// Remove the window
windows.splice(windowIndex, 1)
windowListChanged()
}
} catch (e) {
Logger.error("NiriService", "Error handling WindowClosed:", e)
}
}
function handleWindowsChanged(eventData) {
try {
const windowsData = eventData.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
focusedWindowIndex = -1
for (var i = 0; i < windowsList.length; i++) {
if (windowsList[i].isFocused) {
focusedWindowIndex = i
break
}
}
activeWindowChanged()
} catch (e) {
Logger.error("NiriService", "Error handling WindowsChanged:", e)
}
}
function handleWindowFocusChanged(eventData) {
try {
const focusedId = eventData.id
if (focusedId) {
const newIndex = windows.findIndex(w => w.id === focusedId)
focusedWindowIndex = newIndex >= 0 ? newIndex : -1
} else {
focusedWindowIndex = -1
}
activeWindowChanged()
} catch (e) {
Logger.error("NiriService", "Error handling WindowFocusChanged:", e)
}
}
// Public functions
function switchToWorkspace(workspaceId) {
try {
Quickshell.execDetached(["niri", "msg", "action", "focus-workspace", workspaceId.toString()])
} catch (e) {
Logger.error("NiriService", "Failed to switch workspace:", e)
}
}
function logout() {
try {
Quickshell.execDetached(["niri", "msg", "action", "quit", "--skip-confirmation"])
} catch (e) {
Logger.error("NiriService", "Failed to logout:", e)
}
}
}

View File

@@ -1,47 +0,0 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Commons
import qs.Services
Singleton {
id: root
// Delegate to CompositorService for all workspace operations
property ListModel workspaces: ListModel {}
property bool isHyprland: false
property bool isNiri: false
Component.onCompleted: {
// Connect to CompositorService workspace changes
CompositorService.workspaceChanged.connect(updateWorkspaces)
// Initial sync
updateWorkspaces()
}
// Listen to compositor detection changes
Connections {
target: CompositorService
function onIsHyprlandChanged() {
isHyprland = CompositorService.isHyprland
}
function onIsNiriChanged() {
isNiri = CompositorService.isNiri
}
}
function updateWorkspaces() {
workspaces.clear()
for (var i = 0; i < CompositorService.workspaces.count; i++) {
const ws = CompositorService.workspaces.get(i)
workspaces.append(ws)
}
// Explicitly trigger the signal to ensure the Workspace module gets notified
workspacesChanged()
}
function switchToWorkspace(workspaceId) {
CompositorService.switchToWorkspace(workspaceId)
}
}