diff --git a/Modules/Dock/DockApps.qml b/Modules/Dock/DockApps.qml index 66d4dcae..588f0d1b 100644 --- a/Modules/Dock/DockApps.qml +++ b/Modules/Dock/DockApps.qml @@ -48,7 +48,10 @@ Item { clear() var items = [] - var runningApps = NiriService.getRunningAppIds() + // Use ordered app IDs if available from Niri, fallback to unordered + var runningApps = NiriService.niriAvailable && NiriService.getRunningAppIdsOrdered + ? NiriService.getRunningAppIdsOrdered() + : NiriService.getRunningAppIds() var pinnedApps = [...(SessionData.pinnedApps || [])] var addedApps = new Set() @@ -73,6 +76,7 @@ Item { var unpinnedAppsSet = new Set() // First: Add ALL currently running apps that aren't pinned + // They come pre-ordered from NiriService if Niri is available runningApps.forEach(appId => { var lowerAppId = appId.toLowerCase() if (!addedApps.has(lowerAppId)) { diff --git a/Modules/Settings/WidgetsTab.qml b/Modules/Settings/WidgetsTab.qml index 24542ab9..e1c94970 100644 --- a/Modules/Settings/WidgetsTab.qml +++ b/Modules/Settings/WidgetsTab.qml @@ -25,6 +25,12 @@ Item { "description": "Display currently focused application title", "icon": "window", "enabled": true + }, { + "id": "runningApps", + "text": "Running Apps", + "description": "Shows all running applications with focus indication", + "icon": "apps", + "enabled": true }, { "id": "clock", "text": "Clock", diff --git a/Modules/TopBar/RunningApps.qml b/Modules/TopBar/RunningApps.qml new file mode 100644 index 00000000..299e3506 --- /dev/null +++ b/Modules/TopBar/RunningApps.qml @@ -0,0 +1,129 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Widgets +import qs.Common +import qs.Services +import qs.Widgets + +Rectangle { + id: root + + property string section: "left" + property var parentScreen + + readonly property int windowCount: NiriService.windows.length + readonly property int calculatedWidth: windowCount > 0 ? windowCount * 24 + (windowCount - 1) * Theme.spacingXS + Theme.spacingS * 2 : 0 + + width: calculatedWidth + height: 30 + radius: Theme.cornerRadius + visible: windowCount > 0 + color: { + if (windowCount === 0) + return "transparent" + + const baseColor = Theme.secondaryHover + return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, + baseColor.a * Theme.widgetTransparency) + } + + Row { + id: windowRow + anchors.centerIn: parent + spacing: Theme.spacingXS + + Repeater { + id: windowRepeater + model: NiriService.windows + + delegate: Item { + property bool isFocused: String(modelData.id) === String(FocusedWindowService.focusedWindowId) + property string appId: modelData.app_id || "" + property string windowTitle: modelData.title || "(Unnamed)" + property int windowId: modelData.id + property string tooltipText: { + var appName = "Unknown" + if (appId) { + var desktopEntry = DesktopEntries.byId(appId) + appName = desktopEntry && desktopEntry.name ? desktopEntry.name : appId + } + return appName + (windowTitle ? " • " + windowTitle : "") + } + + width: 24 + height: 24 + + Rectangle { + anchors.fill: parent + radius: Theme.cornerRadius + color: { + if (isFocused) { + return mouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3) : Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2) + } else { + return mouseArea.containsMouse ? Qt.rgba(Theme.primaryHover.r, Theme.primaryHover.g, Theme.primaryHover.b, 0.1) : "transparent" + } + } + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + + // App icon + IconImage { + id: iconImg + anchors.centerIn: parent + width: 18 + height: 18 + source: { + if (!appId) return "" + var desktopEntry = DesktopEntries.byId(appId) + if (desktopEntry && desktopEntry.icon) { + var iconPath = Quickshell.iconPath( + desktopEntry.icon, + SettingsData.iconTheme === "System Default" ? "" : SettingsData.iconTheme) + return iconPath + } + return "" + } + smooth: true + mipmap: true + asynchronous: true + visible: status === Image.Ready + } + + // Fallback text if no icon found + Text { + anchors.centerIn: parent + visible: !iconImg.visible + text: { + if (!appId) return "?" + var desktopEntry = DesktopEntries.byId(appId) + if (desktopEntry && desktopEntry.name) { + return desktopEntry.name.charAt(0).toUpperCase() + } + return appId.charAt(0).toUpperCase() + } + font.pixelSize: 10 + color: Theme.surfaceText + font.weight: Font.Medium + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + NiriService.focusWindow(windowId) + } + } + } + } + } +} \ No newline at end of file diff --git a/Modules/TopBar/TopBar.qml b/Modules/TopBar/TopBar.qml index 4f426608..f6d340d9 100644 --- a/Modules/TopBar/TopBar.qml +++ b/Modules/TopBar/TopBar.qml @@ -10,6 +10,7 @@ import Quickshell.Wayland import Quickshell.Widgets import qs.Common import qs.Modules +import qs.Modules.TopBar import qs.Services import qs.Widgets @@ -285,6 +286,8 @@ PanelWindow { return true case "focusedWindow": return true + case "runningApps": + return true case "clock": return true case "music": @@ -330,6 +333,8 @@ PanelWindow { return workspaceSwitcherComponent case "focusedWindow": return focusedWindowComponent + case "runningApps": + return runningAppsComponent case "clock": return clockComponent case "music": @@ -651,6 +656,23 @@ PanelWindow { } } + Component { + id: runningAppsComponent + + RunningApps { + section: { + if (parent && parent.parent === leftSection) + return "left" + if (parent && parent.parent === rightSection) + return "right" + if (parent && parent.parent === centerSection) + return "center" + return "left" + } + parentScreen: root.screen + } + } + Component { id: clockComponent diff --git a/Services/NiriService.qml b/Services/NiriService.qml index 0b94e261..c1fd4cb0 100644 --- a/Services/NiriService.qml +++ b/Services/NiriService.qml @@ -16,6 +16,9 @@ Singleton { property string focusedWorkspaceId: "" property var currentOutputWorkspaces: [] property string currentOutput: "" + + // Output/Monitor management + property var outputs: ({}) // Map of output name to output info with positions // Window management property var windows: [] @@ -47,6 +50,7 @@ Singleton { root.niriAvailable = exitCode === 0 if (root.niriAvailable) { eventStreamSocket.connected = true + fetchOutputs() } } } @@ -54,6 +58,39 @@ Singleton { function checkNiriAvailability() { niriCheck.running = true } + + function fetchOutputs() { + if (niriAvailable) { + outputsProcess.running = true + } + } + + Process { + id: outputsProcess + command: ["niri", "msg", "-j", "outputs"] + + stdout: StdioCollector { + onStreamFinished: { + try { + var outputsData = JSON.parse(text) + outputs = outputsData + console.log("NiriService: Loaded", Object.keys(outputsData).length, "outputs") + // Re-sort windows with monitor positions + if (windows.length > 0) { + windows = sortWindowsByLayout(windows) + } + } catch (e) { + console.warn("NiriService: Failed to parse outputs:", e) + } + } + } + + onExited: exitCode => { + if (exitCode !== 0) { + console.warn("NiriService: Failed to fetch outputs, exit code:", exitCode) + } + } + } Socket { id: eventStreamSocket @@ -84,6 +121,66 @@ Singleton { connected: root.niriAvailable } + function sortWindowsByLayout(windowList) { + return [...windowList].sort((a, b) => { + // Get workspace info for both windows + var aWorkspace = workspaces[a.workspace_id] + var bWorkspace = workspaces[b.workspace_id] + + if (aWorkspace && bWorkspace) { + var aOutput = aWorkspace.output + var bOutput = bWorkspace.output + + // 1. First, sort by monitor position (left to right, top to bottom) + var aOutputInfo = outputs[aOutput] + var bOutputInfo = outputs[bOutput] + + if (aOutputInfo && bOutputInfo && + aOutputInfo.logical && bOutputInfo.logical) { + // Sort by monitor X position (left to right) + if (aOutputInfo.logical.x !== bOutputInfo.logical.x) { + return aOutputInfo.logical.x - bOutputInfo.logical.x + } + // If same X, sort by Y position (top to bottom) + if (aOutputInfo.logical.y !== bOutputInfo.logical.y) { + return aOutputInfo.logical.y - bOutputInfo.logical.y + } + } + + // 2. If same monitor, sort by workspace index + if (aOutput === bOutput && aWorkspace.idx !== bWorkspace.idx) { + return aWorkspace.idx - bWorkspace.idx + } + } + + // 3. If same workspace, sort by actual position within workspace + if (a.workspace_id === b.workspace_id && + a.layout && b.layout) { + + // Use pos_in_scrolling_layout [x, y] coordinates + if (a.layout.pos_in_scrolling_layout && + b.layout.pos_in_scrolling_layout) { + var aPos = a.layout.pos_in_scrolling_layout + var bPos = b.layout.pos_in_scrolling_layout + + if (aPos.length > 1 && bPos.length > 1) { + // Sort by X (horizontal) position first + if (aPos[0] !== bPos[0]) { + return aPos[0] - bPos[0] + } + // Then sort by Y (vertical) position + if (aPos[1] !== bPos[1]) { + return aPos[1] - bPos[1] + } + } + } + } + + // 4. Fallback to window ID for consistent ordering + return a.id - b.id + }) + } + function handleNiriEvent(event) { if (event.WorkspacesChanged) { handleWorkspacesChanged(event.WorkspacesChanged) @@ -99,6 +196,10 @@ Singleton { handleWindowFocusChanged(event.WindowFocusChanged) } else if (event.WindowOpenedOrChanged) { handleWindowOpenedOrChanged(event.WindowOpenedOrChanged) + } else if (event.WindowLayoutsChanged) { + handleWindowLayoutsChanged(event.WindowLayoutsChanged) + } else if (event.OutputsChanged) { + handleOutputsChanged(event.OutputsChanged) } else if (event.OverviewOpenedOrClosed) { handleOverviewChanged(event.OverviewOpenedOrClosed) } else if (event.ConfigLoaded) { @@ -205,7 +306,7 @@ Singleton { } function handleWindowsChanged(data) { - windows = [...data.windows].sort((a, b) => a.id - b.id) + windows = sortWindowsByLayout(data.windows) updateFocusedWindow() } @@ -235,9 +336,9 @@ Singleton { if (existingIndex >= 0) { let updatedWindows = [...windows] updatedWindows[existingIndex] = window - windows = updatedWindows.sort((a, b) => a.id - b.id) + windows = sortWindowsByLayout(updatedWindows) } else { - windows = [...windows, window].sort((a, b) => a.id - b.id) + windows = sortWindowsByLayout([...windows, window]) } if (window.is_focused) { @@ -249,7 +350,47 @@ Singleton { windowOpenedOrChanged(window) } + + function handleWindowLayoutsChanged(data) { + // Update layout positions for windows that have changed + if (!data.changes) + return + + let updatedWindows = [...windows] + let hasChanges = false + + for (const change of data.changes) { + const windowId = change[0] + const layoutData = change[1] + + const windowIndex = updatedWindows.findIndex(w => w.id === windowId) + if (windowIndex >= 0) { + // Create a new object with updated layout + var updatedWindow = {} + for (var prop in updatedWindows[windowIndex]) { + updatedWindow[prop] = updatedWindows[windowIndex][prop] + } + updatedWindow.layout = layoutData + updatedWindows[windowIndex] = updatedWindow + hasChanges = true + } + } + + if (hasChanges) { + windows = sortWindowsByLayout(updatedWindows) + // Trigger update in dock and widgets + windowsChanged() + } + } + function handleOutputsChanged(data) { + if (data.outputs) { + outputs = data.outputs + // Re-sort windows with new monitor positions + windows = sortWindowsByLayout(windows) + } + } + function handleOverviewChanged(data) { inOverview = data.is_open } @@ -393,4 +534,42 @@ Singleton { }) return Array.from(appIds) } + + function getRunningAppIdsOrdered() { + // Get unique app IDs in order they appear in the Niri layout + // Windows are sorted by workspace and then by position within the workspace + var sortedWindows = [...windows].sort((a, b) => { + // If both have layout info, sort by position + if (a.layout && b.layout && + a.layout.pos_in_scrolling_layout && + b.layout.pos_in_scrolling_layout) { + var aPos = a.layout.pos_in_scrolling_layout + var bPos = b.layout.pos_in_scrolling_layout + + // First compare workspace index + if (aPos[0] !== bPos[0]) { + return aPos[0] - bPos[0] + } + // Then compare position within workspace + return aPos[1] - bPos[1] + } + // Fallback to window ID if no layout info + return a.id - b.id + }) + + var appIds = [] + var seenApps = new Set() + + sortedWindows.forEach(w => { + if (w.app_id) { + var lowerAppId = w.app_id.toLowerCase() + if (!seenApps.has(lowerAppId)) { + appIds.push(lowerAppId) + seenApps.add(lowerAppId) + } + } + }) + + return appIds + } }