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"