import QtQuick 2.15 import Quickshell.Io // 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: Config.color("background") // bar background, used for 1px pill dividers 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 } function pillWidth(appId) { return pillFm.advanceWidth(appId ?? "") + 16 } readonly property int stripHeight: 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 } // 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) // 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 }) } // pills for the workspace this section represents property var currentPills: { if (displayWsId < 0) return [] return windowsForWorkspace(displayWsId).map(function(w) { return Object.assign({}, w, {inViewport: w.is_focused, onCurrentWorkspace: section.isCurrent}) }) } // 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].app_id) } return x } property real focusedW: { if (focusedIdx < 0) return 0 return pillWidth(currentPills[focusedIdx].app_id) } height: windowList.stripHeight width: sectionRow.width // bg strip behind everything Rectangle { anchors.fill: parent color: Config.color("surface") } // 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: Config.color("focus") border.color: Config.color("focusBorder") 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: section.currentPills 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 ? Config.color("hover") : Config.color("transparent") border.color: Config.color("transparent") border.width: 1 Text { anchors.centerIn: parent text: "↑" color: Config.color("muted") font.family: "Inter" font.pixelSize: 14 font.weight: 700 } } MouseArea { id: upArea anchors.fill: parent hoverEnabled: true onClicked: focusWorkspaceUp.running = true } } Repeater { model: section.aboveWinsList 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 ? Config.color("hover") : Config.color("transparent") border.color: Config.color("transparent") border.width: 1 Text { anchors.centerIn: parent text: "↓" color: Config.color("muted") font.family: "Inter" font.pixelSize: 14 font.weight: 700 } } MouseArea { id: downArea anchors.fill: parent hoverEnabled: true onClicked: focusWorkspaceDown.running = true } } Repeater { model: section.belowWinsList delegate: windowPill } } } } Component { id: windowPill Item { id: windowItem required property var modelData height: parent.height width: winLabel.implicitWidth + 16 Rectangle { anchors.centerIn: parent width: winLabel.implicitWidth + 16 height: 26 radius: 0 color: (!windowItem.modelData.inViewport && hoverArea.containsMouse) ? Config.color("hover") : Config.color("transparent") border.color: Config.color("transparent") border.width: 1 Text { id: winLabel anchors.centerIn: parent text: windowItem.modelData.app_id ?? "" color: windowItem.modelData.inViewport ? Config.color("foreground") : (windowItem.modelData.onCurrentWorkspace ? Config.color("secondaryText") : Config.color("muted")) font.family: "Inter" font.pixelSize: 13 font.weight: 700 } } MouseArea { id: hoverArea anchors.fill: parent hoverEnabled: true onClicked: { focusWindowProcess.targetId = windowItem.modelData.id 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 } } } }