From d4c835afb5ef8b5257d57c919363108b908b62a5 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 19:10:06 -0700 Subject: [PATCH 1/8] Fix the default center layout assertion The center layout was reordered when the indicators moved next to the clock, but the assertion kept the old order, so this has been failing on quattro since. Co-Authored-By: Claude Opus 5 (1M context) --- test/shell.d/config-test.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/shell.d/config-test.sh b/test/shell.d/config-test.sh index 905df673..96d68004 100755 --- a/test/shell.d/config-test.sh +++ b/test/shell.d/config-test.sh @@ -19,10 +19,10 @@ pass "default shell.json has versioned bar layout" jq -e ' def ids: map(.id // .); .bar.layout.center | ids == [ + "omarchy.indicators", "omarchy.clock", "omarchy.weather", - "omarchy.system-update", - "omarchy.indicators" + "omarchy.system-update" ] ' "$ROOT/config/omarchy/shell.json" >/dev/null pass "default center layout keeps update next to weather" From 6d4fa7acafc2ea396e5585f8850f3712a2ece56f Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 19:10:06 -0700 Subject: [PATCH 2/8] Make the open-panel mark find and fit its module A center-anchored module is mounted twice: the copy that is drawn, and a zero-size placeholder holding its place in the flow beside the anchor. findPanelWidget returned whichever registered first, and that order is not stable across a live bar reconfiguration, so a panel could open anchored to the invisible copy -- mispositioned, with the drawn slot never lighting up and switchPanelFrom unable to find it again. The mark was also always 55% of the slot, which fits an icon but underlines only a fraction of a text label, and runs the full height of a multi-line module on a vertical bar. Modules can now say how long the mark should be along the bar; anything that does not answer keeps the old proportion. Co-Authored-By: Claude Opus 5 (1M context) --- shell/Ui/WidgetButton.qml | 3 +++ shell/plugins/bar/Bar.qml | 26 +++++++++++++++++++---- shell/plugins/bar/BarModel.js | 24 +++++++++++++++++++++ test/shell.d/bar-test.sh | 40 +++++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/shell/Ui/WidgetButton.qml b/shell/Ui/WidgetButton.qml index 1641fcfd..02d843ab 100644 --- a/shell/Ui/WidgetButton.qml +++ b/shell/Ui/WidgetButton.qml @@ -59,6 +59,9 @@ Item { readonly property real scaledHorizontalMargin: Style.spaceReal(horizontalMargin) readonly property real scaledVerticalPadding: Style.spaceReal(verticalPadding) readonly property bool tooltipHovered: visible && interactive && !concealed && mouseArea.containsMouse + // Width of the painted label, for bar chrome that wants to line up with the + // text rather than with the slot it sits in. Zero on icon-only buttons. + readonly property real labelWidth: label.visible ? label.implicitWidth : 0 visible: hasVisualContent || keepSpace opacity: !hasVisualContent || concealed ? 0 : (dimmed ? 0.45 : 1) diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index af40f78b..4d7202cc 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -406,15 +406,19 @@ Item { function findPanelWidget(pluginId) { var id = String(pluginId || "") if (!id) return null + var candidates = [] for (var i = 0; i < moduleSlots.length; i++) { var slot = moduleSlots[i] if (!slot || !slot.activeItem) continue if (slot.moduleName !== id) continue var item = slot.activeItem if (typeof item.open !== "function" || typeof item.close !== "function" || item.opened === undefined) continue - return item + candidates.push(slot) } - return null + // Anchored center modules are mounted twice; only the drawn copy can + // anchor a popup or carry the open-panel mark. See BarModel.pickDrawnSlot. + var chosen = BarModel.pickDrawnSlot(candidates) + return chosen ? chosen.activeItem : null } function summonBarWidget(pluginId) { @@ -1386,6 +1390,16 @@ Item { readonly property bool hovered: moduleHover.hovered readonly property bool dragSource: root.barDragSource === slot readonly property bool panelOpen: root.activePopout === slot.activeItem + // Modules bigger than the mark they want (a text label in a padded slot, + // a multi-line stack on a vertical bar) can say how long the open-panel + // dot should be along the bar, so it tracks what the module paints + // instead of a fraction of whatever slot it happens to fill. + readonly property real panelIndicatorExtent: { + var key = root.vertical ? "openPanelIndicatorHeight" : "openPanelIndicatorWidth" + var hint = activeItem && key in activeItem ? activeItem[key] : undefined + if (hint !== undefined && hint !== null && hint > 0) return Math.round(hint) + return Math.max(Style.space(10), Math.round((root.vertical ? slot.height : slot.width) * 0.55)) + } implicitWidth: activeItem && activeItem.visible ? (root.vertical ? root.barSize : activeItem.implicitWidth) : 0 implicitHeight: activeItem && activeItem.visible ? activeItem.implicitHeight : 0 width: implicitWidth @@ -1455,8 +1469,12 @@ Item { opacity: slot.panelOpen && !slot.dragSource ? 0.9 : 0 color: Color.accent radius: Math.min(width, height) / 2 - width: root.vertical ? Style.space(2) : Math.max(Style.space(10), Math.round(parent.width * 0.55)) - height: root.vertical ? Math.max(Style.space(10), Math.round(parent.height * 0.55)) : Style.space(2) + width: root.vertical ? Style.space(2) : slot.panelIndicatorExtent + height: root.vertical ? slot.panelIndicatorExtent : Style.space(2) + // The mark sits on the module's inner edge — the one facing the + // desktop — so it underlines a top bar, overlines a bottom one, and + // points inward from a left or right one. It reads as pointing at the + // panel that opens on that side. x: root.vertical ? (root.position === "left" ? parent.width - width - inset : inset) : Math.round((parent.width - width) / 2) diff --git a/shell/plugins/bar/BarModel.js b/shell/plugins/bar/BarModel.js index e94d8a4f..89b3e3c2 100644 --- a/shell/plugins/bar/BarModel.js +++ b/shell/plugins/bar/BarModel.js @@ -96,8 +96,32 @@ function customModulePath(entry, home, configDir) { return source } +// A center module is mounted twice once an anchor is set: the copy that is +// actually drawn, and a zero-size placeholder holding its place in the flow +// beside the anchor. Panel routing has to pick the drawn one — it is the only +// one that can anchor a popup, carry the open-panel mark, or be found again +// by switchPanelFrom — and fall back to the placeholder only when nothing is +// on screen. The order the two are registered in is not stable across a live +// bar reconfiguration, so picking the first match is not good enough. +function isDrawnSlot(slot) { + return !!slot && slot.visible === true && slot.width > 0 && slot.height > 0 +} + +function pickDrawnSlot(slots) { + var placeholder = null + var list = slots || [] + for (var i = 0; i < list.length; i++) { + if (!list[i]) continue + if (isDrawnSlot(list[i])) return list[i] + if (!placeholder) placeholder = list[i] + } + return placeholder +} + if (typeof module !== "undefined") { module.exports = { + isDrawnSlot: isDrawnSlot, + pickDrawnSlot: pickDrawnSlot, normalizePosition: normalizePosition, entrySettings: entrySettings, entryId: entryId, diff --git a/test/shell.d/bar-test.sh b/test/shell.d/bar-test.sh index 99cb26cd..7a40ea9b 100644 --- a/test/shell.d/bar-test.sh +++ b/test/shell.d/bar-test.sh @@ -15,7 +15,47 @@ fi pass "bar move outline has no post-release settling state" run_node_test <<'JS' +const fs = require('fs') const bar = requireFromRoot('shell/plugins/bar/BarModel.js') +const barSource = fs.readFileSync(root + '/shell/plugins/bar/Bar.qml', 'utf8') + +// A center module is mounted twice — drawn copy plus zero-size placeholder — +// and the order they register in is not stable across a live reconfiguration, +// so panel routing has to pick the one that is actually on screen. +const drawn = { moduleName: 'omarchy.clock', visible: true, width: 28, height: 81 } +const placeholder = { moduleName: 'omarchy.clock', visible: false, width: 0, height: 0 } +assertEqual(bar.isDrawnSlot(drawn), true, 'bar recognises a drawn slot') +assertEqual(bar.isDrawnSlot(placeholder), false, 'bar recognises a layout placeholder') +assertEqual(bar.pickDrawnSlot([placeholder, drawn]), drawn, 'bar picks the drawn slot when the placeholder registers first') +assertEqual(bar.pickDrawnSlot([drawn, placeholder]), drawn, 'bar picks the drawn slot when it registers first') +assertEqual(bar.pickDrawnSlot([placeholder]), placeholder, 'bar falls back to the placeholder when nothing is drawn') +assertEqual(bar.pickDrawnSlot([]), null, 'bar reports no slot when there are none') +assertEqual(bar.pickDrawnSlot(null), null, 'bar tolerates a missing slot list') +assert( + /BarModel\.pickDrawnSlot\(candidates\)/.test(barSource), + 'bar routes panels through the drawn-slot picker' +) + +// The open-panel mark sits on the module's desktop-facing edge at every +// position: under a top bar, over a bottom one, inward from left and right. +const indicator = barSource.slice(barSource.indexOf('id: openPanelIndicator'), barSource.indexOf('id: openPanelIndicator') + 1600) +assert( + /x: root\.vertical\s*\n\s*\? \(root\.position === "left" \? parent\.width - width - inset : inset\)/.test(indicator), + 'bar pins the open-panel mark to the desktop-facing edge on vertical bars' +) +assert( + /root\.position === "top" \? parent\.height - height - inset : inset/.test(indicator), + 'bar pins the open-panel mark to the desktop-facing edge on horizontal bars' +) +assert( + /key in activeItem/.test(barSource), + 'bar asks whether a widget declares an indicator hint before reading it' +) +assert( + /width: root\.vertical \? Style\.space\(2\) : slot\.panelIndicatorExtent/.test(indicator) && + /height: root\.vertical \? slot\.panelIndicatorExtent : Style\.space\(2\)/.test(indicator), + 'bar sizes the open-panel mark from the same content hint on both axes' +) assertEqual(bar.normalizePosition('left'), 'left', 'bar accepts valid positions') assertEqual(bar.normalizePosition('sideways'), 'top', 'bar defaults invalid positions') From c2610c3788a928cb39aaff1ffc54ca1e8c573b37 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 19:10:06 -0700 Subject: [PATCH 3/8] Give the weather panel its host widget's identity in the bar The bar identifies a panel by the widget mounted in its slot, but the nested panel handed the popout coordinator itself. The open-panel mark never lit under the weather pill, and Tab could not leave the panel. Closing for a popout switch also cleared the shared hover-reveal flag the incoming panel had just set. Co-Authored-By: Claude Opus 5 (1M context) --- shell/plugins/panels/weather/BarWidget.qml | 10 +++++++++ shell/plugins/panels/weather/Panel.qml | 21 ++++++++++++++++-- test/shell.d/weather-test.sh | 25 ++++++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/shell/plugins/panels/weather/BarWidget.qml b/shell/plugins/panels/weather/BarWidget.qml index 667e9905..845c23d3 100644 --- a/shell/plugins/panels/weather/BarWidget.qml +++ b/shell/plugins/panels/weather/BarWidget.qml @@ -12,6 +12,7 @@ BarWidget { if ("bar" in target) target.bar = root.bar if ("settings" in target) target.settings = root.settings if ("anchorItem" in target) target.anchorItem = button + if ("hostWidget" in target) target.hostWidget = root } function refresh() { @@ -36,6 +37,15 @@ BarWidget { if (panelLoader.item && panelLoader.item.close) panelLoader.item.close() } + // Forwarded so this widget can stand in for the panel as the bar's popout + // identity: Bar.requestPopout prefers closeForPopoutSwitch over close, and + // KeyboardPanel reads popoutSwitchClosing back off its owner. + readonly property bool popoutSwitchClosing: panelLoader.item ? panelLoader.item.popoutSwitchClosing === true : false + + function closeForPopoutSwitch() { + if (panelLoader.item) panelLoader.item.closeForPopoutSwitch() + } + visible: panelLoader.item && panelLoader.item.label !== "" implicitWidth: button.implicitWidth implicitHeight: button.implicitHeight diff --git a/shell/plugins/panels/weather/Panel.qml b/shell/plugins/panels/weather/Panel.qml index a4f86c82..35df14f9 100644 --- a/shell/plugins/panels/weather/Panel.qml +++ b/shell/plugins/panels/weather/Panel.qml @@ -15,6 +15,14 @@ Panel { property var anchorItem: null property bool openedFromHotkey: false + // The bar tracks the widget mounted in its slot — BarWidget.qml — not this + // nested panel. Everything the bar identifies a panel by has to be that + // widget: the popout coordinator (and with it the open-panel dot under the + // pill) compares against `slot.activeItem`, and switchPanelFrom looks the + // slot up the same way. + property var hostWidget: null + readonly property var barIdentity: hostWidget || root + function open() { openedFromHotkey = false setCenterHoverRevealSuppressed(false) @@ -32,7 +40,10 @@ Panel { } function close() { - setCenterHoverRevealSuppressed(false) + // Not when another panel is taking over: it has already set the shared + // flag for itself, and clearing it here would leave the incoming panel + // open with the center indicators revealed behind it. + if (!root.popoutSwitchClosing) setCenterHoverRevealSuppressed(false) if (root.editingLocation) root.cancelEditingLocation() root.controller.hide() } @@ -42,6 +53,12 @@ Panel { else root.openFromHotkey() } + function switchPanel(direction) { + if (root.bar && typeof root.bar.switchPanelFrom === "function") + return root.bar.switchPanelFrom(root.barIdentity, direction) + return false + } + function setCenterHoverRevealSuppressed(value) { if (root.bar && "centerHoverRevealSuppressed" in root.bar) root.bar.centerHoverRevealSuppressed = value @@ -440,7 +457,7 @@ Panel { KeyboardPanel { id: panel anchorItem: root.anchorItem - owner: root + owner: root.barIdentity bar: root.bar open: root.opened centerOnBar: true diff --git a/test/shell.d/weather-test.sh b/test/shell.d/weather-test.sh index 90365e2d..954cbf41 100644 --- a/test/shell.d/weather-test.sh +++ b/test/shell.d/weather-test.sh @@ -8,6 +8,7 @@ run_node_test <<'JS' const fs = require('fs') const weather = requireFromRoot('shell/plugins/panels/weather/Model.js') const panelSource = fs.readFileSync(root + '/shell/plugins/panels/weather/Panel.qml', 'utf8') +const widgetSource = fs.readFileSync(root + '/shell/plugins/panels/weather/BarWidget.qml', 'utf8') assertDeepEqual( weather.parseWeatherStatus('{"text":"☀","class":"sunny"}'), @@ -115,6 +116,30 @@ assertEqual(weather.currentIcon({ openMeteoWeatherCode: 0, isDay: 0 }, ''), weat assert(weather.iconForOpenMeteoCode(45, true) !== weather.iconForOpenMeteoCode(45, false), 'weather distinguishes nighttime fog from daytime fog') assertEqual(weather.provisionalCurrentIcon({ weatherCode: 113 }, ''), weather.iconForCode(113, false), 'weather uses wttr to fill an empty initial icon') assertEqual(weather.provisionalCurrentIcon({ weatherCode: 113 }, 'night'), 'night', 'weather refresh preserves a resolved day-night icon') +// The bar identifies a panel by the widget in its slot, so the nested panel +// has to present the host widget rather than itself — otherwise the +// open-panel dot never lights and Tab cannot leave the panel. +assert( + panelSource.includes('owner: root.barIdentity'), + 'weather panel gives the bar its host widget as popout identity' +) +assert( + panelSource.includes('switchPanelFrom(root.barIdentity, direction)'), + 'weather panel switches panels as its host widget' +) +assert( + widgetSource.includes('target.hostWidget = root'), + 'weather widget injects itself as the panel host' +) +assert( + widgetSource.includes('readonly property bool popoutSwitchClosing:') && widgetSource.includes('function closeForPopoutSwitch()'), + 'weather widget forwards the popout-switch handshake' +) +assert( + panelSource.includes('if (!root.popoutSwitchClosing) setCenterHoverRevealSuppressed(false)'), + 'weather leaves the shared hover-reveal flag alone when another panel takes over' +) + assert( panelSource.includes('text: root.label || "—"'), 'weather hero and bar use the same resolved icon' From e2d655d2655a6f303ecbd69ff24e3ee403112c75 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 19:10:06 -0700 Subject: [PATCH 4/8] Add a calendar popup to the clock Clicking the clock reveals a month grid with ISO week numbers, a year progress meter, and month stepping. Right click walks the common label formats and writes the chosen one back to shell.json, so the bar shows what the config stores. The week start toggles from the grid's "W" heading and persists as weekStartDay, defaulting to the locale's own first day. Rich popup widgets live in their own plugin directories, so the clock moves out of bar/widgets/ into panels/clock/. The id is unchanged, so existing layouts and centerAnchor keep working. Co-Authored-By: Claude Opus 5 (1M context) --- default/hypr/bindings/utilities.lua | 1 + shell/plugins/README.md | 3 +- shell/plugins/bar/README.md | 4 +- shell/plugins/bar/widgets/Clock.qml | 92 --- shell/plugins/panels/clock/BarWidget.qml | 175 ++++++ shell/plugins/panels/clock/Model.js | 222 +++++++ shell/plugins/panels/clock/Panel.qml | 548 ++++++++++++++++++ .../clock/manifest.json} | 6 +- test/shell.d/clock-test.sh | 160 +++++ 9 files changed, 1113 insertions(+), 98 deletions(-) delete mode 100644 shell/plugins/bar/widgets/Clock.qml create mode 100644 shell/plugins/panels/clock/BarWidget.qml create mode 100644 shell/plugins/panels/clock/Model.js create mode 100644 shell/plugins/panels/clock/Panel.qml rename shell/plugins/{bar/widgets/Clock.manifest.json => panels/clock/manifest.json} (61%) create mode 100755 test/shell.d/clock-test.sh diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index a3b8a743..eeb067a4 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -77,6 +77,7 @@ o.bind("SUPER + CTRL + ALT + W", "Toggle weather", "omarchy-notification-weather o.bind("SUPER + CTRL + A", "Audio", "omarchy-shell shell toggle omarchy.audio") o.bind("SUPER + CTRL + B", "Bluetooth", "omarchy-shell shell toggle omarchy.bluetooth") o.bind("SUPER + CTRL + D", "Display", "omarchy-shell shell toggle omarchy.monitor") +o.bind("SUPER + CTRL + ALT + D", "Calendar", "omarchy-shell shell toggle omarchy.clock") o.bind("SUPER + CTRL + W", "Network", "omarchy-shell shell toggle omarchy.network") o.bind("SUPER + CTRL + P", "Power", "omarchy-shell shell toggle omarchy.power") o.bind("SUPER + CTRL + T", "Activity", { tui = "btop" }) diff --git a/shell/plugins/README.md b/shell/plugins/README.md index ab89b02e..7b5e4db8 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -22,6 +22,7 @@ User-installed plugins live alongside these conceptually but on disk under | Notifications | `omarchy.notifications` | `service` | `notifications/Service.qml` | | Audio | `omarchy.audio` | `bar-widget` | `panels/audio/Panel.qml` | | Bluetooth | `omarchy.bluetooth` | `bar-widget` | `panels/bluetooth/Panel.qml` | +| Clock | `omarchy.clock` | `bar-widget` | `panels/clock/BarWidget.qml` | | Monitor | `omarchy.monitor` | `bar-widget` | `panels/monitor/Panel.qml` | | Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` | | Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` | @@ -37,7 +38,7 @@ User-installed plugins live alongside these conceptually but on disk under | Polkit agent | `omarchy.polkit` | `service` | `polkit/PolkitAgent.qml` | First-party bar-only widgets also carry manifests next to their QML files, -e.g. `bar/widgets/Clock.manifest.json`. Rich popup widgets live in their +e.g. `bar/widgets/Workspaces.manifest.json`. Rich popup widgets live in their own plugin directories, each with its own `manifest.json`. ## Bar diff --git a/shell/plugins/bar/README.md b/shell/plugins/bar/README.md index a1d93cc6..a4399320 100644 --- a/shell/plugins/bar/README.md +++ b/shell/plugins/bar/README.md @@ -56,7 +56,7 @@ Example `shell.json` (bar subtree only shown): |---|---|---| | `omarchy.menu` | Omarchy menu launcher | left = menu · right = terminal | | `omarchy.workspaces` | Hyprland workspace switcher | left = focus workspace | -| `omarchy.clock` | Date/time label | left = alternate format · right = timezone selector | +| `omarchy.clock` | Date/time label + popup with a month grid, ISO week numbers, and month stepping | left = popup · right = cycle label format · middle = timezone selector | | `omarchy.media` | MPRIS now-playing — scrolling track + artist, cover-art popup | left = play/pause · middle = next · scroll = prev/next · right = popup | | `omarchy.indicators` | Manual state indicators | left = indicator action | | `omarchy.system-update` | Available update indicator | left = update | @@ -166,7 +166,7 @@ Widgets receive `bar` (the shell root), `moduleName` (string), and `settings` (o - `bar.requestPopout(owner)` / `bar.releasePopout(owner)` — one-popup-at-a-time coordinator First-party bar widgets are manifest-backed just like third-party widgets. -Simple widgets carry sibling manifests such as `widgets/Clock.manifest.json`; +Simple widgets carry sibling manifests such as `widgets/Workspaces.manifest.json`; richer popup plugins live in feature directories such as `../panels/audio/`, `../panels/network/`, and `../model-usage/`; and feature plugins such as `omarchy.menu` and `omarchy.media` declare their bar-widget entry points in their own diff --git a/shell/plugins/bar/widgets/Clock.qml b/shell/plugins/bar/widgets/Clock.qml deleted file mode 100644 index 60eb6749..00000000 --- a/shell/plugins/bar/widgets/Clock.qml +++ /dev/null @@ -1,92 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Io -import qs.Commons -import qs.Ui - -BarWidget { - id: root - moduleName: "omarchy.clock" - - property bool alt: false - property date displayDate: clock.date - - readonly property string activeFormat: alt - ? (vertical ? setting("verticalFormatAlt", "dd\nMMM\n'W'ww\n''yy") : setting("formatAlt", "d MMMM 'W'ww yyyy")) - : (vertical ? setting("verticalFormat", "HH\n—\nmm") : setting("format", "dddd HH:mm")) - readonly property string displayText: formatted(displayDate) - readonly property var verticalLines: displayText.split("\n") - - function refresh() { - displayDate = new Date() - } - - function isoWeek(date) { - var d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) - var day = d.getUTCDay() || 7 - d.setUTCDate(d.getUTCDate() + 4 - day) - var yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)) - return Math.ceil(((d - yearStart) / 86400000 + 1) / 7) - } - - function isoWeekLiteral(date) { - var week = isoWeek(date) - return (week < 10 ? "0" : "") + week - } - - function formatted(date) { - return Qt.formatDateTime(date, activeFormat.replace(/ww/g, isoWeekLiteral(date))) - } - - implicitWidth: button.implicitWidth - implicitHeight: button.implicitHeight - - SystemClock { - id: clock - precision: SystemClock.Minutes - onDateChanged: root.displayDate = date - } - - IpcHandler { - target: "omarchy.clock" - function refresh(): void { root.broadcast("refresh") } - } - - WidgetButton { - id: button - anchors.fill: parent - bar: root.bar - text: root.vertical ? "" : root.displayText - labelVisible: !root.vertical - hasVisualContent: root.vertical ? root.verticalLines.length > 0 : text !== "" - fixedHeight: root.vertical ? root.verticalLines.length * Style.bar.iconSlot : -1 - horizontalMargin: 8.75 - verticalPadding: 8.75 - onPressed: function(button) { - if (!root.bar) return - if (button === Qt.RightButton) root.bar.run("omarchy-menu-timezone") - else root.alt = !root.alt - } - - Column { - visible: root.vertical - anchors.fill: parent - - Repeater { - model: root.verticalLines - - OpticalGlyph { - required property string modelData - width: button.width - height: Style.bar.iconSlot - text: modelData - fontFamily: button.fontFamily - fontSize: modelData.length > 3 - ? button.fontSize * 0.9 - : button.fontSize - color: button.foreground - } - } - } - } -} diff --git a/shell/plugins/panels/clock/BarWidget.qml b/shell/plugins/panels/clock/BarWidget.qml new file mode 100644 index 00000000..92f2d425 --- /dev/null +++ b/shell/plugins/panels/clock/BarWidget.qml @@ -0,0 +1,175 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Ui +import "Model.js" as Model + +// Date/time label for the bar, and the host for the calendar popup. +// +// Left click reveals the calendar — asking "what is the date?" is what a +// click on a clock means — right click walks the common label formats, and +// middle click opens the timezone picker. +BarWidget { + id: root + moduleName: "omarchy.clock" + + property date displayDate: clock.date + + readonly property string configuredFormat: vertical + ? setting("verticalFormat", "HH\n—\nmm") + : setting("format", "dddd HH:mm") + readonly property string configuredAltFormat: vertical + ? setting("verticalFormatAlt", "dd\nMMM\n'W'ww\n''yy") + : setting("formatAlt", "d MMMM 'W'ww yyyy") + + readonly property var formatRing: Model.clockFormatRing(configuredFormat, configuredAltFormat, Model.clockFormats(vertical)) + + // What the bar shows is what shell.json stores, so a cycled format is the + // format from then on rather than something that reverts on restart. + readonly property string activeFormat: configuredFormat + readonly property string displayText: formatted(displayDate) + readonly property var verticalLines: displayText.split("\n") + + function refresh() { + displayDate = new Date() + if (panelLoader.item && panelLoader.item.refresh) panelLoader.item.refresh() + } + + function cycleFormat() { + var current = String(configuredFormat) + var next = Model.nextClockFormat(formatRing, current) + if (next === "" || next === current) return + + var entry = { id: root.moduleName } + for (var key in root.settings) if (key !== "id") entry[key] = root.settings[key] + entry[vertical ? "verticalFormat" : "format"] = next + + // Applied locally first so the label changes on the click itself; the + // shell.json write comes back through the bar as the same value. + root.settings = entry + if (root.bar && root.bar.shell && typeof root.bar.shell.updateEntryInline === "function") + root.bar.shell.updateEntryInline(root.moduleName, entry) + } + + function formatted(date) { + return Qt.formatDateTime(date, activeFormat.replace(/ww/g, Model.isoWeekLiteral(date.getFullYear(), date.getMonth(), date.getDate()))) + } + + // ---- Calendar popup. Shape contract for shell.summon/hide/toggle + // routing: Bar.findPanelWidget requires open/close/opened on the + // bar-widget root. + readonly property bool opened: panelLoader.item ? panelLoader.item.opened === true : false + + function open() { + if (panelLoader.item) panelLoader.item.open() + } + + function close() { + if (panelLoader.item) panelLoader.item.close() + } + + function togglePanel() { + if (panelLoader.item) panelLoader.item.toggle() + } + + // The clock fills more slot than it paints a mark for, at both + // orientations: horizontally it is a text label in a padded slot, so the + // dot takes the label width; vertically it is a stack of icon-sized lines, + // so the dot takes one line — the same mark every icon widget gets, rather + // than a rule running the height of the whole stack. + readonly property real openPanelIndicatorWidth: button.labelWidth + readonly property real openPanelIndicatorHeight: Math.max(Style.space(10), Math.round(Style.bar.iconSlot * 0.55)) + + // Forwarded so this widget can stand in for the panel as the bar's popout + // identity: Bar.requestPopout prefers closeForPopoutSwitch over close, and + // KeyboardPanel reads popoutSwitchClosing back off its owner. + readonly property bool popoutSwitchClosing: panelLoader.item ? panelLoader.item.popoutSwitchClosing === true : false + + function closeForPopoutSwitch() { + if (panelLoader.item) panelLoader.item.closeForPopoutSwitch() + } + + function injectPanel() { + var target = panelLoader.item + if (!target) return + if ("bar" in target) target.bar = root.bar + if ("settings" in target) target.settings = root.settings + if ("anchorItem" in target) target.anchorItem = button + if ("hostWidget" in target) target.hostWidget = root + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + onBarChanged: injectPanel() + onSettingsChanged: injectPanel() + + SystemClock { + id: clock + precision: SystemClock.Minutes + onDateChanged: root.displayDate = date + } + + Loader { + id: panelLoader + active: true + source: Qt.resolvedUrl("Panel.qml") + visible: false + onLoaded: { + root.injectPanel() + Qt.callLater(root.injectPanel) + } + } + + IpcHandler { + target: "omarchy.clock" + + function refresh(): void { root.broadcast("refresh") } + function cycleFormat(): void { root.cycleFormat() } + function open(): void { root.open() } + function close(): void { root.close() } + function show(): void { root.open() } + function hide(): void { root.close() } + function toggle(): void { root.togglePanel() } + } + + WidgetButton { + id: button + anchors.fill: parent + bar: root.bar + text: root.vertical ? "" : root.displayText + labelVisible: !root.vertical + hasVisualContent: root.vertical ? root.verticalLines.length > 0 : text !== "" + fixedHeight: root.vertical ? root.verticalLines.length * Style.bar.iconSlot : -1 + horizontalMargin: 8.75 + verticalPadding: 8.75 + + onPressed: function(b) { + if (b === Qt.RightButton) root.cycleFormat() + else if (b === Qt.MiddleButton) { if (root.bar) root.bar.run("omarchy-menu-timezone") } + else root.togglePanel() + } + + Column { + visible: root.vertical + anchors.fill: parent + + Repeater { + model: root.verticalLines + + OpticalGlyph { + required property string modelData + width: button.width + height: Style.bar.iconSlot + text: modelData + fontFamily: button.fontFamily + fontSize: modelData.length > 3 + ? button.fontSize * 0.9 + : button.fontSize + color: button.foreground + } + } + } + } +} diff --git a/shell/plugins/panels/clock/Model.js b/shell/plugins/panels/clock/Model.js new file mode 100644 index 00000000..767a9276 --- /dev/null +++ b/shell/plugins/panels/clock/Model.js @@ -0,0 +1,222 @@ +// Pure date and format math for the clock widget and its calendar panel. +// Everything here is locale- and Qt-free so it can be unit tested under node +// (test/shell.d/clock-test.sh); the QML owns month/weekday naming through +// Qt.locale(). + +var MS_PER_DAY = 86400000 + +// Weekday indices match both JS Date.getDay() and QML's Locale.Sunday… +// Locale.Saturday, so a locale's firstDayOfWeek can be passed straight in. +var WEEKDAY_NAMES = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"] + +// ---- Bar label formats. Right-clicking the clock walks these in order and +// writes the result back to shell.json, so the label the bar shows and +// the format the config stores are always the same thing. +var CLOCK_FORMATS = [ + "dddd HH:mm", + "HH:mm", + "ddd d MMM HH:mm", + "d MMMM 'W'ww yyyy", + "yyyy-MM-dd HH:mm" +] + +// Vertical bars have room for a few stacked lines and nothing else, so the +// ring stays short. +var VERTICAL_CLOCK_FORMATS = [ + "HH\n—\nmm", + "dd\nMMM\n'W'ww\n''yy", + "HH\nmm" +] + +function clockFormats(vertical) { + return vertical ? VERTICAL_CLOCK_FORMATS.slice() : CLOCK_FORMATS.slice() +} + +// The presets in a fixed order, plus the configured alternate and current +// format when they are something else. The order must not depend on which +// entry is current: cycling writes the result back to shell.json, and a ring +// that reshuffled itself around the current value would bounce between two +// entries instead of walking. +function clockFormatRing(configured, configuredAlt, presets) { + var ring = [] + var candidates = (presets || []).concat([configuredAlt, configured]) + for (var i = 0; i < candidates.length; i++) { + var format = String(candidates[i] === undefined || candidates[i] === null ? "" : candidates[i]) + if (format === "" || ring.indexOf(format) !== -1) continue + ring.push(format) + } + return ring.length > 0 ? ring : ["HH:mm"] +} + +// Next entry after `current`. An unknown current format (a hand-written one +// that is not in the ring) starts the walk at the top. +function nextClockFormat(ring, current) { + if (!ring || ring.length === 0) return "" + var index = ring.indexOf(String(current === undefined || current === null ? "" : current)) + return ring[(index + 1) % ring.length] +} + +// Two-digit ISO week, substituted into a format's 'ww' token before Qt +// formats it -- Qt has no ISO week specifier of its own. +function isoWeekLiteral(year, month, day) { + return pad2(isoWeek(year, month, day)) +} + +function pad2(value) { + var n = Number(value) + return (n < 10 ? "0" : "") + n +} + +// Stable "yyyy-MM-dd" identity for a day, so a grid cell can be compared +// against today without dragging Date objects through bindings. +function dateKey(year, month, day) { + return year + "-" + pad2(Number(month) + 1) + "-" + pad2(day) +} + +function keyForDate(date) { + return dateKey(date.getFullYear(), date.getMonth(), date.getDate()) +} + +function coerceWeekStart(value) { + if (value === undefined || value === null) return null + if (typeof value === "number") + return isFinite(value) ? ((Math.round(value) % 7) + 7) % 7 : null + + var text = String(value).replace(/^\s+|\s+$/g, "").toLowerCase() + if (text === "") return null + + for (var i = 0; i < WEEKDAY_NAMES.length; i++) + if (WEEKDAY_NAMES[i] === text || WEEKDAY_NAMES[i].substr(0, 3) === text) return i + + var parsed = parseInt(text, 10) + return isFinite(parsed) ? ((parsed % 7) + 7) % 7 : null +} + +// Configured week start, falling back to the locale's own first day when +// the setting is missing or nonsense. +function normalizedWeekStart(value, fallback) { + var configured = coerceWeekStart(value) + if (configured !== null) return configured + var fallbackStart = coerceWeekStart(fallback) + return fallbackStart === null ? 1 : fallbackStart +} + +function weekStartSettingName(index) { + return WEEKDAY_NAMES[normalizedWeekStart(index, 1)] +} + +// The toggle flips between the two conventions people actually switch +// between. A calendar configured to any other start (Saturday, say) is +// shown as-is and lands on Monday the first time it is toggled. +function toggledWeekStart(index) { + return normalizedWeekStart(index, 1) === 1 ? 0 : 1 +} + +function weekdayOrder(weekStart) { + var start = normalizedWeekStart(weekStart, 1) + var out = [] + for (var i = 0; i < 7; i++) out.push((start + i) % 7) + return out +} + +// ISO-8601 week number: the week owning the Thursday of that date's +// Monday-based week. Mirrors the clock widget's 'ww' format token. +function isoWeek(year, month, day) { + var date = new Date(Date.UTC(year, month, day)) + var weekday = date.getUTCDay() || 7 + date.setUTCDate(date.getUTCDate() + 4 - weekday) + var yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)) + return Math.ceil(((date.getTime() - yearStart.getTime()) / MS_PER_DAY + 1) / 7) +} + +function dayOfYear(year, month, day) { + return Math.round((Date.UTC(year, month, day) - Date.UTC(year, 0, 1)) / MS_PER_DAY) + 1 +} + +function daysInYear(year) { + return dayOfYear(year, 11, 31) +} + +// Share of the year already behind you: whole days completed over days in +// the year, so January 1 reads 0% and December 31 reads 100%. +function yearProgress(year, month, day) { + var total = daysInYear(year) + if (total <= 0) return 0 + return Math.max(0, Math.min(1, (dayOfYear(year, month, day) - 1) / total)) +} + +function yearProgressPercent(year, month, day) { + return Math.round(yearProgress(year, month, day) * 100) +} + +// Always six rows of seven days. A fixed grid keeps the popup exactly the +// same height in every month, so stepping through the year never makes the +// panel jump under the pointer. +function monthGrid(year, month, weekStart, todayKey) { + var start = normalizedWeekStart(weekStart, 1) + var leading = (new Date(year, month, 1).getDay() - start + 7) % 7 + var cursor = new Date(year, month, 1 - leading) + var today = String(todayKey || "") + var weeks = [] + + for (var w = 0; w < 6; w++) { + var days = [] + var thursday = null + for (var d = 0; d < 7; d++) { + var cellYear = cursor.getFullYear() + var cellMonth = cursor.getMonth() + var cellDay = cursor.getDate() + var weekday = cursor.getDay() + var key = dateKey(cellYear, cellMonth, cellDay) + if (weekday === 4) thursday = { year: cellYear, month: cellMonth, day: cellDay } + days.push({ + key: key, + year: cellYear, + month: cellMonth, + day: cellDay, + weekday: weekday, + inMonth: cellMonth === month && cellYear === year, + weekend: weekday === 0 || weekday === 6, + today: key === today + }) + cursor.setDate(cursor.getDate() + 1) + } + // Number every row by the ISO week owning its Thursday. That is the + // definition itself for Monday-start weeks, and the only answer that + // stays stable for the other starts, where a row straddles two ISO + // weeks but shares all of Monday through Thursday with one of them. + var anchor = thursday || days[0] + weeks.push({ + week: isoWeek(anchor.year, anchor.month, anchor.day), + days: days + }) + } + return weeks +} + +function stepMonth(year, month, delta) { + var target = new Date(year, Number(month) + Number(delta), 1) + return { year: target.getFullYear(), month: target.getMonth() } +} + +if (typeof module !== "undefined") { + module.exports = { + dateKey: dateKey, + keyForDate: keyForDate, + normalizedWeekStart: normalizedWeekStart, + weekStartSettingName: weekStartSettingName, + toggledWeekStart: toggledWeekStart, + weekdayOrder: weekdayOrder, + isoWeek: isoWeek, + dayOfYear: dayOfYear, + daysInYear: daysInYear, + yearProgress: yearProgress, + yearProgressPercent: yearProgressPercent, + monthGrid: monthGrid, + stepMonth: stepMonth, + clockFormats: clockFormats, + clockFormatRing: clockFormatRing, + nextClockFormat: nextClockFormat, + isoWeekLiteral: isoWeekLiteral + } +} diff --git a/shell/plugins/panels/clock/Panel.qml b/shell/plugins/panels/clock/Panel.qml new file mode 100644 index 00000000..f12950d6 --- /dev/null +++ b/shell/plugins/panels/clock/Panel.qml @@ -0,0 +1,548 @@ +import QtQuick +import Quickshell +import qs.Commons +import qs.Ui +import "Model.js" as Model + +// The clock's calendar popup: a month grid with ISO week numbers, built to +// sit beside the weather panel — same hero-over-detail composition, same +// spacing scale, same small-caps labels. +// +// The grid is a read-out rather than a picker: today is the only marked +// day, and the only thing that moves is which month is on screen — +// chevrons, the scroll wheel, and the arrow keys all step it. +// +// BarWidget.qml owns the bar label and hands this panel the button to +// anchor against. +Panel { + id: root + moduleName: "omarchy.clock" + ipcTarget: "omarchy.clock" + manageIpc: false + + property var anchorItem: null + + // The bar tracks the widget mounted in its slot — BarWidget.qml — not this + // nested panel. Everything the bar identifies a panel by has to be that + // widget: the popout coordinator (and with it the open-panel dot under the + // pill) compares against `slot.activeItem`, and switchPanelFrom looks the + // slot up the same way. + property var hostWidget: null + readonly property var barIdentity: hostWidget || root + + // ---- Today. SystemClock keeps this honest across midnight so the + // highlight rolls over without the panel being reopened. + property date today: new Date() + readonly property string todayKey: Model.keyForDate(today) + + // The month on screen. Stepping moves this and nothing else: the grid is + // a read-out, not a picker, so there is no per-day cursor to keep in sync. + property int viewYear: today.getFullYear() + property int viewMonth: today.getMonth() + + readonly property date viewDate: new Date(viewYear, viewMonth, 1) + readonly property bool viewingCurrentMonth: viewYear === today.getFullYear() && viewMonth === today.getMonth() + + // Pinned to today, not to the month being browsed — stepping through the + // calendar does not change how much of the year is gone. + readonly property real yearDone: Model.yearProgress(today.getFullYear(), today.getMonth(), today.getDate()) + readonly property int yearDonePercent: Model.yearProgressPercent(today.getFullYear(), today.getMonth(), today.getDate()) + + // Unset falls through to the locale's own first day, so a fresh install + // starts out matching the rest of the desktop rather than a hardcoded + // convention. Clicking the grid's "W" heading writes the choice back to + // shell.json. + readonly property int weekStart: Model.normalizedWeekStart(setting("weekStartDay", null), Qt.locale().firstDayOfWeek) + readonly property string nextWeekStartLabel: Qt.locale().dayName(Model.toggledWeekStart(weekStart), Locale.LongFormat) + readonly property var weekdays: Model.weekdayOrder(weekStart) + readonly property var weeks: Model.monthGrid(viewYear, viewMonth, weekStart, todayKey) + + + // Guarded so the widget renders before the bar is injected (the bar-widget + // contract instantiates it bare). + readonly property color contentForeground: bar ? bar.foreground : Color.foreground + readonly property string contentFontFamily: bar ? bar.fontFamily : Style.font.family + + readonly property int cellWidth: Style.space(52) + readonly property int cellHeight: Style.space(34) + readonly property int cellSpacing: Style.space(2) + readonly property int weekColumnWidth: Style.space(32) + readonly property int gutterWidth: Style.space(14) + + function open() { + setCenterHoverRevealSuppressed(true) + refresh() + root.controller.show() + } + + function close() { + // Not when another panel is taking over: it has already set the shared + // flag for itself, and clearing it here would leave the incoming panel + // open with the center indicators revealed behind it. + if (!root.popoutSwitchClosing) setCenterHoverRevealSuppressed(false) + root.controller.hide() + } + + function toggle() { + if (root.opened) root.close() + else root.open() + } + + function switchPanel(direction) { + if (root.bar && typeof root.bar.switchPanelFrom === "function") + return root.bar.switchPanelFrom(root.barIdentity, direction) + return false + } + + // Summoning by hotkey moves no pointer, so a hover the bar was still + // holding must not keep the center indicators revealed behind the panel. + function setCenterHoverRevealSuppressed(value) { + if (root.bar && "centerHoverRevealSuppressed" in root.bar) + root.bar.centerHoverRevealSuppressed = value + } + + function refresh() { + root.today = new Date() + root.goToToday() + } + + function goToToday() { + root.viewYear = today.getFullYear() + root.viewMonth = today.getMonth() + } + + function moveMonth(delta) { + var next = Model.stepMonth(viewYear, viewMonth, delta) + root.viewYear = next.year + root.viewMonth = next.month + } + + function moveYear(delta) { + moveMonth(delta * 12) + } + + function setWeekStart(day) { + var next = Model.normalizedWeekStart(day, root.weekStart) + if (next === root.weekStart) return + + var entry = { id: root.moduleName } + for (var key in root.settings) if (key !== "id") entry[key] = root.settings[key] + entry.weekStartDay = Model.weekStartSettingName(next) + + // Applied locally first so the grid reflows on the click itself; the + // shell.json write comes back through the bar as the same value. With + // no writable entry (the widget is not in the layout) it stays a + // session-only preference rather than doing nothing. + root.settings = entry + // The host widget builds its own entry when the label format is cycled. + // Until shell.json round-trips it would be working from a copy without + // this key, and would write the week start straight back out. + if (root.hostWidget && "settings" in root.hostWidget) root.hostWidget.settings = entry + if (root.bar && root.bar.shell && typeof root.bar.shell.updateEntryInline === "function") + root.bar.shell.updateEntryInline(root.moduleName, entry) + } + + function toggleWeekStart() { + setWeekStart(Model.toggledWeekStart(root.weekStart)) + } + + // Locale short day names, trimmed of the trailing period some locales + // carry ("man." -> "MAN") so the header row stays a clean band of caps. + function weekdayLabel(weekday) { + return String(Qt.locale().dayName(weekday, Locale.ShortFormat)).replace(/\.$/, "").toUpperCase() + } + + SystemClock { + id: clock + precision: SystemClock.Minutes + onDateChanged: { + if (Model.keyForDate(clock.date) === String(root.todayKey)) return + var followToday = root.viewingCurrentMonth + root.today = clock.date + if (followToday) root.goToToday() + } + } + + KeyboardPanel { + id: panel + anchorItem: root.anchorItem + owner: root.barIdentity + bar: root.bar + open: root.opened + centerOnBar: true + focusTarget: keyCatcher + contentWidth: panel.fittedContentWidth(Style.space(560)) + contentHeight: panel.fittedContentHeight(calendarColumn.implicitHeight) + + PanelKeyCatcher { + id: keyCatcher + anchors.fill: parent + onMoveRequested: function(dx, dy) { + if (dx !== 0) root.moveMonth(dx) + if (dy !== 0) root.moveYear(dy) + } + onActivateRequested: root.goToToday() + onCloseRequested: root.close() + onTabRequested: function(direction) { root.switchPanel(direction) } + onTextKey: function(t) { + if (t === "[") root.moveMonth(-1) + else if (t === "]") root.moveMonth(1) + else if (t === "{") root.moveYear(-1) + else if (t === "}") root.moveYear(1) + else if (t === "t" || t === "T") root.goToToday() + else if (t === "w" || t === "W") root.toggleWeekStart() + } + + Flickable { + id: calendarScroll + anchors.fill: parent + contentWidth: calendarColumn.width + contentHeight: calendarColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + interactive: contentHeight > height || contentWidth > width + + Column { + id: calendarColumn + // Never narrower than the grid. The popup width is capped to what + // the screen allows, and a fixed seven-column grid would otherwise + // lose its last days off the edge instead of scrolling. + width: Math.max(calendarScroll.width, gridColumn.width) + spacing: Style.space(8) + + // ---- Hero: today, centered. Once the view has stepped back + // it is also the way home — clicking the date you are + // looking for beats hunting for a reset button. + Item { + width: parent.width + height: heroRow.height + + Row { + id: heroRow + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.space(22) + + Text { + // Baseline-aligned, not center-aligned: "July 26" carries a + // descender, so centering the two boxes leaves the icon + // sitting visibly low against the digits. + anchors.baseline: heroDate.baseline + text: "󰃭" + color: heroMouse.containsMouse + ? Style.hoverStateColor(root.contentForeground, Color.accent) + : root.contentForeground + font.family: root.contentFontFamily + // Decorative, and deliberately outside the Style.font.* + // scale. Sized so the glyph reads at the cap height of the + // date beside it rather than towering over it. + font.pixelSize: 48 + } + + Text { + id: heroDate + anchors.verticalCenter: parent.verticalCenter + text: Qt.formatDate(root.today, "MMMM d") + color: heroMouse.containsMouse + ? Style.hoverStateColor(root.contentForeground, Color.accent) + : root.contentForeground + font.family: root.contentFontFamily + font.pixelSize: 52 + font.bold: true + } + } + + MouseArea { + id: heroMouse + x: heroRow.x + y: heroRow.y + width: heroRow.width + height: heroRow.height + enabled: !root.viewingCurrentMonth + hoverEnabled: enabled + cursorShape: Qt.PointingHandCursor + onClicked: root.goToToday() + + PanelToolTip { + visible: heroMouse.containsMouse + text: "Back to today" + fontFamily: root.contentFontFamily + } + } + } + + // ---- Year progress, doubling as the rule under the hero: + // a plain hairline said nothing, and whole days done + // over days in the year says the same thing louder. + Item { + width: parent.width + height: yearBlock.y + yearBlock.height + + Item { + id: yearBlock + y: Style.space(6) + anchors.horizontalCenter: parent.horizontalCenter + width: gridColumn.width + height: Math.max(yearLabel.implicitHeight, Style.space(10)) + + Text { + id: yearLabel + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: root.today.getFullYear() + color: Qt.darker(root.contentForeground, 1.5) + font.family: root.contentFontFamily + font.pixelSize: Style.font.bodySmall + font.letterSpacing: 1 + } + + Text { + id: yearPercent + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.yearDonePercent + "%" + color: root.contentForeground + font.family: root.contentFontFamily + font.pixelSize: Style.font.bodySmall + } + + Rectangle { + id: yearTrack + anchors.left: yearLabel.right + anchors.right: yearPercent.left + anchors.leftMargin: Style.space(12) + anchors.rightMargin: Style.space(12) + anchors.verticalCenter: parent.verticalCenter + height: Style.space(6) + radius: Style.cornerRadius > 0 ? height / 2 : 0 + color: Qt.rgba(root.contentForeground.r, root.contentForeground.g, root.contentForeground.b, 0.12) + + Rectangle { + width: Math.round(parent.width * root.yearDone) + height: parent.height + radius: parent.radius + color: Style.selectedStateColor(root.contentForeground, Color.accent) + + Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } + } + } + } + } + + // ---- Month grid: week numbers down a gutter on the left, then + // the seven day columns. Always six rows, so the popup is + // exactly as tall in February as it is in August. + Item { + width: parent.width + height: gridColumn.y + gridColumn.height + + WheelHandler { + onWheel: function(event) { + // Horizontal wheels and touchpad side-scrolls report y === 0; + // without this they would every one read as "next month". + if (event.angleDelta.y === 0) return + root.moveMonth(event.angleDelta.y > 0 ? -1 : 1) + } + } + + Column { + id: gridColumn + // The meter above is a solid rule; the grid needs room to + // read as its own block rather than hanging off it. + y: Style.space(18) + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.space(3) + + Row { + id: headerRow + spacing: root.cellSpacing + + // The week-number heading doubles as the week-start toggle. + // It is the one control in the panel whose meaning is not + // self-evident, so it carries a tooltip naming the day the + // click will switch to. + Rectangle { + width: root.weekColumnWidth + height: Style.space(16) + radius: Style.cornerRadius + color: weekStartMouse.containsMouse + ? Style.hoverFillFor(root.contentForeground, Color.accent) + : "transparent" + + Text { + anchors.centerIn: parent + text: "W" + color: weekStartMouse.containsMouse + ? Style.hoverStateColor(root.contentForeground, Color.accent) + : Qt.darker(root.contentForeground, 1.9) + font.family: root.contentFontFamily + font.pixelSize: Style.font.caption + font.letterSpacing: 1 + font.bold: true + } + + MouseArea { + id: weekStartMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.toggleWeekStart() + } + + PanelToolTip { + visible: weekStartMouse.containsMouse + text: "Start weeks on " + root.nextWeekStartLabel + fontFamily: root.contentFontFamily + } + } + + Item { + width: root.gutterWidth + height: Style.space(16) + } + + Repeater { + model: root.weekdays + + Text { + required property var modelData + width: root.cellWidth + height: Style.space(16) + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: root.weekdayLabel(modelData) + color: Qt.darker(root.contentForeground, 1.5) + font.family: root.contentFontFamily + font.pixelSize: Style.font.caption + font.letterSpacing: 1 + font.bold: true + } + } + } + + Repeater { + model: root.weeks + + Row { + required property var modelData + spacing: root.cellSpacing + + Text { + width: root.weekColumnWidth + height: root.cellHeight + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: modelData.week + color: Qt.darker(root.contentForeground, 1.9) + font.family: root.contentFontFamily + font.pixelSize: Style.font.caption + } + + Item { + width: root.gutterWidth + height: root.cellHeight + } + + Repeater { + model: modelData.days + + Rectangle { + required property var modelData + + width: root.cellWidth + height: root.cellHeight + radius: Style.cornerRadius + // Today is outlined, not filled: a lit-up block shouts + // over a grid this quiet. + color: "transparent" + border.width: modelData.today ? Style.spacing.hairline : 0 + border.color: Style.normalBorderFor(root.contentForeground, Color.accent) + + Text { + anchors.centerIn: parent + text: modelData.day + color: modelData.inMonth + ? (modelData.weekend ? Qt.darker(root.contentForeground, 1.45) : root.contentForeground) + : Qt.darker(root.contentForeground, 2.2) + font.family: root.contentFontFamily + font.pixelSize: Style.font.body + font.bold: modelData.today + } + } + } + } + } + } + + // Hairline down the week-number gutter, drawn only beside the + // day rows so it does not cut through the header band. + Rectangle { + x: gridColumn.x + root.weekColumnWidth + root.cellSpacing + Math.round((root.gutterWidth - width) / 2) + y: gridColumn.y + headerRow.height + gridColumn.spacing + width: Style.spacing.hairline + height: gridColumn.height - headerRow.height - gridColumn.spacing + color: root.contentForeground + opacity: 0.1 + } + } + + // ---- Month stepping, spanning the grid it drives. The chevrons + // sit on the grid's outer bounds, the same edges the year + // rail above uses, so the row reads as the panel's other + // full-width rail instead of a cluster floating in space. + // The label is centered and fixed-width, so it holds still + // from "MAY" to "SEPTEMBER". + Item { + width: parent.width + height: monthNav.height + + Item { + id: monthNav + anchors.horizontalCenter: parent.horizontalCenter + width: gridColumn.width + height: monthLabel.implicitHeight + Style.space(10) + + Text { + id: monthLabel + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + // Fixed width so the chevrons hold still between a + // "MAY 2026" and a "SEPTEMBER 2026". + width: Style.space(130) + horizontalAlignment: Text.AlignHCenter + text: Qt.formatDate(root.viewDate, "MMMM yyyy").toUpperCase() + color: Qt.darker(root.contentForeground, 1.4) + font.family: root.contentFontFamily + font.pixelSize: Style.font.body + font.letterSpacing: 1 + } + + PanelActionButton { + // Pulled out by the button's own padding so the glyph, not + // its hit box, lines up with the "2026" on the year rail. + anchors.left: parent.left + anchors.leftMargin: -Style.space(8) + anchors.verticalCenter: parent.verticalCenter + iconText: "󰅁" + tooltipText: "Previous month" + foreground: root.contentForeground + fontFamily: root.contentFontFamily + onClicked: root.moveMonth(-1) + } + + PanelActionButton { + anchors.right: parent.right + anchors.rightMargin: -Style.space(8) + anchors.verticalCenter: parent.verticalCenter + iconText: "󰅂" + tooltipText: "Next month" + foreground: root.contentForeground + fontFamily: root.contentFontFamily + onClicked: root.moveMonth(1) + } + } + } + } + } + } + } +} diff --git a/shell/plugins/bar/widgets/Clock.manifest.json b/shell/plugins/panels/clock/manifest.json similarity index 61% rename from shell/plugins/bar/widgets/Clock.manifest.json rename to shell/plugins/panels/clock/manifest.json index 119dbb96..16401216 100644 --- a/shell/plugins/bar/widgets/Clock.manifest.json +++ b/shell/plugins/panels/clock/manifest.json @@ -4,16 +4,16 @@ "name": "Clock", "version": "1.0.0", "author": "Omarchy", - "description": "Day/time label; click to toggle alternate format", + "description": "Date/time label with a calendar popup", "kinds": [ "bar-widget" ], "entryPoints": { - "barWidget": "Clock.qml" + "barWidget": "BarWidget.qml" }, "barWidget": { "displayName": "Clock", - "description": "Day/time label; click to toggle alternate format", + "description": "Date/time label with a calendar popup", "category": "Time", "allowMultiple": false } diff --git a/test/shell.d/clock-test.sh b/test/shell.d/clock-test.sh new file mode 100755 index 00000000..eef31506 --- /dev/null +++ b/test/shell.d/clock-test.sh @@ -0,0 +1,160 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const fs = require('fs') +const calendar = requireFromRoot('shell/plugins/panels/clock/Model.js') +const panelSource = fs.readFileSync(root + '/shell/plugins/panels/clock/Panel.qml', 'utf8') +const widgetSource = fs.readFileSync(root + '/shell/plugins/panels/clock/BarWidget.qml', 'utf8') + +// ---- week start resolution +assertEqual(calendar.normalizedWeekStart('monday', 0), 1, 'calendar reads a named week start') +assertEqual(calendar.normalizedWeekStart('SUN', 1), 0, 'calendar reads an abbreviated week start') +assertEqual(calendar.normalizedWeekStart(6, 1), 6, 'calendar reads a numeric week start') +assertEqual(calendar.normalizedWeekStart(null, 0), 0, 'calendar falls back to the locale first day') +assertEqual(calendar.normalizedWeekStart('garbage', 0), 0, 'calendar falls back when the setting is unparseable') +assertEqual(calendar.normalizedWeekStart(undefined, undefined), 1, 'calendar defaults to Monday without a locale') + +assertEqual(calendar.weekStartSettingName(0), 'sunday', 'calendar persists Sunday by name') +assertEqual(calendar.weekStartSettingName(1), 'monday', 'calendar persists Monday by name') + +assertEqual(calendar.toggledWeekStart(1), 0, 'calendar toggles Monday to Sunday') +assertEqual(calendar.toggledWeekStart(0), 1, 'calendar toggles Sunday to Monday') +assertEqual(calendar.toggledWeekStart(6), 1, 'calendar toggles an exotic week start to Monday') + +assertDeepEqual(calendar.weekdayOrder(1), [1, 2, 3, 4, 5, 6, 0], 'calendar orders weekdays from Monday') +assertDeepEqual(calendar.weekdayOrder(0), [0, 1, 2, 3, 4, 5, 6], 'calendar orders weekdays from Sunday') + +// ---- ISO week numbers, matching the clock widget's 'ww' token +assertEqual(calendar.isoWeek(2026, 0, 1), 1, 'calendar numbers a Thursday January 1 as week 1') +assertEqual(calendar.isoWeek(2021, 0, 1), 53, 'calendar numbers early January into the previous ISO year') +assertEqual(calendar.isoWeek(2026, 11, 31), 53, 'calendar numbers the last week of a long year') +assertEqual(calendar.isoWeek(2026, 6, 26), 30, 'calendar numbers a midsummer Sunday') + +// ---- day-of-year arithmetic behind the hero stats +assertEqual(calendar.dayOfYear(2026, 6, 26), 207, 'calendar counts the day of the year') +assertEqual(calendar.yearProgressPercent(2026, 0, 1), 0, 'calendar starts the year at zero percent done') +assertEqual(calendar.yearProgressPercent(2026, 6, 26), 56, 'calendar reports the share of the year behind you') +assertEqual(calendar.yearProgressPercent(2026, 11, 31), 100, 'calendar finishes the year at a hundred percent') +assertEqual(calendar.yearProgressPercent(2024, 11, 31), 100, 'calendar finishes a leap year too') +assertEqual(calendar.daysInYear(2024), 366, 'calendar knows leap years are longer') +assertEqual(calendar.daysInYear(2026), 365, 'calendar knows common years') + +// ---- month grid +const july = calendar.monthGrid(2026, 6, 1, '2026-07-26') +assertEqual(july.length, 6, 'calendar always draws six week rows') +assert(july.every(week => week.days.length === 7), 'calendar always draws seven day columns') +assertDeepEqual(july.map(week => week.week), [27, 28, 29, 30, 31, 32], 'calendar numbers every row by ISO week') +assertDeepEqual(july[0].days.map(day => day.day), [29, 30, 1, 2, 3, 4, 5], 'calendar pads the first row with the previous month') +assertDeepEqual(july[0].days.map(day => day.inMonth), [false, false, true, true, true, true, true], 'calendar marks padding days as outside the month') +assertDeepEqual(july[0].days.map(day => day.weekend), [false, false, false, false, false, true, true], 'calendar marks Saturday and Sunday as the weekend') + +assertDeepEqual( + july.flatMap(week => week.days).filter(day => day.today).map(day => day.key), + ['2026-07-26'], + 'calendar marks exactly one cell as today' +) +assert(july.flatMap(week => week.days).every(day => day.cursor === undefined), 'calendar grid carries no selection state') + +// Both conventions land on the same ISO week numbers, because every row is +// numbered by the ISO week owning its Thursday. +const julySunday = calendar.monthGrid(2026, 6, 0, '') +assertDeepEqual(julySunday[0].days.map(day => day.day), [28, 29, 30, 1, 2, 3, 4], 'calendar shifts the grid for a Sunday week start') +assertDeepEqual(julySunday.map(week => week.week), [27, 28, 29, 30, 31, 32], 'calendar keeps ISO week numbers across week starts') + +const januarySunday = calendar.monthGrid(2021, 0, 0, '') +assertEqual(januarySunday[0].week, 53, 'calendar carries the previous ISO year into a straddling first row') + +// ---- stepping +assertDeepEqual(calendar.stepMonth(2026, 0, 1), { year: 2026, month: 1 }, 'calendar steps to the next month') +assertDeepEqual(calendar.stepMonth(2026, 0, -1), { year: 2025, month: 11 }, 'calendar steps back across the new year') +assertDeepEqual(calendar.stepMonth(2026, 11, 1), { year: 2027, month: 0 }, 'calendar steps forward across the new year') +assertDeepEqual(calendar.stepMonth(2026, 6, 12), { year: 2027, month: 6 }, 'calendar steps a whole year at a time') + +assertEqual(calendar.dateKey(2026, 0, 5), '2026-01-05', 'calendar zero-pads date keys') +assertEqual(calendar.keyForDate(new Date(2026, 6, 26)), '2026-07-26', 'calendar keys a Date the same way') + +// ---- bar label format ring +// The order must not depend on which entry is current: cycling writes the +// result back to shell.json, so a self-reordering ring would ping-pong. +const ring = calendar.clockFormatRing('dddd HH:mm', "d MMMM 'W'ww yyyy", calendar.clockFormats(false)) +assertDeepEqual(ring, calendar.clockFormats(false), 'clock rings the stock presets in their own order') +assertEqual(new Set(ring).size, ring.length, 'clock never offers the same format twice') + +assertDeepEqual( + calendar.clockFormatRing("d MMMM 'W'ww yyyy", 'dddd HH:mm', calendar.clockFormats(false)), + ring, + 'clock keeps the ring order steady as the current format moves through it' +) +assertDeepEqual( + calendar.clockFormatRing('HH:mm:ss', '', ['HH:mm', 'dddd HH:mm']), + ['HH:mm', 'dddd HH:mm', 'HH:mm:ss'], + 'clock appends a hand-written format to the end of the ring' +) +assertDeepEqual(calendar.clockFormatRing('', '', []), ['HH:mm'], 'clock keeps a usable format when nothing is configured') + +assertEqual(calendar.nextClockFormat(ring, ring[0]), ring[1], 'clock steps to the next format') +assertEqual(calendar.nextClockFormat(ring, ring[ring.length - 1]), ring[0], 'clock wraps the format ring') +assertEqual(calendar.nextClockFormat(ring, 'HH:mm:ss'), ring[0], 'clock starts at the top from a format outside the ring') +assertEqual(calendar.clockFormats(true)[0], 'HH\n\u2014\nmm', 'clock keeps stacked formats for vertical bars') +assertEqual(calendar.isoWeekLiteral(2026, 0, 5), '02', 'clock zero-pads the ISO week token') + +// ---- widget wiring +assert(/moduleName: "omarchy\.clock"/.test(panelSource), 'calendar panel declares its module name') +assert(/ipcTarget: "omarchy\.clock"/.test(panelSource), 'calendar panel registers its IPC target') +assert(/manageIpc: false/.test(panelSource), 'calendar panel leaves the IPC target to the bar widget') +assert(/anchorItem: root\.anchorItem/.test(panelSource), 'calendar panel anchors to the host widget button') +assert(/function toggleWeekStart\(\)/.test(panelSource), 'calendar panel exposes a week start toggle') +assert(/setting\("weekStartDay", null\)/.test(panelSource) && /entry\.weekStartDay =/.test(panelSource), 'calendar reads and writes the week start as weekStartDay') +assert(/updateEntryInline/.test(panelSource), 'calendar panel persists the week start to shell.json') +assert(/function moveMonth\(delta\)/.test(panelSource), 'calendar panel steps between months') +assert(!/property bool onToday/.test(panelSource) && !/root\.onToday/.test(panelSource), 'calendar panel avoids the on-prefixed property name QML reads as a signal handler') +assert(/readonly property bool viewingCurrentMonth:/.test(panelSource), 'calendar panel tracks whether the current month is on screen') +assert(!/MouseArea/.test(panelSource.slice(panelSource.indexOf('model: modelData.days'), panelSource.indexOf('// Hairline'))), 'calendar day cells are not selectable') +assert(/yearDone: Model\.yearProgress\(today\./.test(panelSource), 'calendar year bar stays pinned to today while months are stepped') +assert(/if \(!root\.popoutSwitchClosing\) setCenterHoverRevealSuppressed\(false\)/.test(panelSource), 'calendar leaves the shared hover-reveal flag alone when another panel takes over') +assert(/width: Math\.max\(calendarScroll\.width, gridColumn\.width\)/.test(panelSource), 'calendar scrolls rather than clipping the grid on a narrow popup') +assert(/enabled: !root\.viewingCurrentMonth/.test(panelSource) && /onClicked: root\.goToToday\(\)/.test(panelSource), 'calendar hero returns to today once the view has stepped away') +assert(!/clampMonth/.test(panelSource), 'calendar steps freely into future months') +assert(/Qt\.formatDate\(root\.today, "MMMM d"\)/.test(panelSource), 'calendar hero spells out today') +assert(/id: yearLabel/.test(panelSource) && /root\.yearDone/.test(panelSource), 'calendar panel shows the year progress bar') + +assert(/source: Qt\.resolvedUrl\("Panel\.qml"\)/.test(widgetSource), 'clock widget hosts the calendar panel') +assert(/readonly property bool opened:/.test(widgetSource), 'clock widget exposes the panel open state to shell routing') +assert(/Qt\.RightButton\) root\.cycleFormat\(\)/.test(widgetSource), 'clock right click cycles the label format') +assert(/readonly property string activeFormat: configuredFormat/.test(widgetSource), 'clock shows the format it has stored') +assert(/entry\[vertical \? "verticalFormat" : "format"\] = next/.test(widgetSource) && /updateEntryInline/.test(widgetSource), 'clock writes a cycled format back to shell.json') +assert(!/formatIndex/.test(widgetSource), 'clock keeps no session-only format position') +assert(/else root\.togglePanel\(\)/.test(widgetSource), 'clock left click reveals the calendar') +assert(/omarchy-menu-timezone/.test(widgetSource), 'clock keeps the timezone picker on middle click') + +// The bar identifies a panel by the widget in its slot, so the nested panel +// has to present the host widget rather than itself. +assert(/owner: root\.barIdentity/.test(panelSource), 'calendar panel gives the bar its host widget as popout identity') +assert(/switchPanelFrom\(root\.barIdentity, direction\)/.test(panelSource), 'calendar panel switches panels as its host widget') +assert(/hostWidget" in target\) target\.hostWidget = root/.test(widgetSource), 'clock widget injects itself as the panel host') +assert(/readonly property bool popoutSwitchClosing:/.test(widgetSource) && /function closeForPopoutSwitch\(\)/.test(widgetSource), 'clock widget forwards the popout-switch handshake') +assert(/openPanelIndicatorWidth: button\.labelWidth/.test(widgetSource), 'clock sizes the open-panel dot to its label') +assert(/openPanelIndicatorHeight: Math\.max/.test(widgetSource), 'clock sizes the vertical open-panel dot to one stacked line') +const barSource = fs.readFileSync(root + '/shell/plugins/bar/Bar.qml', 'utf8') +assert(/openPanelIndicatorWidth/.test(barSource) && /openPanelIndicatorHeight/.test(barSource), 'bar honours a widget-supplied open-panel dot size on both axes') +assert(/height: root\.vertical \? slot\.panelIndicatorExtent : Style\.space\(2\)/.test(barSource), 'bar sizes the vertical dot from the same hint as the horizontal one') +assert(/readonly property real labelWidth:/.test(fs.readFileSync(root + '/shell/Ui/WidgetButton.qml', 'utf8')), 'widget buttons expose their painted label width') + +// A horizontal wheel reports angleDelta.y === 0; without the guard every one +// of them would read as a forward step. +assert(/if \(event\.angleDelta\.y === 0\) return/.test(panelSource), 'calendar ignores wheel events with no vertical delta') +JS + +shell_json=$(cd "$ROOT" && jq -r '[.bar.layout.center[].id] | join(",")' config/omarchy/shell.json) +[[ $shell_json == *"omarchy.clock"* ]] || fail "default bar layout includes the clock widget" "center: $shell_json" +[[ $shell_json != *"omarchy.calendar"* ]] || fail "clock hosts the calendar instead of a second bar pill" "center: $shell_json" +pass "default bar layout includes the clock widget" + +grep -q 'o.bind("SUPER + CTRL + ALT + D", "Calendar", "omarchy-shell shell toggle omarchy.clock")' \ + "$ROOT/default/hypr/bindings/utilities.lua" || + fail "SUPER+CTRL+ALT+D toggles the calendar panel" +pass "SUPER+CTRL+ALT+D toggles the calendar panel" From c6198654326ac404f1a278599318a4683ef3db61 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 19:16:05 -0700 Subject: [PATCH 5/8] Keep the center hover reveal honest when panels hand off Claiming the shared suppression flag before showing meant the outgoing panel's close cleared it again, leaving the incoming panel open with the indicators still revealed. Guarding the clear instead only moved the problem: handing off to a panel that does not manage the flag left it stuck on, and the center indicators stopped revealing on hover for good. Claim it after the handoff instead. The panel taking over always wins, and a handoff to a panel that knows nothing about the flag still leaves it cleared. Co-Authored-By: Claude Opus 5 (1M context) --- shell/plugins/panels/clock/Panel.qml | 14 +++++++++----- shell/plugins/panels/weather/Panel.qml | 14 +++++++++----- test/shell.d/clock-test.sh | 3 ++- test/shell.d/weather-test.sh | 4 ++-- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/shell/plugins/panels/clock/Panel.qml b/shell/plugins/panels/clock/Panel.qml index f12950d6..aba21af9 100644 --- a/shell/plugins/panels/clock/Panel.qml +++ b/shell/plugins/panels/clock/Panel.qml @@ -70,16 +70,20 @@ Panel { readonly property int gutterWidth: Style.space(14) function open() { - setCenterHoverRevealSuppressed(true) refresh() root.controller.show() + // Set after showing, not before: showing hands the popout coordinator + // over, which closes whichever panel was open, and that close clears the + // shared flag. Deferring means the panel taking over always wins, while + // a handoff to a panel that does not manage the flag still leaves it + // cleared rather than stuck on. + Qt.callLater(function() { + if (root.opened) setCenterHoverRevealSuppressed(true) + }) } function close() { - // Not when another panel is taking over: it has already set the shared - // flag for itself, and clearing it here would leave the incoming panel - // open with the center indicators revealed behind it. - if (!root.popoutSwitchClosing) setCenterHoverRevealSuppressed(false) + setCenterHoverRevealSuppressed(false) root.controller.hide() } diff --git a/shell/plugins/panels/weather/Panel.qml b/shell/plugins/panels/weather/Panel.qml index 35df14f9..3757d942 100644 --- a/shell/plugins/panels/weather/Panel.qml +++ b/shell/plugins/panels/weather/Panel.qml @@ -33,17 +33,21 @@ Panel { function openFromHotkey() { openedFromHotkey = true - setCenterHoverRevealSuppressed(true) root.controller.show() locationFile.reload() root.refresh() + // Set after showing, not before: showing hands the popout coordinator + // over, which closes whichever panel was open, and that close clears the + // shared flag. Deferring means the panel taking over always wins, while + // a handoff to a panel that does not manage the flag still leaves it + // cleared rather than stuck on. + Qt.callLater(function() { + if (root.opened) setCenterHoverRevealSuppressed(true) + }) } function close() { - // Not when another panel is taking over: it has already set the shared - // flag for itself, and clearing it here would leave the incoming panel - // open with the center indicators revealed behind it. - if (!root.popoutSwitchClosing) setCenterHoverRevealSuppressed(false) + setCenterHoverRevealSuppressed(false) if (root.editingLocation) root.cancelEditingLocation() root.controller.hide() } diff --git a/test/shell.d/clock-test.sh b/test/shell.d/clock-test.sh index eef31506..db9e6a85 100755 --- a/test/shell.d/clock-test.sh +++ b/test/shell.d/clock-test.sh @@ -115,7 +115,8 @@ assert(!/property bool onToday/.test(panelSource) && !/root\.onToday/.test(panel assert(/readonly property bool viewingCurrentMonth:/.test(panelSource), 'calendar panel tracks whether the current month is on screen') assert(!/MouseArea/.test(panelSource.slice(panelSource.indexOf('model: modelData.days'), panelSource.indexOf('// Hairline'))), 'calendar day cells are not selectable') assert(/yearDone: Model\.yearProgress\(today\./.test(panelSource), 'calendar year bar stays pinned to today while months are stepped') -assert(/if \(!root\.popoutSwitchClosing\) setCenterHoverRevealSuppressed\(false\)/.test(panelSource), 'calendar leaves the shared hover-reveal flag alone when another panel takes over') +assert(/Qt\.callLater\(function\(\) \{\s*\n\s*if \(root\.opened\) setCenterHoverRevealSuppressed\(true\)/.test(panelSource), 'calendar claims the shared hover-reveal flag after the popout handoff, so the panel taking over wins') +assert(/function close\(\) \{\s*\n\s*setCenterHoverRevealSuppressed\(false\)/.test(panelSource), 'calendar always releases the shared hover-reveal flag on close') assert(/width: Math\.max\(calendarScroll\.width, gridColumn\.width\)/.test(panelSource), 'calendar scrolls rather than clipping the grid on a narrow popup') assert(/enabled: !root\.viewingCurrentMonth/.test(panelSource) && /onClicked: root\.goToToday\(\)/.test(panelSource), 'calendar hero returns to today once the view has stepped away') assert(!/clampMonth/.test(panelSource), 'calendar steps freely into future months') diff --git a/test/shell.d/weather-test.sh b/test/shell.d/weather-test.sh index 954cbf41..bc94c11b 100644 --- a/test/shell.d/weather-test.sh +++ b/test/shell.d/weather-test.sh @@ -136,8 +136,8 @@ assert( 'weather widget forwards the popout-switch handshake' ) assert( - panelSource.includes('if (!root.popoutSwitchClosing) setCenterHoverRevealSuppressed(false)'), - 'weather leaves the shared hover-reveal flag alone when another panel takes over' + /Qt\.callLater\(function\(\) \{\s*\n\s*if \(root\.opened\) setCenterHoverRevealSuppressed\(true\)/.test(panelSource), + 'weather claims the shared hover-reveal flag after the popout handoff, so the panel taking over wins' ) assert( From cc4771fd913b89abb0b5d07a0d6541bf6a64b41a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 19:16:05 -0700 Subject: [PATCH 6/8] Clone widgets that import a sibling JS module A cloned bar widget is copied out on its own, so a relative JS import lands in a directory without the file and the clone fails to load. Point those imports back at the bundled module, the same way Qt.resolvedUrl() references were already rewritten. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-plugin-clone | 13 +++++++++ test/shell.d/plugin-clone-test.sh | 48 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100755 test/shell.d/plugin-clone-test.sh diff --git a/bin/omarchy-plugin-clone b/bin/omarchy-plugin-clone index 173c3fd1..c18d0543 100755 --- a/bin/omarchy-plugin-clone +++ b/bin/omarchy-plugin-clone @@ -98,6 +98,19 @@ def resolve_relative(match): text = re.sub(r'Qt\.resolvedUrl\("([^"]+)"\)', resolve_relative, text) + +# A widget that imports a sibling JS module (the clock's Model.js) clones into +# a directory that does not have it. Point the import back at the bundled file, +# the same way Qt.resolvedUrl() references above are rewritten. +def resolve_js_import(match): + rel = match.group(1) + if rel.startswith("/") or "://" in rel: + return match.group(0) + resolved = (source_path.parent / rel).resolve() + return 'import "' + resolved.as_uri() + '"' + +text = re.sub(r'import\s+"([^"]+\.js)"', resolve_js_import, text) + # Indicators dynamically loads sibling indicator components via string # concatenation, so the simple Qt.resolvedUrl("literal") rewrite above cannot # see it. Point the clone back at Omarchy's bundled indicator directory. diff --git a/test/shell.d/plugin-clone-test.sh b/test/shell.d/plugin-clone-test.sh new file mode 100755 index 00000000..9bafe0b1 --- /dev/null +++ b/test/shell.d/plugin-clone-test.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command jq +require_command python3 + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT +mkdir -p "$TMPDIR/home/.config/omarchy" + +clone() { + HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" PATH="$ROOT/bin:$PATH" \ + omarchy-plugin-clone "$1" "$2" --name "$3" +} + +# A bar widget that pulls in a sibling JS module clones into a directory that +# does not contain it, so the import has to be rewritten back to the bundled +# file or the cloned widget fails to load. +clone omarchy.clock local.clock-clone "Cloned Clock" >/dev/null +widget="$TMPDIR/home/.config/omarchy/plugins/local.clock-clone/Widget.qml" + +[[ -f $widget ]] || fail "clone produces a widget file" +pass "clone produces a widget file" + +grep -q 'moduleName: "local.clock-clone"' "$widget" || + fail "clone rewrites the module name" +pass "clone rewrites the module name" + +while read -r ref; do + [[ $ref == file://* ]] || fail "clone leaves a relative reference behind" "$ref" +done < <(grep -oE '(import|Qt\.resolvedUrl\() *"[^"]+"' "$widget" | + grep -oE '"[^"]+"' | tr -d '"' | grep -E '\.(js|qml)$') +pass "clone resolves every relative QML and JS reference to the bundled file" + +for ref in $(grep -oE 'file://[^"]+\.(js|qml)' "$widget"); do + path=${ref#file://} + [[ -f $path ]] || fail "cloned reference points at a real file" "$ref" +done +pass "cloned references point at files that exist" + +# The bundled widget genuinely has such an import, so the check above is not +# passing by accident. +grep -qE '^import "[^"/][^"]*\.js"' "$ROOT/shell/plugins/panels/clock/BarWidget.qml" || + fail "the clock widget still imports a sibling JS module" +pass "the clock widget still imports a sibling JS module" From 9e3da4e27f1bf36557a8a89d1497f7fdb39c0f99 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 19:35:50 -0700 Subject: [PATCH 7/8] Count the other progress bar too Double-tapping the year bar asks for a birth year and a life expectancy, and a second bar appears below measuring one against the other. Tab moves between the two, Enter commits the pair, Escape drops them. Hovering the bar names the thing, and double-tapping it puts it away again. A birth year rather than an age, so the bar keeps counting on its own instead of going stale the moment it is entered. Expectancy defaults to ninety and falls back to it when what is entered makes no sense, so the bar always has something to measure against; a birth year that makes no sense leaves the bar hidden instead, which is also where it starts. Putting the bar away keeps the expectancy, so setting a birth year again brings your own number back rather than the default. Co-Authored-By: Claude Opus 5 (1M context) --- shell/plugins/panels/clock/Model.js | 63 ++++++++ shell/plugins/panels/clock/Panel.qml | 223 +++++++++++++++++++++++++-- test/shell.d/clock-test.sh | 59 ++++++- 3 files changed, 331 insertions(+), 14 deletions(-) diff --git a/shell/plugins/panels/clock/Model.js b/shell/plugins/panels/clock/Model.js index 767a9276..8d42f7fc 100644 --- a/shell/plugins/panels/clock/Model.js +++ b/shell/plugins/panels/clock/Model.js @@ -149,6 +149,63 @@ function yearProgressPercent(year, month, day) { return Math.round(yearProgress(year, month, day) * 100) } +// Memento mori. The default span is a round number rather than anything from +// an actuarial table: the point of the bar is the reminder, not the +// arithmetic, and whoever wants a different number can say so. +var DEFAULT_LIFE_EXPECTANCY = 90 + +// A birth year rather than an age, so the bar keeps counting on its own +// instead of going stale the moment it is entered. 0 means "not set", which +// is also what a blank, malformed, future, or implausibly distant year means. +function parseBirthYear(value, currentYear) { + var now = Math.round(Number(currentYear)) + if (!isFinite(now)) return 0 + var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "") + if (!/^\d{4}$/.test(text)) return 0 + var year = parseInt(text, 10) + if (!isFinite(year) || year > now || year < now - 120) return 0 + return year +} + +// Whole years, the way people say their age: born in 1979 makes you 47 for +// all of 2026, whichever side of your birthday today falls. +function ageFromBirthYear(birthYear, currentYear) { + var born = parseBirthYear(birthYear, currentYear) + if (born <= 0) return 0 + return Math.round(Number(currentYear)) - born +} + +// 0 means "not set", which is also what a blank, negative, fractional, or +// absurd entry means — the life bar simply stays hidden. +function parseAge(value) { + var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "") + if (!/^\d+$/.test(text)) return 0 + var years = parseInt(text, 10) + if (!isFinite(years) || years <= 0 || years > 120) return 0 + return years +} + +// Unset or nonsense falls back to the default rather than to zero, so the +// bar always has something to measure against. +function parseLifeExpectancy(value) { + var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "") + if (!/^\d+$/.test(text)) return DEFAULT_LIFE_EXPECTANCY + var years = parseInt(text, 10) + if (!isFinite(years) || years <= 0 || years > 150) return DEFAULT_LIFE_EXPECTANCY + return years +} + +function lifeProgress(age, expectancy) { + var years = parseAge(age) + var span = parseLifeExpectancy(expectancy) + if (years <= 0 || span <= 0) return 0 + return Math.max(0, Math.min(1, years / span)) +} + +function lifeProgressPercent(age, expectancy) { + return Math.round(lifeProgress(age, expectancy) * 100) +} + // Always six rows of seven days. A fixed grid keeps the popup exactly the // same height in every month, so stepping through the year never makes the // panel jump under the pointer. @@ -212,6 +269,12 @@ if (typeof module !== "undefined") { daysInYear: daysInYear, yearProgress: yearProgress, yearProgressPercent: yearProgressPercent, + parseAge: parseAge, + parseBirthYear: parseBirthYear, + ageFromBirthYear: ageFromBirthYear, + parseLifeExpectancy: parseLifeExpectancy, + lifeProgress: lifeProgress, + lifeProgressPercent: lifeProgressPercent, monthGrid: monthGrid, stepMonth: stepMonth, clockFormats: clockFormats, diff --git a/shell/plugins/panels/clock/Panel.qml b/shell/plugins/panels/clock/Panel.qml index aba21af9..fd9c4477 100644 --- a/shell/plugins/panels/clock/Panel.qml +++ b/shell/plugins/panels/clock/Panel.qml @@ -48,6 +48,17 @@ Panel { readonly property real yearDone: Model.yearProgress(today.getFullYear(), today.getMonth(), today.getDate()) readonly property int yearDonePercent: Model.yearProgressPercent(today.getFullYear(), today.getMonth(), today.getDate()) + // Memento mori, for anyone who goes looking: double-tapping the year bar + // asks for a birth year and a life expectancy, and a second bar tracks one + // against the other. A birth year rather than an age, so it keeps counting + // on its own. Without one the bar stays hidden. + readonly property int birthYear: Model.parseBirthYear(setting("birthYear", 0), today.getFullYear()) + readonly property int age: Model.ageFromBirthYear(birthYear, today.getFullYear()) + readonly property int lifeExpectancy: Model.parseLifeExpectancy(setting("lifeExpectancy", 0)) + readonly property real lifeDone: Model.lifeProgress(age, lifeExpectancy) + readonly property int lifeDonePercent: Model.lifeProgressPercent(age, lifeExpectancy) + property bool editingLife: false + // Unset falls through to the locale's own first day, so a fresh install // starts out matching the rest of the desktop rather than a hardcoded // convention. Clicking the grid's "W" heading writes the choice back to @@ -84,6 +95,9 @@ Panel { function close() { setCenterHoverRevealSuppressed(false) + // Dismissing the panel mid-edit would otherwise leave the inputs up, + // waiting behind a closed popup for the next time it opens. + if (root.editingLife) root.cancelEditingLife() root.controller.hide() } @@ -125,27 +139,76 @@ Panel { moveMonth(delta * 12) } - function setWeekStart(day) { - var next = Model.normalizedWeekStart(day, root.weekStart) - if (next === root.weekStart) return - + // Applied locally first so the panel redraws on the click itself; the + // shell.json write comes back through the bar as the same value. With no + // writable entry (the widget is not in the layout) it stays a session-only + // preference rather than doing nothing. The host widget builds its own + // entry when the label format is cycled, so it has to be kept in step or + // it would write this key straight back out from a stale copy. + function persistSettings(values) { var entry = { id: root.moduleName } - for (var key in root.settings) if (key !== "id") entry[key] = root.settings[key] - entry.weekStartDay = Model.weekStartSettingName(next) + for (var existing in root.settings) if (existing !== "id") entry[existing] = root.settings[existing] + for (var key in values) entry[key] = values[key] - // Applied locally first so the grid reflows on the click itself; the - // shell.json write comes back through the bar as the same value. With - // no writable entry (the widget is not in the layout) it stays a - // session-only preference rather than doing nothing. root.settings = entry - // The host widget builds its own entry when the label format is cycled. - // Until shell.json round-trips it would be working from a copy without - // this key, and would write the week start straight back out. if (root.hostWidget && "settings" in root.hostWidget) root.hostWidget.settings = entry if (root.bar && root.bar.shell && typeof root.bar.shell.updateEntryInline === "function") root.bar.shell.updateEntryInline(root.moduleName, entry) } + function setWeekStart(day) { + var next = Model.normalizedWeekStart(day, root.weekStart) + if (next === root.weekStart) return + persistSettings({ weekStartDay: Model.weekStartSettingName(next) }) + } + + function startEditingLife() { + root.editingLife = true + Qt.callLater(function() { + bornField.text = root.birthYear > 0 ? String(root.birthYear) : "" + expectancyField.text = String(root.lifeExpectancy) + bornField.selectAll() + bornField.forceActiveFocus() + }) + } + + function cancelEditingLife() { + root.editingLife = false + Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) + } + + // Shared by both fields: Tab hops to the other one, Enter commits the pair, + // Escape drops the lot. + function handleLifeKey(event, other) { + if (event.key === Qt.Key_Escape) { + root.cancelEditingLife() + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.commitLife() + event.accepted = true + } else if (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) { + other.selectAll() + other.forceActiveFocus() + event.accepted = true + } + } + + // Double-tapping the life bar puts it away again. The expectancy stays in + // the config so setting a birth year again brings your own number back + // rather than the default. + function clearLife() { + if (root.birthYear <= 0) return + persistSettings({ birthYear: 0 }) + } + + function commitLife() { + var born = Model.parseBirthYear(bornField.text, today.getFullYear()) + var span = Model.parseLifeExpectancy(expectancyField.text) + if (born !== root.birthYear || span !== root.lifeExpectancy) + persistSettings({ birthYear: born, lifeExpectancy: span }) + cancelEditingLife() + } + function toggleWeekStart() { setWeekStart(Model.toggledWeekStart(root.weekStart)) } @@ -181,6 +244,7 @@ Panel { PanelKeyCatcher { id: keyCatcher anchors.fill: parent + blocked: root.editingLife onMoveRequested: function(dx, dy) { if (dx !== 0) root.moveMonth(dx) if (dy !== 0) root.moveYear(dy) @@ -288,8 +352,65 @@ Panel { width: gridColumn.width height: Math.max(yearLabel.implicitHeight, Style.space(10)) + TapHandler { + enabled: !root.editingLife + onDoubleTapped: root.startEditingLife() + } + + Row { + visible: root.editingLife + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + spacing: Style.space(10) + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "BORN" + color: Qt.darker(root.contentForeground, 1.5) + font.family: root.contentFontFamily + font.pixelSize: Style.font.bodySmall + font.letterSpacing: 1 + } + + TextField { + id: bornField + width: Style.space(70) + anchors.verticalCenter: parent.verticalCenter + placeholderText: "year" + foreground: root.contentForeground + font.family: root.contentFontFamily + inputMethodHints: Qt.ImhDigitsOnly + + Keys.onPressed: function(event) { root.handleLifeKey(event, expectancyField) } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: 0 + leftPadding: Style.space(6) + text: "LIVE TO" + color: Qt.darker(root.contentForeground, 1.5) + font.family: root.contentFontFamily + font.pixelSize: Style.font.bodySmall + font.letterSpacing: 1 + } + + TextField { + id: expectancyField + width: Style.space(60) + anchors.verticalCenter: parent.verticalCenter + placeholderText: "90" + foreground: root.contentForeground + font.family: root.contentFontFamily + inputMethodHints: Qt.ImhDigitsOnly + + Keys.onPressed: function(event) { root.handleLifeKey(event, bornField) } + } + } + Text { id: yearLabel + visible: !root.editingLife anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter text: root.today.getFullYear() @@ -301,6 +422,7 @@ Panel { Text { id: yearPercent + visible: !root.editingLife anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: root.yearDonePercent + "%" @@ -311,6 +433,7 @@ Panel { Rectangle { id: yearTrack + visible: !root.editingLife anchors.left: yearLabel.right anchors.right: yearPercent.left anchors.leftMargin: Style.space(12) @@ -332,6 +455,80 @@ Panel { } } + // ---- Memento mori. Only here once someone has gone looking and + // given an age; the same rail as the year above it, measured + // against a nominal lifetime. + Item { + visible: root.birthYear > 0 + width: parent.width + height: visible ? lifeBlock.height : 0 + + Item { + id: lifeBlock + anchors.horizontalCenter: parent.horizontalCenter + width: gridColumn.width + height: Math.max(lifeLabel.implicitHeight, Style.space(10)) + + Text { + id: lifeLabel + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "LIFE" + color: Qt.darker(root.contentForeground, 1.5) + font.family: root.contentFontFamily + font.pixelSize: Style.font.bodySmall + font.letterSpacing: 1 + } + + Text { + id: lifePercent + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.lifeDonePercent + "%" + color: root.contentForeground + font.family: root.contentFontFamily + font.pixelSize: Style.font.bodySmall + } + + Rectangle { + anchors.left: lifeLabel.right + anchors.right: lifePercent.left + anchors.leftMargin: Style.space(12) + anchors.rightMargin: Style.space(12) + anchors.verticalCenter: parent.verticalCenter + height: Style.space(6) + radius: Style.cornerRadius > 0 ? height / 2 : 0 + color: Qt.rgba(root.contentForeground.r, root.contentForeground.g, root.contentForeground.b, 0.12) + + Rectangle { + width: Math.round(parent.width * root.lifeDone) + height: parent.height + radius: parent.radius + color: Style.selectedStateColor(root.contentForeground, Color.accent) + + Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } + } + } + + TapHandler { + onDoubleTapped: root.clearLife() + } + + MouseArea { + id: lifeMouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + + PanelToolTip { + visible: lifeMouse.containsMouse + text: "Memento Mori" + fontFamily: root.contentFontFamily + } + } + } + } + // ---- Month grid: week numbers down a gutter on the left, then // the seven day columns. Always six rows, so the popup is // exactly as tall in February as it is in August. diff --git a/test/shell.d/clock-test.sh b/test/shell.d/clock-test.sh index db9e6a85..ac9ea572 100755 --- a/test/shell.d/clock-test.sh +++ b/test/shell.d/clock-test.sh @@ -40,6 +40,45 @@ assertEqual(calendar.yearProgressPercent(2026, 0, 1), 0, 'calendar starts the ye assertEqual(calendar.yearProgressPercent(2026, 6, 26), 56, 'calendar reports the share of the year behind you') assertEqual(calendar.yearProgressPercent(2026, 11, 31), 100, 'calendar finishes the year at a hundred percent') assertEqual(calendar.yearProgressPercent(2024, 11, 31), 100, 'calendar finishes a leap year too') + +// ---- memento mori +// A birth year rather than an age, so the bar keeps counting on its own. +assertEqual(calendar.parseBirthYear('1979', 2026), 1979, 'calendar reads a birth year') +assertEqual(calendar.parseBirthYear(1979, 2026), 1979, 'calendar reads a numeric birth year') +assertEqual(calendar.parseBirthYear(' 1979 ', 2026), 1979, 'calendar trims a birth year') +assertEqual(calendar.parseBirthYear('2026', 2026), 2026, 'calendar accepts the current year') +assertEqual(calendar.parseBirthYear('2027', 2026), 0, 'calendar rejects a birth year in the future') +assertEqual(calendar.parseBirthYear('1905', 2026), 0, 'calendar rejects an implausibly distant birth year') +assertEqual(calendar.parseBirthYear('79', 2026), 0, 'calendar rejects a two-digit year') +assertEqual(calendar.parseBirthYear('', 2026), 0, 'calendar treats a blank birth year as unset') +assertEqual(calendar.parseBirthYear('abc', 2026), 0, 'calendar treats a non-numeric birth year as unset') + +assertEqual(calendar.ageFromBirthYear('1979', 2026), 47, 'calendar derives an age from a birth year') +assertEqual(calendar.ageFromBirthYear('1979', 2027), 48, 'calendar keeps counting as the years pass') +assertEqual(calendar.ageFromBirthYear('2026', 2026), 0, 'calendar makes someone born this year zero') +assertEqual(calendar.ageFromBirthYear('', 2026), 0, 'calendar derives no age without a birth year') + +assertEqual(calendar.parseAge('47'), 47, 'calendar reads an age') +assertEqual(calendar.parseAge(47), 47, 'calendar reads a numeric age') +assertEqual(calendar.parseAge(' 47 '), 47, 'calendar trims an age') +assertEqual(calendar.parseAge(''), 0, 'calendar treats a blank age as unset') +assertEqual(calendar.parseAge('0'), 0, 'calendar treats zero as unset') +assertEqual(calendar.parseAge('-3'), 0, 'calendar treats a negative age as unset') +assertEqual(calendar.parseAge('121'), 0, 'calendar treats an implausible age as unset') +assertEqual(calendar.parseAge('abc'), 0, 'calendar treats a non-numeric age as unset') +assertEqual(calendar.parseAge('4.5'), 0, 'calendar treats a fractional age as unset') +assertEqual(calendar.parseLifeExpectancy('65'), 65, 'calendar reads a life expectancy') +assertEqual(calendar.parseLifeExpectancy(''), 90, 'calendar defaults the life expectancy to ninety') +assertEqual(calendar.parseLifeExpectancy(0), 90, 'calendar defaults an unset life expectancy') +assertEqual(calendar.parseLifeExpectancy('abc'), 90, 'calendar defaults a non-numeric life expectancy') +assertEqual(calendar.parseLifeExpectancy('200'), 90, 'calendar defaults an implausible life expectancy') + +assertEqual(calendar.lifeProgressPercent(45, 90), 50, 'calendar measures a life against the expectancy given') +assertEqual(calendar.lifeProgressPercent(45, 65), 69, 'calendar honours a shorter expectancy') +assertEqual(calendar.lifeProgressPercent(45, ''), 50, 'calendar falls back to ninety when no expectancy is set') +assertEqual(calendar.lifeProgressPercent(90, 90), 100, 'calendar fills the life bar at the expectancy') +assertEqual(calendar.lifeProgressPercent(80, 65), 100, 'calendar never overfills the life bar') +assertEqual(calendar.lifeProgressPercent(0, 90), 0, 'calendar leaves the life bar empty when no age is set') assertEqual(calendar.daysInYear(2024), 366, 'calendar knows leap years are longer') assertEqual(calendar.daysInYear(2026), 365, 'calendar knows common years') @@ -108,7 +147,7 @@ assert(/ipcTarget: "omarchy\.clock"/.test(panelSource), 'calendar panel register assert(/manageIpc: false/.test(panelSource), 'calendar panel leaves the IPC target to the bar widget') assert(/anchorItem: root\.anchorItem/.test(panelSource), 'calendar panel anchors to the host widget button') assert(/function toggleWeekStart\(\)/.test(panelSource), 'calendar panel exposes a week start toggle') -assert(/setting\("weekStartDay", null\)/.test(panelSource) && /entry\.weekStartDay =/.test(panelSource), 'calendar reads and writes the week start as weekStartDay') +assert(/setting\("weekStartDay", null\)/.test(panelSource) && /persistSettings\(\{ weekStartDay:/.test(panelSource), 'calendar reads and writes the week start as weekStartDay') assert(/updateEntryInline/.test(panelSource), 'calendar panel persists the week start to shell.json') assert(/function moveMonth\(delta\)/.test(panelSource), 'calendar panel steps between months') assert(!/property bool onToday/.test(panelSource) && !/root\.onToday/.test(panelSource), 'calendar panel avoids the on-prefixed property name QML reads as a signal handler') @@ -123,6 +162,24 @@ assert(!/clampMonth/.test(panelSource), 'calendar steps freely into future month assert(/Qt\.formatDate\(root\.today, "MMMM d"\)/.test(panelSource), 'calendar hero spells out today') assert(/id: yearLabel/.test(panelSource) && /root\.yearDone/.test(panelSource), 'calendar panel shows the year progress bar') +// The memento mori bar is opt-in: double-tapping the year bar asks for an age, +// and nothing shows until one has been given. +assert(/onDoubleTapped: root\.startEditingLife\(\)/.test(panelSource), 'calendar asks for an age when the year bar is double-tapped') +assert(/persistSettings\(\{ birthYear: born, lifeExpectancy: span \}\)/.test(panelSource), 'calendar saves birth year and expectancy together, so neither lands on a stale copy') +assert(/readonly property int birthYear: Model\.parseBirthYear\(setting\("birthYear", 0\)/.test(panelSource), 'calendar reads the saved birth year back') +assert(/readonly property int age: Model\.ageFromBirthYear\(birthYear/.test(panelSource), 'calendar derives the age from the stored birth year') +assert(/readonly property int lifeExpectancy: Model\.parseLifeExpectancy\(setting\("lifeExpectancy", 0\)\)/.test(panelSource), 'calendar reads the saved expectancy back') +assert(/id: expectancyField/.test(panelSource) && /id: bornField/.test(panelSource), 'calendar offers both inputs') +assert(/visible: root\.editingLife\s*\n\s*anchors\.horizontalCenter: parent\.horizontalCenter/.test(panelSource), 'calendar centers the inputs over the bar they replace') +assert(/visible: root\.birthYear > 0/.test(panelSource), 'calendar hides the life bar until a birth year is known') +assert(/text: "LIFE"/.test(panelSource) && /root\.lifeDone/.test(panelSource), 'calendar shows the life bar') +assert(/text: "Memento Mori"/.test(panelSource), 'calendar names the life bar on hover') +assert(/onDoubleTapped: root\.clearLife\(\)/.test(panelSource), 'calendar puts the life bar away when it is double-tapped') +assert(/persistSettings\(\{ birthYear: 0 \}\)/.test(panelSource), 'calendar clears the birth year to hide the life bar') +assertEqual(calendar.parseBirthYear(0, 2026), 0, 'a cleared birth year reads back as unset') +assert(/blocked: root\.editingLife/.test(panelSource), 'calendar lets the inputs have the keyboard while they are up') +assert(/if \(root\.editingLife\) root\.cancelEditingLife\(\)/.test(panelSource), 'calendar drops a half-finished edit when the panel closes') + assert(/source: Qt\.resolvedUrl\("Panel\.qml"\)/.test(widgetSource), 'clock widget hosts the calendar panel') assert(/readonly property bool opened:/.test(widgetSource), 'clock widget exposes the panel open state to shell routing') assert(/Qt\.RightButton\) root\.cycleFormat\(\)/.test(widgetSource), 'clock right click cycles the label format') From 8728791c93ecf6f19d8a928c60407a813ec77e9b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 20:30:52 -0700 Subject: [PATCH 8/8] Build only the module list the bar is showing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The center section declares both an anchored and an unanchored arrangement and shows whichever fits, but a hidden ModuleList is still a loaded Loader. With a center anchor set — the default — every center module was therefore mounted twice for the life of the session: two IPC handlers registered for the same target, two clocks ticking, two of every timer and network fetch behind them, one set of which nothing could reach. Co-Authored-By: Claude Opus 5 (1M context) --- shell/plugins/bar/Bar.qml | 6 ++++++ test/shell.d/bar-test.sh | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index 4d7202cc..daf243c0 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -1321,6 +1321,12 @@ Item { property string region: "" visible: entries.length > 0 + // A hidden list must not build its modules. The center section declares + // both an anchored and an unanchored arrangement and shows whichever + // fits, so leaving the other one loaded mounts every center module + // twice — two IPC handlers registered for the same target, two clocks + // ticking, two of every timer and fetch behind them. + active: visible && entries.length > 0 sourceComponent: root.vertical ? verticalModuleList : horizontalModuleList width: item ? item.implicitWidth : 0 height: item ? item.implicitHeight : 0 diff --git a/test/shell.d/bar-test.sh b/test/shell.d/bar-test.sh index 7a40ea9b..5bd64d5e 100644 --- a/test/shell.d/bar-test.sh +++ b/test/shell.d/bar-test.sh @@ -19,6 +19,14 @@ const fs = require('fs') const bar = requireFromRoot('shell/plugins/bar/BarModel.js') const barSource = fs.readFileSync(root + '/shell/plugins/bar/Bar.qml', 'utf8') +// The center section declares two arrangements and shows one; the hidden one +// must not build its modules or every center widget exists twice. +const moduleList = barSource.slice(barSource.indexOf('component ModuleList'), barSource.indexOf('component ModuleSlot')) +assert( + /active: visible && entries\.length > 0/.test(moduleList), + 'bar builds only the module list it is showing' +) + // A center module is mounted twice — drawn copy plus zero-size placeholder — // and the order they register in is not stable across a live reconfiguration, // so panel routing has to pick the one that is actually on screen.