diff --git a/CLAUDE.md b/CLAUDE.md index 5e0142a..55338dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/DECISIONS.md b/DECISIONS.md index 90f2e4a..8152a17 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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 diff --git a/defaults/xdg/quickshell/moonarch/Niri.qml b/defaults/xdg/quickshell/moonarch/Niri.qml index 2cddb27..8a4e173 100644 --- a/defaults/xdg/quickshell/moonarch/Niri.qml +++ b/defaults/xdg/quickshell/moonarch/Niri.qml @@ -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 + } } diff --git a/defaults/xdg/quickshell/moonarch/WorkspaceWindows.qml b/defaults/xdg/quickshell/moonarch/WorkspaceWindows.qml new file mode 100644 index 0000000..d14a296 --- /dev/null +++ b/defaults/xdg/quickshell/moonarch/WorkspaceWindows.qml @@ -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 + } + } + } +} diff --git a/defaults/xdg/quickshell/moonarch/qmldir b/defaults/xdg/quickshell/moonarch/qmldir index 575990e..2491963 100644 --- a/defaults/xdg/quickshell/moonarch/qmldir +++ b/defaults/xdg/quickshell/moonarch/qmldir @@ -56,3 +56,4 @@ UpdatesIndicator UpdatesIndicator.qml VolumeSlider VolumeSlider.qml WindowTitle WindowTitle.qml Workspaces Workspaces.qml +WorkspaceWindows WorkspaceWindows.qml diff --git a/defaults/xdg/quickshell/moonarch/shell.qml b/defaults/xdg/quickshell/moonarch/shell.qml index f3cd8e1..ed7ffb8 100644 --- a/defaults/xdg/quickshell/moonarch/shell.qml +++ b/defaults/xdg/quickshell/moonarch/shell.qml @@ -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 : "" } ]