feat(quickshell): show every window of the workspace as an icon
Update PKGBUILD version / update-pkgver (push) Successful in 5s

WorkspaceWindows replaces WindowTitle in the default composition: one
icon per window on the screen's active workspace, in niri layout order,
the marked window's title after the icons. Niri.qml handles
WindowLayoutsChanged and gains focusWindow(id).
This commit is contained in:
nevaforget committed 2026-10-15 18:16:10 +02:00
1 parent 3ecb55357f
commit 4398ecb4db
6 files changed
+196 -1

No files matched your search

+2
View File
@@ -121,6 +121,8 @@ quickshell is the **default bar and notification daemon**, started by `quickshel
- **Calendar:** clicking the clock opens `CalendarPopout.qml` (registered as `calendar`) — a `MonthGrid` with a `WeekNumberColumn`, both from `QtQuick.Controls` (already pulled in by `TrayMenu`). Today's cell is an `accent` rectangle with `base` text; leading/trailing days stay visible in `Theme.surface2` because the grid always renders six weeks and hiding them would leave the week-number column pointing at blank rows. Header actions page the month (‹ / ›) and snap back to today (calendar glyph); ←/→ page as well, which no other control in this popout claims. Week start and weekday/month names come from the **system locale** (`Qt.locale()`), so `de_DE` yields Monday-first and ISO week numbers; the title uses `standaloneMonthName` because some locales inflect the month inside a full date. The popout instance survives reopens, so `onShownChanged` snaps back to the current month on every open. Two implementation notes: the clock's root is a `Text`, whose `implicitHeight` is **read-only** (derived from content) — the full bar height goes on `height`, which is what keeps the topmost pixels clickable and anchors the popout below the bar; and a derived `Component.onCompleted` **adds** a handler rather than replacing the base one (verified with an exit-code probe), so `PopoutPanel`'s `focusFirst` still runs alongside the popout's own init. The clock's former click action (long/short date toggle) was dropped — the popout and the tooltip both carry the full date.
- **Workspace windows:** `WorkspaceWindows.qml` sits next to `Workspaces` in the default composition and shows one app icon per window on the **active workspace of its own screen** (`output === screenName && is_active`). Order follows the niri layout: tiled windows by `layout.pos_in_scrolling_layout` (column, then tile in column, 1-based), floating ones after them by id — that field is documented for tiled windows only and is optional, so a tiled window without it sorts with the floating ones. The icons stay put; the marked window's title follows **after all icons** with an animated width (an earlier accordion with the title between the icons was dropped — every focus change shifted the icons). The marker reuses the workspace styling from `Workspaces.qml`: the focused window gets `Theme.accent` at 18 %; on a screen that does not hold the focus, the workspace's `active_window_id` gets `Theme.text` at 8 % and its title is shown there too. Icons resolve via `DesktopEntries.heuristicLookup(app_id)` with `application-x-executable` as the `iconPath` fallback, so every window stays visible. Left click → `Niri.focusWindow(id)`, middle click → `Niri.closeWindow(id)`. The title's width comes from a `TextMetrics`, not from the `Text`'s own `implicitWidth` — that self-reference is a binding loop while the width animates. Reordering only works because `Niri.qml` handles `WindowLayoutsChanged` (`changes: [[id, layout], …]`); without it, moving a column leaves the icons in the old order. `Quickshell.WindowManager` is no alternative: its `Windowset` (ext-workspace) has no window list. `WindowTitle.qml` stays in the library for user `shell.qml` copies that still compose it.
- **Hover affordance:** clickable bar widgets dim to `opacity: 0.8` on hover (110ms `animDurationShort` fade) so they read as interactive. Deliberately **opacity, not color** — the icon color already encodes status (muted/off → `subtext0`, active → `accent`, battery critical → red), so a color-based hover would clobber the state signal. The Launcher is the exception: no status color, so it uses an `accent` hover instead. Applied per widget (no shared parent to hoist it to); `Workspaces`/`Tray` keep their own per-element hover, non-clickable `PrivacyIndicator`/`WindowTitle` get none.
- **Network indicator + VPN:** quickshell's Networking API exposes no VPN, so `NetworkService.qml` (singleton) holds the VPN connection list via nmcli — event-driven through a long-running `nmcli monitor` process (debounced refresh) plus a 60s fallback poll; shared by `NetworkIndicator` and `NetworkPopout`. The indicator shows connecting (cycling strength glyphs), an active VPN (MDI `wifi-strength-N-lock` variants; wired+VPN → `security-network`), and wired (`ethernet` glyph). **Wired wins over wifi** in both indicator and popout: when both a wired and a wifi connection are up, NM routes over ethernet (lower metric) and nm-applet shows the wired icon — so the widget checks `wiredDev` first (also beats wifi-off, so ethernet-only machines don't show a dead wifi icon).
+7
View File
@@ -1,5 +1,12 @@
# Decisions
## 2026-10-15 – quickshell: every window of the workspace as an icon, title after the icons
- **Who**: Dominik, ClaudeCode
- **Why**: The bar showed only the focused window (`WindowTitle.qml`). The wish was all windows of the workspace side by side as icons, in layout order, with the title of the focused one.
- **Tradeoffs**: (1) **New widget, not an extension of `WindowTitle`.** User `shell.qml` copies that compose `WindowTitle` keep their behaviour; the default composition switches. (2) **Data from niri IPC, not `Quickshell.WindowManager`.** Quickshell 0.3.2's `Windowset` (ext-workspace) exposes no window list, so `Niri.qml` stays the source — it already holds every window with `workspace_id` and `layout`. (3) **Per screen, not the focused workspace everywhere.** Each bar shows its own monitor's active workspace. The focused window is marked like the focused workspace in `Workspaces.qml` (`Theme.accent` 18 %); on the other screens the workspace's `active_window_id` is marked like an active workspace (`Theme.text` 8 %), and each bar shows the title of its marked window. (4) **Floating windows at the end, sorted by id.** `pos_in_scrolling_layout` is documented for tiled windows only and is optional (niri-ipc 26.4.0), so it is not relied on for floating ones. (5) **Generic icon instead of hiding.** A window whose `app_id` has no desktop entry would otherwise vanish from the row; `iconPath(icon, fallback)` keeps it as `application-x-executable`. (6) **`heuristicLookup` over `byId`.** Quickshell's docs recommend it as finding entries more often. (7) **Title after the icons, not between them.** A first version unfolded the title next to the focused icon (an accordion); in use, every focus change shifted the icons to its right, which made them hard to hit. The icons now stay put and the marker shows which one the title belongs to.
- **How**: `WorkspaceWindows.qml` added and composed in `shell.qml` in place of `WindowTitle`. `Niri.qml` gained `focusWindow(id)` and a handler for `WindowLayoutsChanged` (`changes: [[id, layout], …]`), without which reordering a column left the icons stale. The title width is measured with `TextMetrics`; binding it to the `Text`'s own `implicitWidth` produced a binding-loop warning once the width was animated.
## 2026-09-14 – moonarch-lock: the input form follows the monitor you are on
- **Who**: Dominik, ClaudeCode
+22
View File
@@ -108,6 +108,16 @@ Singleton {
if (w2.is_focused)
root.focusedWindowId = w2.id;
root.recomputeActive();
} else if (ev.WindowLayoutsChanged) {
// changes: [[window id, layout], …] — only the layout of known windows is replaced.
var m4 = Object.assign({}, root.windowsById);
ev.WindowLayoutsChanged.changes.forEach(function (c) {
if (m4[c[0]])
m4[c[0]] = Object.assign({}, m4[c[0]], {
layout: c[1]
});
});
root.windowsById = m4;
} else if (ev.WindowClosed) {
var m3 = Object.assign({}, root.windowsById);
delete m3[ev.WindowClosed.id];
@@ -145,4 +155,16 @@ Singleton {
Process {
id: closeProc
}
// Focus a window by id, same reasoning as closeWindow.
function focusWindow(id) {
if (id === null || id === undefined)
return;
focusWindowProc.command = ["niri", "msg", "action", "focus-window", "--id", String(id)];
focusWindowProc.running = true;
}
Process {
id: focusWindowProc
}
}
@@ -0,0 +1,162 @@
// ABOUTME: Workspace windows bar widget — one app icon per window on this screen's active workspace, in layout order.
// ABOUTME: The focused window's icon carries the active indicator and its title follows the icons; click focuses, middle click closes.
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
Row {
id: root
property string screenName: ""
property int maxWidth: 420
height: Theme.barHeight
spacing: 2
readonly property int iconSize: Theme.fontSize + 4
readonly property string fallbackIcon: "application-x-executable"
readonly property var workspace: Niri.workspaces.find(x => x && x.output === root.screenName && x.is_active) || null
// Tiled windows by (column, tile in column), then floating ones by id. pos_in_scrolling_layout is only
// documented for tiled windows and optional, so a tiled window without it sorts with the floating ones.
function position(w) {
return (!w.is_floating && w.layout && w.layout.pos_in_scrolling_layout) ? w.layout.pos_in_scrolling_layout : null;
}
function order(a, b) {
const pa = root.position(a);
const pb = root.position(b);
if (pa && pb)
return (pa[0] - pb[0]) || (pa[1] - pb[1]);
if (pa)
return -1;
if (pb)
return 1;
return a.id - b.id;
}
readonly property var windows: root.workspace ? Object.values(Niri.windowsById).filter(w => w.workspace_id === root.workspace.id).sort(root.order) : []
// Title of the window whose icon carries the indicator: the focused window when it is on this
// workspace, otherwise the workspace's active window on a screen that does not hold the focus.
readonly property string focusedTitle: {
if (!root.workspace)
return "";
const f = Niri.windowsById[Niri.focusedWindowId];
if (f && f.workspace_id === root.workspace.id)
return f.title || "";
const a = root.workspace.is_focused ? null : Niri.windowsById[root.workspace.active_window_id];
return a ? (a.title || "") : "";
}
// Room left for the title once every icon is placed.
readonly property int titleMaxWidth: Math.max(0, root.maxWidth - root.windows.length * (Theme.moduleHeight + root.spacing))
function iconFor(appId) {
const e = appId ? DesktopEntries.heuristicLookup(appId) : null;
return Quickshell.iconPath(e && e.icon ? e.icon : root.fallbackIcon, root.fallbackIcon);
}
function rewrite(t) {
if (!t)
return "";
var m = t.match(/^(.*) - Mozilla Firefox$/);
if (m)
return "🌎 " + m[1]; // globe emoji
m = t.match(/^(.*) - zsh$/);
if (m)
return "> [" + m[1] + "]";
return t;
}
Repeater {
model: root.windows
delegate: Item {
id: entry
required property var modelData
readonly property bool focused: modelData.id === Niri.focusedWindowId
// The workspace's last active window, shown dimmer on a screen that does not hold the focus.
readonly property bool active: root.workspace !== null && !root.workspace.is_focused && modelData.id === root.workspace.active_window_id
width: Theme.moduleHeight
height: root.height
// Hover affordance — dim slightly so each icon reads as clickable.
opacity: hover.hovered ? 0.8 : 1.0
Behavior on opacity {
NumberAnimation {
duration: Theme.animDurationShort
}
}
// Same active styling as the focused / active workspace in Workspaces.qml.
Rectangle {
anchors.centerIn: parent
width: Theme.moduleHeight
height: Theme.moduleHeight
radius: Theme.radius
color: entry.focused ? Qt.rgba(Theme.accent.r, Theme.accent.g, Theme.accent.b, 0.18) : entry.active ? Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.08) : "transparent"
}
Image {
anchors.centerIn: parent
source: root.iconFor(entry.modelData.app_id || "")
width: root.iconSize
height: root.iconSize
sourceSize.width: root.iconSize
sourceSize.height: root.iconSize
fillMode: Image.PreserveAspectFit
}
HoverHandler {
id: hover
}
TapHandler {
acceptedButtons: Qt.LeftButton
onTapped: Niri.focusWindow(entry.modelData.id)
}
// Middle click closes the window the icon belongs to.
TapHandler {
acceptedButtons: Qt.MiddleButton
onTapped: Niri.closeWindow(entry.modelData.id)
}
Tooltip {
anchorItem: entry
shown: hover.hovered
text: entry.modelData.title || ""
}
}
}
// Measured apart from the Text itself: binding the Text's width to its own implicitWidth loops
// while the width animates.
TextMetrics {
id: titleMetrics
font: title.font
text: title.text
}
// Zero width without a marked window here; the padding lives inside the width so a collapsed
// title leaves no gap behind the icons.
Text {
id: title
height: parent.height
verticalAlignment: Text.AlignVCenter
leftPadding: 6
clip: true
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: Theme.subtext1
elide: Text.ElideRight
text: root.rewrite(root.focusedTitle)
width: root.focusedTitle ? Math.min(Math.ceil(titleMetrics.advanceWidth) + leftPadding, root.titleMaxWidth) : 0
Behavior on width {
NumberAnimation {
duration: Theme.animDurationShort
easing.type: Theme.animEasing
}
}
}
}
+1
View File
@@ -56,3 +56,4 @@ UpdatesIndicator UpdatesIndicator.qml
VolumeSlider VolumeSlider.qml
WindowTitle WindowTitle.qml
Workspaces Workspaces.qml
WorkspaceWindows WorkspaceWindows.qml
+2 -1
View File
@@ -28,9 +28,10 @@ ShellRoot {
Layout.alignment: Qt.AlignVCenter
screenName: bar.screen ? bar.screen.name : ""
},
WindowTitle {
WorkspaceWindows {
Layout.alignment: Qt.AlignVCenter
Layout.maximumWidth: 420
screenName: bar.screen ? bar.screen.name : ""
}
]