From e2d655d2655a6f303ecbd69ff24e3ee403112c75 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 19:10:06 -0700 Subject: [PATCH] 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"