feat(quickshell): open a calendar on clock click
Update PKGBUILD version / update-pkgver (push) Successful in 6s

The clock's click toggled the date between short and long form, which the
tooltip already showed in full. It now opens a calendar popout instead.

CalendarPopout uses MonthGrid + WeekNumberColumn + DayOfWeekRow from
QtQuick.Controls (already imported by TrayMenu) so the locale rules —
week start, ISO week numbers, weekday and month names — come from Qt
rather than being re-implemented; only the delegates are ours. Leading
and trailing days stay visible but dimmed, since the grid always renders
six weeks and hiding them would leave the week-number column labelling
blank rows.

Clock takes the full bar height on `height`, not `implicitHeight`: on a
Text root that property is read-only, and the assignment fails the config
load outright.
This commit is contained in:
2026-08-03 11:06:50 +02:00
parent 798f73c5d3
commit a95b524d79
6 changed files with 200 additions and 6 deletions
+3 -1
View File
@@ -92,10 +92,12 @@ quickshell is the **default bar and notification daemon**, spawned by niri as `q
- **Own popouts:** a composed widget carries its own popout as a `Component` and renders it in the shared morphing host. Pattern (see any indicator, e.g. `AudioIndicator.qml`): an inline `Component { AudioPopout { popouts: root.popouts } }`; the widget declares a `popoutName` and registers on completion (`popouts.register(popoutName, popoutComponent, root, payloadFn)`), then its click handler calls `popouts.toggleByName(popoutName)`. The widget file needs `pragma ComponentBehavior: Bound` so the popout resolves `root` when the shared host instantiates it. The low-level `popouts.open(component, rect, payload)` still exists for non-registered popouts (Tray). The host anchors the popout body to the trigger's on-screen box and picks the side from it: **left-aligned** when the trigger sits in the left half of the screen (so left-cluster popouts grow rightward and stay on-screen), **right-aligned** otherwise (`PopoutHost.qml` `frame.leftAnchored`). The anchor rect carries the trigger's left edge + width; both `openByName` and Tray's direct `open()` pass that box.
- **Popouts by keybind:** registered popouts are addressable by name through the `PopoutRouter` singleton, which exposes an `IpcHandler { target: "popout" }` with `toggle`/`open`/`close`. A niri keybind fires `qs -c moonarch ipc call popout toggle <name>` (e.g. `Mod+A` → `audio`, in `defaults/xdg/niri/config.kdl`). The router routes to the focused monitor's `PopoutState` via `Niri.activeOutput` (workspace `is_focused` → `output`, matched against `screen.name`); an open popout grabs the keyboard (Escape closes). Names: `audio backlight network bluetooth nightlight cpugov battery notifications media launcher camera`. Tray is not keybind-addressable.
- **Popouts by keybind:** registered popouts are addressable by name through the `PopoutRouter` singleton, which exposes an `IpcHandler { target: "popout" }` with `toggle`/`open`/`close`. A niri keybind fires `qs -c moonarch ipc call popout toggle <name>` (e.g. `Mod+A` → `audio`, in `defaults/xdg/niri/config.kdl`). The router routes to the focused monitor's `PopoutState` via `Niri.activeOutput` (workspace `is_focused` → `output`, matched against `screen.name`); an open popout grabs the keyboard (Escape closes). Names: `audio backlight network bluetooth nightlight cpugov battery notifications media launcher camera calendar`. Tray is not keybind-addressable.
- **Launcher:** `Launcher.qml` is the first widget in the left cluster (an apps glyph) and owns `LauncherPopout.qml` — an application launcher over `DesktopEntries.applications` (search field + keyboard-navigable icon+name list; ↑/↓ select, Enter/click launch via `entry.execute()`, Esc close; app icons via `Quickshell.iconPath`). It registers under `launcher` (so it is IPC-addressable) and is bound to `Mod+Space` (`qs -c moonarch ipc call popout toggle launcher`, in `config.kdl`) in addition to icon-click. Scope is app-launch (drun) only; `walker` stays installed and keeps the other modes — `Super+C` still opens walker's clipboard, and walker remains the run-command/calc launcher (no longer on a keybind).
- **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.
- **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
@@ -268,3 +268,10 @@
- **Tradeoffs**: (1) **Mechanism**`authorized` on the USB device beats `modprobe -r uvcvideo` (per device rather than all cameras at once, and no reboot after 59 toggles as the Codeberg camera-control tool reports for the module path) and beats interface-level tricks (there is no per-interface authorize for non-wireless USB). Measured: `authorized=0` drops the `/dev/videoN` nodes immediately, udev emits `remove` per node, the PipeWire node disappears; `authorized=1` brings the nodes back, `uvcvideo` rebinds itself, capture works, and PipeWire re-exposes the camera a few seconds later under a **new** node id. Cost: the switch cuts the whole USB function, so a webcam's built-in microphone dies with it (`3-1` carries `01/01,01/02`) and the internal camera's DFU interface too (`5-1` carries `fe/01`, so fwupd cannot update it while off) — surfaced in the popout. USB-only by construction: MIPI/IPU6 cameras have no `authorized`, the widget self-hides there. (2) **Identification**`descriptors` (world-readable, `-r--r--r--`) carries every interface class and is the only source that works in **both** states. Measured across a full deauthorize cycle of the internal camera: the blob stays at 1420 bytes with identical interface classes while `authorized=0`, whereas the interface directories drop from 5 to 0 — so they cannot be the gate. `bDeviceClass` is useless as well (`0xef` on every composite camera). `moonarch-camera-list` correctly reported the switched-off camera as `off`, i.e. it stays listable and switchable with no state of our own. Verified across 21 USB devices that interface class `0x0e` selects exactly the two cameras (hubs `09`, HID `03`, audio `01`, AV adapter `11`). Gotcha: `wc -c` reports 65553 for that file on every device — the real length is 1420 / 1332 bytes here, so the TLV walk must stop on `bLength == 0`. (3) **No state file** — an earlier draft kept a registry of known cameras under `/var/lib/moonarch/`; rejected because that directory is group-writable by `wheel`, so the privileged helper would have been validating its target against a caller-writable allowlist. It now trusts only kernel data: `descriptors`, `DEVTYPE=usb_device` from `uevent`, and the `authorized` symlink resolved into `/sys/devices/`. Note `/sys/bus/usb/devices/<id>` is itself a symlink, so confinement cannot be checked against that prefix. (4) **Boot enforcement** — a systemd oneshot, not a udev rule. A rule on `add` would also disable **hot-plugged** cameras, and plugging one in is a deliberate act; a fresh webcam that is dead until a password prompt is the wrong default. Accepted gap: `udevadm settle` only waits for already-queued events, so a camera enumerating very late behind a dock can stay on until switched. (5) **Auth** — plain `pkexec` with the standard password prompt, as `moonarch-batsaver-*` (2026-05-04); no polkit rule, since a no-password rule would let any process in the session re-arm the camera unnoticed and defeat the switch. Popout-spawned prompts are typeable thanks to the `OnDemand` keyboard focus fix (2026-07-09).
- **How**: Four scripts in `defaults/bin/``moonarch-camera-list` (unprivileged; prints `id|vid:pid|label|on/off|video[+audio]`, sanitizes device-supplied product strings so they cannot forge columns), `moonarch-camera-toggle` (unprivileged pkexec dispatch, passes the exit code through), `moonarch-camera-apply` (privileged; validation ladder = value ∈ {0,1}, bus-port id shape, device exists, `authorized` is a real file, resolved path under `/sys/devices/`, `DEVTYPE=usb_device`, video interface class present, then an idempotence skip before the write), `moonarch-camera-boot` (enumerates via `-list`, writes 0 to each). New unit `defaults/etc/systemd/system/moonarch-camera.service` (`Type=oneshot`, `ConditionPathExistsGlob=/sys/class/video4linux/video*`, batsaver's hardening block, enabled from `moonarch.install`; `Type=oneshot` also satisfies `moonarch-doctor`'s oneshot handling). QML trio `CameraService` / `CameraIndicator` / `CameraPopout`: the service keeps a `probed` flag so a failed list never empties the bar widget, an optimistic `pending` map so a row reacts before the password prompt returns, a single in-flight `pkexec` so prompts cannot stack, and treats exit 126 as a silent cancel. Its `udevadm monitor` watches the **usb** subsystem, not `video4linux` — unplugging an already switched-off camera emits no video4linux event, and the widget would keep listing a device that is gone.
- **Follow-up (same day, after audit)**: A security audit of the finished code found that the camera gate accepts a device as soon as *any* interface reports class `0x0e`, while `authorized=1` re-authorizes the **whole** device — so a composite device with an alibi video interface plus a hidden keyboard would appear in the popout as a camera and go live on the first switch-on. Rejected blocking it by allow-list or deny-list: the internal camera itself carries a DFU interface (`0xfe`) and multimedia devices commonly expose HID for their buttons (measured: the USB audio device at `3-2.2` reports `01,01,01,01,01,03`), so a class filter would lock out legitimate hardware, while the attack needs physical access to a port. Instead `moonarch-camera-list` now reports every notable interface class and the popout names them per row, in red for classes that have no business on a camera. Also fixed from the same audit: `textFormat: Text.PlainText` on the device-supplied label and in the shared `Tooltip` (matching `NotifToast`/`NotifCenter`, which already guard foreign text), and the label sanitizer now strips the invisible/directional Unicode range (`U+200B..U+200F`, `U+202A..U+202E`, `U+2066..U+2069`) under `LC_ALL=C`, so a device cannot make its name render as another one's — verified that `|`, control characters, RLO and zero-width characters are removed while umlauts survive. Left as known and accepted: a TOCTOU window between the checks and the write that would need dedicated switching hardware inside a millisecond gap after authentication, and the absence of `CapabilityBoundingSet`/`SystemCallFilter` in the unit, which is inherited from `moonarch-batsaver.service` and would be a shared change.
## 2026-08-03 Calendar popout on the clock (Qt calendar controls, no backend)
- **Who**: Dominik, ClaudeCode
- **Why**: Clicking the clock did nothing useful — it toggled the date between a short and a long form, which the tooltip already showed in full. A calendar is the expected thing behind a clock, and every other bar widget already opens a popout on click.
- **Tradeoffs**: (1) **Grid**`MonthGrid` + `WeekNumberColumn` + `DayOfWeekRow` from `QtQuick.Controls` rather than a hand-rolled grid: they carry the locale rules (week start, ISO week numbers, weekday names) that would otherwise be re-implemented, and `QtQuick.Controls` is already imported by `TrayMenu` (`StackView`), so no new dependency. Only the delegates are ours, so the Catppuccin look is unaffected. (2) **Click action** — the `showYear` toggle was dropped rather than moved to right-click: the popout header carries month + year and the tooltip carries the full date, so the toggle was redundant, and right-click in this bar means "tray context menu" everywhere else. (3) **Leading/trailing days shown dimmed** (`Theme.surface2`) instead of hidden as Qt's default delegate does — the grid always renders six weeks, so hiding them leaves the week-number column labelling blank rows. (4) **Locale, not a hardcoded week start**`Qt.locale()`, so `de_DE` gives Monday-first + ISO weeks and an `en_US` install gets its own convention; the title uses `standaloneMonthName` because a month inflected for a full date reads wrong on its own. (5) **No appointments** — nothing on the system provides them (no khal/vdirsyncer/Evolution), so a source would have to be chosen first; scoped out.
- **How**: New `CalendarPopout.qml` (`PopoutPanel`, `panelWidth: 300`; cell size derived so the week column plus seven day columns fill `contentWidth` exactly; header actions / calendar-today / , ←/→ page months, `onShownChanged` snaps back to today since the instance survives reopens). `Clock.qml` gains the standard popout wiring (`popoutName: "calendar"`, `register`, `toggleByName`) plus `pragma ComponentBehavior: Bound`, loses `showYear`, and takes the full bar height on **`height`** — `Text.implicitHeight` is read-only (content-derived) and assigning it fails the config load. `shell.qml` passes `popouts: bar.popouts` to `Clock`; `qmldir` regenerated. No niri keybind added (the name is IPC-addressable, a binding is a separate call).
- **Verified**: `qmllint` clean; throwaway `quickshell -p …/moonarch` reached `Configuration Loaded` with no binding loops (only the expected `org.freedesktop.Notifications` collision with the running instance). Screenshot of the live popout for August 2026: Monday-first, week 31 on the 27.07.02.08. row, today's cell measured at `#B3BEFD` = `Theme.accent` (the colour fringes on thin digits are subpixel antialiasing, confirmed by sampling). A throwaway probe config that imports the repo component stepped December 2026 → `shiftMonth(1)`**January 2027**, i.e. the year rollover and the dimmed neighbour-month days behave. Glyph `0xf00f6` confirmed as calendar-today by rendering the MDI range with ImageMagick. That a derived `Component.onCompleted` does not replace the base handler (so `PopoutPanel.focusFirst` still runs) was verified with a separate exit-code probe.
@@ -0,0 +1,160 @@
// ABOUTME: Calendar popout — month grid with week numbers, opened from the clock widget.
// ABOUTME: Month navigation is local view state; every open snaps back to the current month.
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import Quickshell
PopoutPanel {
id: root
property var popouts: null
panelWidth: 300
// Standalone month name — the genitive form some locales use inside a full date reads wrong alone.
title: Qt.locale().standaloneMonthName(root.viewMonth) + " " + root.viewYear
// Cell geometry — the week-number column plus seven day columns fill contentWidth exactly.
readonly property int gridSpacing: 4
readonly property int weekColWidth: 24
readonly property int cellSize: Math.floor((root.contentWidth - root.weekColWidth - root.gridSpacing * 7) / 7)
// Displayed month, 0-based as MonthGrid/WeekNumberColumn expect.
property int viewMonth: 0
property int viewYear: 0
SystemClock {
id: clock
precision: SystemClock.Minutes
}
function showToday() {
const d = clock.date;
root.viewMonth = d.getMonth();
root.viewYear = d.getFullYear();
}
// Paging through Date handles the year rollover (month -1 / 12) without a manual wrap.
function shiftMonth(delta) {
const d = new Date(root.viewYear, root.viewMonth + delta, 1);
root.viewMonth = d.getMonth();
root.viewYear = d.getFullYear();
}
// The popout instance persists across reopens, so initialize once on load and snap back to the
// current month on every open. PopoutPanel's own onCompleted (focusFirst) still runs — a derived
// Component.onCompleted adds a handler, it does not replace the base one.
Component.onCompleted: root.showToday()
readonly property bool shown: root.popouts && root.popouts.hasCurrent
onShownChanged: if (root.shown)
root.showToday()
// ←/→ page months (no slider in this popout to claim them); Tab/↑/↓ walk the header buttons.
Keys.onLeftPressed: root.shiftMonth(-1)
Keys.onRightPressed: root.shiftMonth(1)
headerActions: Row {
spacing: 10
IconButton {
pixelSize: Theme.fontSize + 2
text: String.fromCodePoint(0xf0141) // md-chevron-left
onActivated: root.shiftMonth(-1)
}
IconButton {
pixelSize: Theme.fontSize + 2
text: String.fromCodePoint(0xf00f6) // md-calendar-today
onActivated: root.showToday()
}
IconButton {
pixelSize: Theme.fontSize + 2
text: String.fromCodePoint(0xf0142) // md-chevron-right
onActivated: root.shiftMonth(1)
}
}
// Week numbers left, weekday header + days right. Both columns use the same row spacing and
// cell height, so the week number lines up with its row; the spacer skips the weekday header.
Row {
spacing: root.gridSpacing
Column {
spacing: 0
Item {
width: root.weekColWidth
height: dow.height
}
WeekNumberColumn {
month: root.viewMonth
year: root.viewYear
spacing: root.gridSpacing
leftPadding: 0
rightPadding: 0
delegate: Text {
id: weekCell
required property int weekNumber
width: root.weekColWidth
height: root.cellSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: weekCell.weekNumber
color: Theme.surface2
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 2
}
}
}
Column {
spacing: 0
DayOfWeekRow {
id: dow
spacing: root.gridSpacing
delegate: Text {
id: dowCell
required property string shortName
width: root.cellSize
horizontalAlignment: Text.AlignHCenter
text: dowCell.shortName
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
font.bold: true
}
}
MonthGrid {
month: root.viewMonth
year: root.viewYear
spacing: root.gridSpacing
// Leading/trailing days stay visible but dimmed — the grid always shows six weeks,
// so hiding them would leave the week-number column pointing at blank rows.
delegate: Item {
id: dayCell
required property var model
width: root.cellSize
height: root.cellSize
readonly property bool inMonth: dayCell.model.month === root.viewMonth
Rectangle {
anchors.fill: parent
radius: Theme.radius
color: Theme.accent
visible: dayCell.model.today
}
Text {
anchors.fill: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: dayCell.model.day
color: dayCell.model.today ? Theme.base : dayCell.inMonth ? Theme.text : Theme.surface2
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.bold: dayCell.model.today
}
}
}
}
}
}
+26 -4
View File
@@ -1,5 +1,6 @@
// ABOUTME: Clock/date bar widget backed by the quickshell SystemClock.
// ABOUTME: Click toggles between the short (no year) and long (with year) date.
// ABOUTME: Click opens the calendar popout; the tooltip carries the full date.
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
@@ -12,22 +13,43 @@ Text {
duration: Theme.animDurationShort
}
}
property bool showYear: false
property var popouts: null
property string popoutName: "calendar"
// Register with the shared popout state so this popout is addressable by keybind (niri IPC).
Component.onCompleted: if (root.popouts)
root.popouts.register(popoutName, popoutComponent, root, null)
// This widget's popout — handed to the shared animated host on click.
Component {
id: popoutComponent
CalendarPopout {
popouts: root.popouts
}
}
SystemClock {
id: clock
precision: SystemClock.Minutes
}
// Full bar height so the topmost pixels stay clickable and the popout anchors below the bar.
// Set on height, not implicitHeight — Text derives that from its content and rejects the write.
height: Theme.barHeight
verticalAlignment: Text.AlignVCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.bold: true
color: Theme.text
text: Qt.formatDateTime(clock.date, root.showYear ? "dd.MM.yyyy HH:mm" : "dd.MM. HH:mm")
text: Qt.formatDateTime(clock.date, "dd.MM. HH:mm")
MouseArea {
anchors.fill: parent
onClicked: root.showYear = !root.showYear
onClicked: {
if (!root.popouts)
return;
root.popouts.toggleByName(root.popoutName);
}
}
HoverHandler {
+1
View File
@@ -7,6 +7,7 @@ Battery Battery.qml
BatteryPopout BatteryPopout.qml
BluetoothIndicator BluetoothIndicator.qml
BluetoothPopout BluetoothPopout.qml
CalendarPopout CalendarPopout.qml
CameraIndicator CameraIndicator.qml
CameraPopout CameraPopout.qml
Clock Clock.qml
+3 -1
View File
@@ -20,7 +20,9 @@ ShellRoot {
},
ModuleBox {
Layout.alignment: Qt.AlignVCenter
Clock {}
Clock {
popouts: bar.popouts
}
},
Workspaces {
Layout.alignment: Qt.AlignVCenter