import QtQuick 2.15 import Quickshell.Io import Quickshell.Wayland // Left section of the bar: a vertically-sliding strip of workspace/window pills. // Renders the currently displayed workspace's pills plus ↑/↓ affordances into adjacent // workspaces, and animates a vertical slide when the active workspace changes. Item { id: windowList // --- inputs (fed by Bar / shell state) --- property var niriWindowsList: [] property var niriWorkspacesList: [] property string outputName: "" // this monitor's niri output name property color barColor: "black" // bar background, used for 1px pill dividers property bool thumbnails: false // config: box-is-bitmap window previews where positions are known property bool thumbnailLabels: false // config: show the standard app_id label on previews property bool screencast: true // use niri-window-castd snapshots when available property var outputScreen: null // this monitor's ShellScreen object (captureSource) // Keep the daemon socket alive even before a window delegate has a frame. readonly property int screencastRevision: screencast ? Screencast.revision : 0 clip: true implicitHeight: stripHeight implicitWidth: Math.max( aboveSectionLoader.item ? aboveSectionLoader.item.width : 0, currentSectionLoader.item ? currentSectionLoader.item.width : 0, belowSectionLoader.item ? belowSectionLoader.item.width : 0 ) property var activeWorkspace: niriWorkspacesList.find( function(ws) { return ws.output === windowList.outputName && ws.is_active }) || null // shared font metrics for measuring pill widths so the highlight knows where to slide FontMetrics { id: pillFm font.family: "Inter" font.pixelSize: 13 font.weight: Font.Bold } // Box width of a thumbnail pill: use compositor geometry when available, then // fall back to the native window size exposed by niri. function thumbAspect(win) { var region = winRegion(win) if (region && region.w > 0 && region.h > 0) return region.w / region.h var layout = win.layout var size = layout && (layout.window_size || layout.tile_size) if (size && size[0] > 0 && size[1] > 0) return size[0] / size[1] return 16 / 9 } function thumbPillWidth(win) { return Math.max(32, Math.round(stripHeight * thumbAspect(win))) } function pillWidth(win) { if (win.isThumb) return thumbPillWidth(win) return pillFm.advanceWidth(win.app_id ?? "") + 16 } readonly property int stripHeight: thumbnails ? 64 : 26 // What workspace's pills are currently shown in the "current" slot of the strip stack. // Lags activeWorkspace.id during a slide, so the strip can render the OLD state // while sliding to reveal the NEW workspace (rendered in the above/below slot). property int displayedWorkspaceId: -1 property var displayedWorkspace: niriWorkspacesList.find( function(ws) { return ws.id === displayedWorkspaceId }) || null property int aboveAdjWorkspaceId: { if (!displayedWorkspace) return -1 var ws = niriWorkspacesList.find(function(w) { return w.output === displayedWorkspace.output && w.idx === displayedWorkspace.idx - 1 }) return ws ? ws.id : -1 } property int belowAdjWorkspaceId: { if (!displayedWorkspace) return -1 var ws = niriWorkspacesList.find(function(w) { return w.output === displayedWorkspace.output && w.idx === displayedWorkspace.idx + 1 }) return ws ? ws.id : -1 } // Vertical translation of the strip stack during a transition. // Steady state = 0. +stripHeight = above slot in view. -stripHeight = below slot in view. property real transitionOffset: 0 Component.onCompleted: { if (activeWorkspace) displayedWorkspaceId = activeWorkspace.id } // Drive the slide animation from displayedWorkspaceId toward activeWorkspace.id. // If a slide is already in flight, leave it alone; onStopped will re-invoke us to // chain a new slide from the just-reached workspace to whatever the latest active // workspace is. That way rapid switches turn into a sequence of slides instead of // a single slide followed by a snap-cut. function syncWorkspaceAnim() { if (!activeWorkspace) return var newId = activeWorkspace.id if (displayedWorkspaceId === -1) { displayedWorkspaceId = newId return } if (displayedWorkspaceId === newId) return if (slideAnim.running) return var oldWs = niriWorkspacesList.find(function(ws) { return ws.id === displayedWorkspaceId }) if (!oldWs || oldWs.output !== activeWorkspace.output) { displayedWorkspaceId = newId return } // niri slides screen DOWN when focusing workspace UP -> stack offset goes +. slideAnim.goingUp = activeWorkspace.idx < oldWs.idx slideAnim.targetWsId = newId slideAnim.start() } onActiveWorkspaceChanged: syncWorkspaceAnim() NumberAnimation { id: slideAnim target: windowList property: "transitionOffset" duration: 200 easing.type: Easing.OutCubic property bool goingUp: false // The workspace this slide is animating *to*. Tracked explicitly so onStopped // doesn't snap straight to the latest activeWorkspace (which can race ahead // mid-flight during rapid switching). property int targetWsId: -1 to: goingUp ? windowList.stripHeight : -windowList.stripHeight onStopped: { // Land on this slide's intended target, then re-evaluate: if the active // workspace has moved further while we were animating, syncWorkspaceAnim // kicks off the next slide from here. windowList.displayedWorkspaceId = targetWsId windowList.transitionOffset = 0 windowList.syncWorkspaceAnim() } } function windowsForWorkspace(wsId) { var wins = niriWindowsList.filter(function(w) { return w.workspace_id === wsId }) wins.sort(function(a, b) { var ap = a.layout && a.layout.pos_in_scrolling_layout var bp = b.layout && b.layout.pos_in_scrolling_layout if (ap && bp) return ap[0] !== bp[0] ? ap[0] - bp[0] : ap[1] - bp[1] return 0 }) return wins } // Crop rectangle for a window in QML (logical) px, or null when niri does not // expose its position: tiled windows report null for tile_pos_in_workspace_view // (niri #2381/#4147). Fullscreen/maximized is detected as near-full-output tile. function winRegion(win) { var lay = win.layout if (!lay || !outputScreen || !outputScreen.width || !outputScreen.height) return null var outW = outputScreen.width, outH = outputScreen.height var ts = lay.tile_size if (ts && ts[0] > 0 && ts[1] > 0) { if (ts[0] >= outW * 0.95 && ts[1] >= outH * 0.95) { return { x: 0, y: 0, w: outW, h: outH } } var pos = lay.tile_pos_in_workspace_view if (pos) { return { x: pos[0], y: pos[1], w: ts[0], h: ts[1] } } } return null } // workspaces above a given workspace, farthest -> closest function workspacesAbove(wsId) { var ws = niriWorkspacesList.find(function(w) { return w.id === wsId }) if (!ws) return [] return niriWorkspacesList .filter(function(w) { return w.output === ws.output && w.idx < ws.idx }) .sort(function(a, b) { return a.idx - b.idx }) } // workspaces below a given workspace, closest -> farthest function workspacesBelow(wsId) { var ws = niriWorkspacesList.find(function(w) { return w.id === wsId }) if (!ws) return [] return niriWorkspacesList .filter(function(w) { return w.output === ws.output && w.idx > ws.idx }) .sort(function(a, b) { return a.idx - b.idx }) } Process { id: focusWindowProcess property int targetId: 0 command: ["niri", "msg", "action", "focus-window", "--id", targetId.toString()] } Process { id: focusWorkspaceUp command: ["niri", "msg", "action", "focus-workspace-up"] } Process { id: focusWorkspaceDown command: ["niri", "msg", "action", "focus-workspace-down"] } // A full duplicate of the bar's left section, rendered for a given workspace. // Includes: bg + focus highlight + current-workspace pills + ↑ + above pills + ↓ + below pills. // Three instances live in the clipped viewport (above / current / below) and slide vertically // on workspace change. Component { id: leftSectionComp Item { id: section property int displayWsId: -1 property var displayWs: niriWorkspacesList.find(function(ws) { return ws.id === displayWsId }) || null property bool isCurrent: displayWsId === (windowList.activeWorkspace ? windowList.activeWorkspace.id : -1) property var outputScreen: windowList.outputScreen // Briefly suppresses the highlight's slide Behavior whenever this section's // workspace changes (e.g., the Loader rebinds after a slide ends). Without this, // the highlight would animate from the OLD workspace's focused position to the // NEW one's across now-different pills, producing the "second" animation. property bool _allowHighlightAnim: true onDisplayWsIdChanged: { _allowHighlightAnim = false Qt.callLater(function() { section._allowHighlightAnim = true }) section.scheduleModelSync() } ListModel { id: currentPillsModel } ListModel { id: aboveWinsModel } ListModel { id: belowWinsModel } function modelRow(window, current) { var region = window.region ?? windowList.winRegion(window) return { windowId: window.id, appId: window.app_id ?? "", inViewport: !!window.inViewport, onCurrentWorkspace: !!current, thumb: windowList.thumbnails && (windowList.screencast || region !== null), hasRegion: region !== null, regionX: region ? region.x : 0, regionY: region ? region.y : 0, regionW: region ? region.w : 0, regionH: region ? region.h : 0, thumbWidth: windowList.thumbPillWidth(window) } } function sameModelRow(a, b) { return a.windowId === b.windowId && a.appId === b.appId && a.inViewport === b.inViewport && a.onCurrentWorkspace === b.onCurrentWorkspace && a.thumb === b.thumb && a.hasRegion === b.hasRegion && a.regionX === b.regionX && a.regionY === b.regionY && a.regionW === b.regionW && a.regionH === b.regionH && a.thumbWidth === b.thumbWidth } function syncListModel(model, rows) { for (var i = 0; i < rows.length; i++) { var wanted = rows[i] var current = i < model.count ? model.get(i) : null if (!current || current.windowId !== wanted.windowId) { var found = -1 for (var j = i + 1; j < model.count; j++) { if (model.get(j).windowId === wanted.windowId) { found = j break } } if (found >= 0) { model.move(found, i, 1) } else { model.insert(i, wanted) } current = model.get(i) } if (!sameModelRow(current, wanted)) model.set(i, wanted) } while (model.count > rows.length) model.remove(model.count - 1) } function syncModels() { syncListModel(currentPillsModel, currentPills.map(function(pill) { return section.modelRow(pill, section.isCurrent) })) syncListModel(aboveWinsModel, aboveWinsList.map(function(window) { return section.modelRow(window, false) })) syncListModel(belowWinsModel, belowWinsList.map(function(window) { return section.modelRow(window, false) })) } property bool _modelSyncPending: false function scheduleModelSync() { if (_modelSyncPending) return _modelSyncPending = true Qt.callLater(function() { _modelSyncPending = false section.syncModels() }) } Component.onCompleted: scheduleModelSync() Connections { target: windowList function onNiriWindowsListChanged() { section.scheduleModelSync() } function onNiriWorkspacesListChanged() { section.scheduleModelSync() } function onActiveWorkspaceChanged() { section.scheduleModelSync() } function onThumbnailsChanged() { section.scheduleModelSync() } function onScreencastChanged() { section.scheduleModelSync() } } // pills for the workspace this section represents property var currentPills: { if (displayWsId < 0) return [] return windowsForWorkspace(displayWsId).map(function(w) { var region = windowList.winRegion(w) return Object.assign({}, w, { inViewport: w.is_focused, onCurrentWorkspace: section.isCurrent, region: region, isThumb: windowList.thumbnails && (windowList.screencast || region !== null) }) }) } // flat list of windows from all workspaces above this section's workspace property var aboveWinsList: { if (!displayWs) return [] var result = [] workspacesAbove(displayWsId).forEach(function(ws) { result = result.concat(windowsForWorkspace(ws.id)) }) return result } // flat list of windows from all workspaces below this section's workspace property var belowWinsList: { if (!displayWs) return [] var result = [] workspacesBelow(displayWsId).forEach(function(ws) { result = result.concat(windowsForWorkspace(ws.id)) }) return result } // focus highlight is meaningful only for the workspace that actually has the // globally-focused window (niri marks exactly one window is_focused) property int focusedIdx: { for (var i = 0; i < currentPills.length; i++) { if (currentPills[i].inViewport) return i } return -1 } property real focusedX: { if (focusedIdx < 0) return 0 var x = 0 for (var i = 0; i < focusedIdx; i++) { x += pillWidth(currentPills[i]) } return x } property real focusedW: { if (focusedIdx < 0) return 0 return pillWidth(currentPills[focusedIdx]) } height: windowList.stripHeight width: sectionRow.width // bg strip behind everything Rectangle { anchors.fill: parent color: "#1a1a1a" } // focus highlight. Slides between pills within a workspace, but snaps during // workspace transitions (controlled by _allowHighlightAnim and slideAnim.running). Rectangle { visible: section.focusedIdx >= 0 x: section.focusedX width: section.focusedW height: windowList.stripHeight color: "#555" border.color: "#888" border.width: 1 Behavior on x { enabled: section._allowHighlightAnim && !slideAnim.running NumberAnimation { duration: 180; easing.type: Easing.OutCubic } } Behavior on width { enabled: section._allowHighlightAnim && !slideAnim.running NumberAnimation { duration: 180; easing.type: Easing.OutCubic } } } Row { id: sectionRow spacing: 0 height: windowList.stripHeight Repeater { model: currentPillsModel delegate: windowPill } // ↑ arrow with 1px bar-bg dividers Item { visible: section.aboveWinsList.length > 0 width: visible ? 28 : 0 height: parent.height Rectangle { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter width: 1 height: 26 color: windowList.barColor } Rectangle { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter width: 1 height: 26 color: windowList.barColor } Rectangle { anchors.centerIn: parent width: 26 height: 26 radius: 0 color: upArea.containsMouse ? "#2a2a2a" : "transparent" border.color: "transparent" border.width: 1 Text { anchors.centerIn: parent text: "↑" color: "#888" font.family: "Inter" font.pixelSize: 14 font.weight: 700 } } MouseArea { id: upArea anchors.fill: parent hoverEnabled: true onClicked: focusWorkspaceUp.running = true } } Repeater { model: aboveWinsModel delegate: windowPill } // ↓ arrow with 1px bar-bg dividers Item { visible: section.belowWinsList.length > 0 width: visible ? 28 : 0 height: parent.height Rectangle { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter width: 1 height: 26 color: windowList.barColor } Rectangle { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter width: 1 height: 26 color: windowList.barColor } Rectangle { anchors.centerIn: parent width: 26 height: 26 radius: 0 color: downArea.containsMouse ? "#2a2a2a" : "transparent" border.color: "transparent" border.width: 1 Text { anchors.centerIn: parent text: "↓" color: "#888" font.family: "Inter" font.pixelSize: 14 font.weight: 700 } } MouseArea { id: downArea anchors.fill: parent hoverEnabled: true onClicked: focusWorkspaceDown.running = true } } Repeater { model: belowWinsModel delegate: windowPill } } } } Component { id: windowPill Item { id: windowItem required property var windowId required property var appId required property var inViewport required property var onCurrentWorkspace required property var thumb required property var hasRegion required property var regionX required property var regionY required property var regionW required property var regionH required property var thumbWidth height: parent.height width: windowItem.isThumb ? windowItem.thumbWidth : winLabel.implicitWidth + 16 property var region: windowItem.hasRegion ? { x: windowItem.regionX, y: windowItem.regionY, w: windowItem.regionW, h: windowItem.regionH } : null property bool isThumb: !!windowItem.thumb property var outSize: { var s = windowList.outputScreen return s ? Qt.size(s.width, s.height) : Qt.size(0, 0) } // Fit the whole window into the box (no crop): scale to the smaller dimension. property real thumbScale: windowItem.region ? Math.min(windowItem.width / windowItem.region.w, windowList.stripHeight / windowItem.region.h) : 1 property bool _stopRestarting: false property int daemonSequence: windowList.screencast ? Screencast.sequenceFor(windowItem.windowId) : 0 property bool daemonFrameReady: daemonSequence > 0 property bool imageReadyForWindow: false onWindowIdChanged: imageReadyForWindow = false Rectangle { anchors.centerIn: parent width: parent.width height: parent.height radius: 0 color: (!windowItem.inViewport && hoverArea.containsMouse) ? "#2a2a2a" : "transparent" border.color: "transparent" border.width: 1 Text { id: winLabel visible: !windowItem.isThumb anchors.centerIn: parent text: windowItem.appId color: windowItem.inViewport ? "white" : (windowItem.onCurrentWorkspace ? "#b0b0b0" : "#888") font.family: "Inter" font.pixelSize: 13 font.weight: Font.Bold } Item { anchors.fill: parent visible: windowItem.isThumb clip: true Rectangle { anchors.fill: parent color: "#0d0d0d" } Image { id: daemonView anchors.fill: parent visible: windowList.screencast ? windowItem.daemonFrameReady && windowItem.imageReadyForWindow : true source: windowList.screencast ? Screencast.imageSource(windowItem.windowId) : "" cache: false asynchronous: true retainWhileLoading: true smooth: true fillMode: Image.PreserveAspectFit onStatusChanged: { if (status === Image.Ready) windowItem.imageReadyForWindow = true } } ScreencopyView { id: thumbView // Native screencopy is only a legacy fallback when the daemon // integration is disabled. Daemon mode must not capture output // pixels or create a second Wayland capture path. captureSource: (!windowList.screencast && !windowItem._stopRestarting && windowItem.outSize.width > 0) ? windowList.outputScreen : null visible: !windowList.screencast && !windowItem.daemonFrameReady width: windowItem.outSize.width * windowItem.thumbScale height: windowItem.outSize.height * windowItem.thumbScale x: windowItem.region ? windowItem.width / 2 - (windowItem.region.x + windowItem.region.w / 2) * windowItem.thumbScale : 0 y: windowItem.region ? windowList.stripHeight / 2 - (windowItem.region.y + windowItem.region.h / 2) * windowItem.thumbScale : 0 onStopped: { windowItem._stopRestarting = true Qt.callLater(function() { windowItem._stopRestarting = false }) } } // A low-opacity tint keeps the hover cue on both thumbnail render paths. Rectangle { anchors.fill: parent z: 1 color: "#f2f2f2" opacity: hoverArea.containsMouse ? 0.08 : 0 } Rectangle { id: thumbnailLabelBackground visible: windowList.thumbnailLabels && windowItem.appId.length > 0 anchors.left: parent.left anchors.top: parent.top anchors.margins: 4 z: 2 width: Math.min(parent.width - 8, thumbnailLabel.implicitWidth + 8) height: thumbnailLabel.implicitHeight + 4 color: "#b0000000" radius: 2 Text { id: thumbnailLabel anchors.fill: parent anchors.leftMargin: 4 anchors.rightMargin: 4 text: windowItem.appId color: "white" font.family: "Inter" font.pixelSize: 11 font.weight: Font.Bold elide: Text.ElideRight verticalAlignment: Text.AlignVCenter } } } } MouseArea { id: hoverArea anchors.fill: parent hoverEnabled: true onClicked: { focusWindowProcess.targetId = windowItem.windowId focusWindowProcess.running = true } } } } // Clipped viewport showing one strip's worth of vertical space. Inside, a stack of // three full leftSectionComp instances (above / current / below) is translated up/down // on workspace change to reveal the adjacent section. Item { id: sectionStack anchors.left: parent.left y: windowList.transitionOffset height: parent.height width: parent.width Loader { id: aboveSectionLoader sourceComponent: leftSectionComp y: -windowList.stripHeight Binding { target: aboveSectionLoader.item property: "displayWsId" value: windowList.aboveAdjWorkspaceId when: aboveSectionLoader.item !== null } } Loader { id: currentSectionLoader sourceComponent: leftSectionComp y: 0 Binding { target: currentSectionLoader.item property: "displayWsId" value: windowList.displayedWorkspaceId when: currentSectionLoader.item !== null } } Loader { id: belowSectionLoader sourceComponent: leftSectionComp y: windowList.stripHeight Binding { target: belowSectionLoader.item property: "displayWsId" value: windowList.belowAdjWorkspaceId when: belowSectionLoader.item !== null } } } }