diff --git a/bin/omarchy-plugin-clone b/bin/omarchy-plugin-clone index 173c3fd1..c18d0543 100755 --- a/bin/omarchy-plugin-clone +++ b/bin/omarchy-plugin-clone @@ -98,6 +98,19 @@ def resolve_relative(match): text = re.sub(r'Qt\.resolvedUrl\("([^"]+)"\)', resolve_relative, text) + +# A widget that imports a sibling JS module (the clock's Model.js) clones into +# a directory that does not have it. Point the import back at the bundled file, +# the same way Qt.resolvedUrl() references above are rewritten. +def resolve_js_import(match): + rel = match.group(1) + if rel.startswith("/") or "://" in rel: + return match.group(0) + resolved = (source_path.parent / rel).resolve() + return 'import "' + resolved.as_uri() + '"' + +text = re.sub(r'import\s+"([^"]+\.js)"', resolve_js_import, text) + # Indicators dynamically loads sibling indicator components via string # concatenation, so the simple Qt.resolvedUrl("literal") rewrite above cannot # see it. Point the clone back at Omarchy's bundled indicator directory. diff --git a/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/Ui/WidgetButton.qml b/shell/Ui/WidgetButton.qml index 1641fcfd..02d843ab 100644 --- a/shell/Ui/WidgetButton.qml +++ b/shell/Ui/WidgetButton.qml @@ -59,6 +59,9 @@ Item { readonly property real scaledHorizontalMargin: Style.spaceReal(horizontalMargin) readonly property real scaledVerticalPadding: Style.spaceReal(verticalPadding) readonly property bool tooltipHovered: visible && interactive && !concealed && mouseArea.containsMouse + // Width of the painted label, for bar chrome that wants to line up with the + // text rather than with the slot it sits in. Zero on icon-only buttons. + readonly property real labelWidth: label.visible ? label.implicitWidth : 0 visible: hasVisualContent || keepSpace opacity: !hasVisualContent || concealed ? 0 : (dimmed ? 0.45 : 1) diff --git a/shell/plugins/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/Bar.qml b/shell/plugins/bar/Bar.qml index af40f78b..daf243c0 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -406,15 +406,19 @@ Item { function findPanelWidget(pluginId) { var id = String(pluginId || "") if (!id) return null + var candidates = [] for (var i = 0; i < moduleSlots.length; i++) { var slot = moduleSlots[i] if (!slot || !slot.activeItem) continue if (slot.moduleName !== id) continue var item = slot.activeItem if (typeof item.open !== "function" || typeof item.close !== "function" || item.opened === undefined) continue - return item + candidates.push(slot) } - return null + // Anchored center modules are mounted twice; only the drawn copy can + // anchor a popup or carry the open-panel mark. See BarModel.pickDrawnSlot. + var chosen = BarModel.pickDrawnSlot(candidates) + return chosen ? chosen.activeItem : null } function summonBarWidget(pluginId) { @@ -1317,6 +1321,12 @@ Item { property string region: "" visible: entries.length > 0 + // A hidden list must not build its modules. The center section declares + // both an anchored and an unanchored arrangement and shows whichever + // fits, so leaving the other one loaded mounts every center module + // twice — two IPC handlers registered for the same target, two clocks + // ticking, two of every timer and fetch behind them. + active: visible && entries.length > 0 sourceComponent: root.vertical ? verticalModuleList : horizontalModuleList width: item ? item.implicitWidth : 0 height: item ? item.implicitHeight : 0 @@ -1386,6 +1396,16 @@ Item { readonly property bool hovered: moduleHover.hovered readonly property bool dragSource: root.barDragSource === slot readonly property bool panelOpen: root.activePopout === slot.activeItem + // Modules bigger than the mark they want (a text label in a padded slot, + // a multi-line stack on a vertical bar) can say how long the open-panel + // dot should be along the bar, so it tracks what the module paints + // instead of a fraction of whatever slot it happens to fill. + readonly property real panelIndicatorExtent: { + var key = root.vertical ? "openPanelIndicatorHeight" : "openPanelIndicatorWidth" + var hint = activeItem && key in activeItem ? activeItem[key] : undefined + if (hint !== undefined && hint !== null && hint > 0) return Math.round(hint) + return Math.max(Style.space(10), Math.round((root.vertical ? slot.height : slot.width) * 0.55)) + } implicitWidth: activeItem && activeItem.visible ? (root.vertical ? root.barSize : activeItem.implicitWidth) : 0 implicitHeight: activeItem && activeItem.visible ? activeItem.implicitHeight : 0 width: implicitWidth @@ -1455,8 +1475,12 @@ Item { opacity: slot.panelOpen && !slot.dragSource ? 0.9 : 0 color: Color.accent radius: Math.min(width, height) / 2 - width: root.vertical ? Style.space(2) : Math.max(Style.space(10), Math.round(parent.width * 0.55)) - height: root.vertical ? Math.max(Style.space(10), Math.round(parent.height * 0.55)) : Style.space(2) + width: root.vertical ? Style.space(2) : slot.panelIndicatorExtent + height: root.vertical ? slot.panelIndicatorExtent : Style.space(2) + // The mark sits on the module's inner edge — the one facing the + // desktop — so it underlines a top bar, overlines a bottom one, and + // points inward from a left or right one. It reads as pointing at the + // panel that opens on that side. x: root.vertical ? (root.position === "left" ? parent.width - width - inset : inset) : Math.round((parent.width - width) / 2) diff --git a/shell/plugins/bar/BarModel.js b/shell/plugins/bar/BarModel.js index e94d8a4f..89b3e3c2 100644 --- a/shell/plugins/bar/BarModel.js +++ b/shell/plugins/bar/BarModel.js @@ -96,8 +96,32 @@ function customModulePath(entry, home, configDir) { return source } +// A center module is mounted twice once an anchor is set: the copy that is +// actually drawn, and a zero-size placeholder holding its place in the flow +// beside the anchor. Panel routing has to pick the drawn one — it is the only +// one that can anchor a popup, carry the open-panel mark, or be found again +// by switchPanelFrom — and fall back to the placeholder only when nothing is +// on screen. The order the two are registered in is not stable across a live +// bar reconfiguration, so picking the first match is not good enough. +function isDrawnSlot(slot) { + return !!slot && slot.visible === true && slot.width > 0 && slot.height > 0 +} + +function pickDrawnSlot(slots) { + var placeholder = null + var list = slots || [] + for (var i = 0; i < list.length; i++) { + if (!list[i]) continue + if (isDrawnSlot(list[i])) return list[i] + if (!placeholder) placeholder = list[i] + } + return placeholder +} + if (typeof module !== "undefined") { module.exports = { + isDrawnSlot: isDrawnSlot, + pickDrawnSlot: pickDrawnSlot, normalizePosition: normalizePosition, entrySettings: entrySettings, entryId: entryId, diff --git a/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..8d42f7fc --- /dev/null +++ b/shell/plugins/panels/clock/Model.js @@ -0,0 +1,285 @@ +// 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) +} + +// Memento mori. The default span is a round number rather than anything from +// an actuarial table: the point of the bar is the reminder, not the +// arithmetic, and whoever wants a different number can say so. +var DEFAULT_LIFE_EXPECTANCY = 90 + +// A birth year rather than an age, so the bar keeps counting on its own +// instead of going stale the moment it is entered. 0 means "not set", which +// is also what a blank, malformed, future, or implausibly distant year means. +function parseBirthYear(value, currentYear) { + var now = Math.round(Number(currentYear)) + if (!isFinite(now)) return 0 + var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "") + if (!/^\d{4}$/.test(text)) return 0 + var year = parseInt(text, 10) + if (!isFinite(year) || year > now || year < now - 120) return 0 + return year +} + +// Whole years, the way people say their age: born in 1979 makes you 47 for +// all of 2026, whichever side of your birthday today falls. +function ageFromBirthYear(birthYear, currentYear) { + var born = parseBirthYear(birthYear, currentYear) + if (born <= 0) return 0 + return Math.round(Number(currentYear)) - born +} + +// 0 means "not set", which is also what a blank, negative, fractional, or +// absurd entry means — the life bar simply stays hidden. +function parseAge(value) { + var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "") + if (!/^\d+$/.test(text)) return 0 + var years = parseInt(text, 10) + if (!isFinite(years) || years <= 0 || years > 120) return 0 + return years +} + +// Unset or nonsense falls back to the default rather than to zero, so the +// bar always has something to measure against. +function parseLifeExpectancy(value) { + var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "") + if (!/^\d+$/.test(text)) return DEFAULT_LIFE_EXPECTANCY + var years = parseInt(text, 10) + if (!isFinite(years) || years <= 0 || years > 150) return DEFAULT_LIFE_EXPECTANCY + return years +} + +function lifeProgress(age, expectancy) { + var years = parseAge(age) + var span = parseLifeExpectancy(expectancy) + if (years <= 0 || span <= 0) return 0 + return Math.max(0, Math.min(1, years / span)) +} + +function lifeProgressPercent(age, expectancy) { + return Math.round(lifeProgress(age, expectancy) * 100) +} + +// Always six rows of seven days. A fixed grid keeps the popup exactly the +// same height in every month, so stepping through the year never makes the +// panel jump under the pointer. +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, + parseAge: parseAge, + parseBirthYear: parseBirthYear, + ageFromBirthYear: ageFromBirthYear, + parseLifeExpectancy: parseLifeExpectancy, + lifeProgress: lifeProgress, + lifeProgressPercent: lifeProgressPercent, + 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..fd9c4477 --- /dev/null +++ b/shell/plugins/panels/clock/Panel.qml @@ -0,0 +1,749 @@ +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()) + + // Memento mori, for anyone who goes looking: double-tapping the year bar + // asks for a birth year and a life expectancy, and a second bar tracks one + // against the other. A birth year rather than an age, so it keeps counting + // on its own. Without one the bar stays hidden. + readonly property int birthYear: Model.parseBirthYear(setting("birthYear", 0), today.getFullYear()) + readonly property int age: Model.ageFromBirthYear(birthYear, today.getFullYear()) + readonly property int lifeExpectancy: Model.parseLifeExpectancy(setting("lifeExpectancy", 0)) + readonly property real lifeDone: Model.lifeProgress(age, lifeExpectancy) + readonly property int lifeDonePercent: Model.lifeProgressPercent(age, lifeExpectancy) + property bool editingLife: false + + // Unset falls through to the locale's own first day, so a fresh install + // starts out matching the rest of the desktop rather than a hardcoded + // convention. Clicking the grid's "W" heading writes the choice back to + // 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() { + refresh() + root.controller.show() + // Set after showing, not before: showing hands the popout coordinator + // over, which closes whichever panel was open, and that close clears the + // shared flag. Deferring means the panel taking over always wins, while + // a handoff to a panel that does not manage the flag still leaves it + // cleared rather than stuck on. + Qt.callLater(function() { + if (root.opened) setCenterHoverRevealSuppressed(true) + }) + } + + function close() { + setCenterHoverRevealSuppressed(false) + // Dismissing the panel mid-edit would otherwise leave the inputs up, + // waiting behind a closed popup for the next time it opens. + if (root.editingLife) root.cancelEditingLife() + root.controller.hide() + } + + 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) + } + + // Applied locally first so the panel redraws on the click itself; the + // shell.json write comes back through the bar as the same value. With no + // writable entry (the widget is not in the layout) it stays a session-only + // preference rather than doing nothing. The host widget builds its own + // entry when the label format is cycled, so it has to be kept in step or + // it would write this key straight back out from a stale copy. + function persistSettings(values) { + var entry = { id: root.moduleName } + for (var existing in root.settings) if (existing !== "id") entry[existing] = root.settings[existing] + for (var key in values) entry[key] = values[key] + + root.settings = entry + if (root.hostWidget && "settings" in root.hostWidget) root.hostWidget.settings = entry + if (root.bar && root.bar.shell && typeof root.bar.shell.updateEntryInline === "function") + root.bar.shell.updateEntryInline(root.moduleName, entry) + } + + function setWeekStart(day) { + var next = Model.normalizedWeekStart(day, root.weekStart) + if (next === root.weekStart) return + persistSettings({ weekStartDay: Model.weekStartSettingName(next) }) + } + + function startEditingLife() { + root.editingLife = true + Qt.callLater(function() { + bornField.text = root.birthYear > 0 ? String(root.birthYear) : "" + expectancyField.text = String(root.lifeExpectancy) + bornField.selectAll() + bornField.forceActiveFocus() + }) + } + + function cancelEditingLife() { + root.editingLife = false + Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) + } + + // Shared by both fields: Tab hops to the other one, Enter commits the pair, + // Escape drops the lot. + function handleLifeKey(event, other) { + if (event.key === Qt.Key_Escape) { + root.cancelEditingLife() + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.commitLife() + event.accepted = true + } else if (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) { + other.selectAll() + other.forceActiveFocus() + event.accepted = true + } + } + + // Double-tapping the life bar puts it away again. The expectancy stays in + // the config so setting a birth year again brings your own number back + // rather than the default. + function clearLife() { + if (root.birthYear <= 0) return + persistSettings({ birthYear: 0 }) + } + + function commitLife() { + var born = Model.parseBirthYear(bornField.text, today.getFullYear()) + var span = Model.parseLifeExpectancy(expectancyField.text) + if (born !== root.birthYear || span !== root.lifeExpectancy) + persistSettings({ birthYear: born, lifeExpectancy: span }) + cancelEditingLife() + } + + function toggleWeekStart() { + setWeekStart(Model.toggledWeekStart(root.weekStart)) + } + + // 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 + blocked: root.editingLife + 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)) + + TapHandler { + enabled: !root.editingLife + onDoubleTapped: root.startEditingLife() + } + + Row { + visible: root.editingLife + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + spacing: Style.space(10) + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "BORN" + color: Qt.darker(root.contentForeground, 1.5) + font.family: root.contentFontFamily + font.pixelSize: Style.font.bodySmall + font.letterSpacing: 1 + } + + TextField { + id: bornField + width: Style.space(70) + anchors.verticalCenter: parent.verticalCenter + placeholderText: "year" + foreground: root.contentForeground + font.family: root.contentFontFamily + inputMethodHints: Qt.ImhDigitsOnly + + Keys.onPressed: function(event) { root.handleLifeKey(event, expectancyField) } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: 0 + leftPadding: Style.space(6) + text: "LIVE TO" + color: Qt.darker(root.contentForeground, 1.5) + font.family: root.contentFontFamily + font.pixelSize: Style.font.bodySmall + font.letterSpacing: 1 + } + + TextField { + id: expectancyField + width: Style.space(60) + anchors.verticalCenter: parent.verticalCenter + placeholderText: "90" + foreground: root.contentForeground + font.family: root.contentFontFamily + inputMethodHints: Qt.ImhDigitsOnly + + Keys.onPressed: function(event) { root.handleLifeKey(event, bornField) } + } + } + + Text { + id: yearLabel + visible: !root.editingLife + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: root.today.getFullYear() + color: Qt.darker(root.contentForeground, 1.5) + font.family: root.contentFontFamily + font.pixelSize: Style.font.bodySmall + font.letterSpacing: 1 + } + + Text { + id: yearPercent + visible: !root.editingLife + 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 + visible: !root.editingLife + 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 } } + } + } + } + } + + // ---- Memento mori. Only here once someone has gone looking and + // given an age; the same rail as the year above it, measured + // against a nominal lifetime. + Item { + visible: root.birthYear > 0 + width: parent.width + height: visible ? lifeBlock.height : 0 + + Item { + id: lifeBlock + anchors.horizontalCenter: parent.horizontalCenter + width: gridColumn.width + height: Math.max(lifeLabel.implicitHeight, Style.space(10)) + + Text { + id: lifeLabel + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "LIFE" + color: Qt.darker(root.contentForeground, 1.5) + font.family: root.contentFontFamily + font.pixelSize: Style.font.bodySmall + font.letterSpacing: 1 + } + + Text { + id: lifePercent + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.lifeDonePercent + "%" + color: root.contentForeground + font.family: root.contentFontFamily + font.pixelSize: Style.font.bodySmall + } + + Rectangle { + anchors.left: lifeLabel.right + anchors.right: lifePercent.left + anchors.leftMargin: Style.space(12) + anchors.rightMargin: Style.space(12) + anchors.verticalCenter: parent.verticalCenter + height: Style.space(6) + radius: Style.cornerRadius > 0 ? height / 2 : 0 + color: Qt.rgba(root.contentForeground.r, root.contentForeground.g, root.contentForeground.b, 0.12) + + Rectangle { + width: Math.round(parent.width * root.lifeDone) + height: parent.height + radius: parent.radius + color: Style.selectedStateColor(root.contentForeground, Color.accent) + + Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } + } + } + + TapHandler { + onDoubleTapped: root.clearLife() + } + + MouseArea { + id: lifeMouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + + PanelToolTip { + visible: lifeMouse.containsMouse + text: "Memento Mori" + fontFamily: root.contentFontFamily + } + } + } + } + + // ---- Month grid: week numbers down a gutter on the left, then + // the seven day columns. Always six rows, so the popup is + // exactly as tall in February as it is in August. + 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/shell/plugins/panels/weather/BarWidget.qml b/shell/plugins/panels/weather/BarWidget.qml index 667e9905..845c23d3 100644 --- a/shell/plugins/panels/weather/BarWidget.qml +++ b/shell/plugins/panels/weather/BarWidget.qml @@ -12,6 +12,7 @@ BarWidget { if ("bar" in target) target.bar = root.bar if ("settings" in target) target.settings = root.settings if ("anchorItem" in target) target.anchorItem = button + if ("hostWidget" in target) target.hostWidget = root } function refresh() { @@ -36,6 +37,15 @@ BarWidget { if (panelLoader.item && panelLoader.item.close) panelLoader.item.close() } + // Forwarded so this widget can stand in for the panel as the bar's popout + // identity: Bar.requestPopout prefers closeForPopoutSwitch over close, and + // KeyboardPanel reads popoutSwitchClosing back off its owner. + readonly property bool popoutSwitchClosing: panelLoader.item ? panelLoader.item.popoutSwitchClosing === true : false + + function closeForPopoutSwitch() { + if (panelLoader.item) panelLoader.item.closeForPopoutSwitch() + } + visible: panelLoader.item && panelLoader.item.label !== "" implicitWidth: button.implicitWidth implicitHeight: button.implicitHeight diff --git a/shell/plugins/panels/weather/Panel.qml b/shell/plugins/panels/weather/Panel.qml index a4f86c82..3757d942 100644 --- a/shell/plugins/panels/weather/Panel.qml +++ b/shell/plugins/panels/weather/Panel.qml @@ -15,6 +15,14 @@ Panel { property var anchorItem: null property bool openedFromHotkey: false + // The bar tracks the widget mounted in its slot — BarWidget.qml — not this + // nested panel. Everything the bar identifies a panel by has to be that + // widget: the popout coordinator (and with it the open-panel dot under the + // pill) compares against `slot.activeItem`, and switchPanelFrom looks the + // slot up the same way. + property var hostWidget: null + readonly property var barIdentity: hostWidget || root + function open() { openedFromHotkey = false setCenterHoverRevealSuppressed(false) @@ -25,10 +33,17 @@ Panel { function openFromHotkey() { openedFromHotkey = true - setCenterHoverRevealSuppressed(true) root.controller.show() locationFile.reload() root.refresh() + // Set after showing, not before: showing hands the popout coordinator + // over, which closes whichever panel was open, and that close clears the + // shared flag. Deferring means the panel taking over always wins, while + // a handoff to a panel that does not manage the flag still leaves it + // cleared rather than stuck on. + Qt.callLater(function() { + if (root.opened) setCenterHoverRevealSuppressed(true) + }) } function close() { @@ -42,6 +57,12 @@ Panel { else root.openFromHotkey() } + function switchPanel(direction) { + if (root.bar && typeof root.bar.switchPanelFrom === "function") + return root.bar.switchPanelFrom(root.barIdentity, direction) + return false + } + function setCenterHoverRevealSuppressed(value) { if (root.bar && "centerHoverRevealSuppressed" in root.bar) root.bar.centerHoverRevealSuppressed = value @@ -440,7 +461,7 @@ Panel { KeyboardPanel { id: panel anchorItem: root.anchorItem - owner: root + owner: root.barIdentity bar: root.bar open: root.opened centerOnBar: true diff --git a/test/shell.d/bar-test.sh b/test/shell.d/bar-test.sh index 99cb26cd..5bd64d5e 100644 --- a/test/shell.d/bar-test.sh +++ b/test/shell.d/bar-test.sh @@ -15,7 +15,55 @@ fi pass "bar move outline has no post-release settling state" run_node_test <<'JS' +const fs = require('fs') const bar = requireFromRoot('shell/plugins/bar/BarModel.js') +const barSource = fs.readFileSync(root + '/shell/plugins/bar/Bar.qml', 'utf8') + +// The center section declares two arrangements and shows one; the hidden one +// must not build its modules or every center widget exists twice. +const moduleList = barSource.slice(barSource.indexOf('component ModuleList'), barSource.indexOf('component ModuleSlot')) +assert( + /active: visible && entries\.length > 0/.test(moduleList), + 'bar builds only the module list it is showing' +) + +// A center module is mounted twice — drawn copy plus zero-size placeholder — +// and the order they register in is not stable across a live reconfiguration, +// so panel routing has to pick the one that is actually on screen. +const drawn = { moduleName: 'omarchy.clock', visible: true, width: 28, height: 81 } +const placeholder = { moduleName: 'omarchy.clock', visible: false, width: 0, height: 0 } +assertEqual(bar.isDrawnSlot(drawn), true, 'bar recognises a drawn slot') +assertEqual(bar.isDrawnSlot(placeholder), false, 'bar recognises a layout placeholder') +assertEqual(bar.pickDrawnSlot([placeholder, drawn]), drawn, 'bar picks the drawn slot when the placeholder registers first') +assertEqual(bar.pickDrawnSlot([drawn, placeholder]), drawn, 'bar picks the drawn slot when it registers first') +assertEqual(bar.pickDrawnSlot([placeholder]), placeholder, 'bar falls back to the placeholder when nothing is drawn') +assertEqual(bar.pickDrawnSlot([]), null, 'bar reports no slot when there are none') +assertEqual(bar.pickDrawnSlot(null), null, 'bar tolerates a missing slot list') +assert( + /BarModel\.pickDrawnSlot\(candidates\)/.test(barSource), + 'bar routes panels through the drawn-slot picker' +) + +// The open-panel mark sits on the module's desktop-facing edge at every +// position: under a top bar, over a bottom one, inward from left and right. +const indicator = barSource.slice(barSource.indexOf('id: openPanelIndicator'), barSource.indexOf('id: openPanelIndicator') + 1600) +assert( + /x: root\.vertical\s*\n\s*\? \(root\.position === "left" \? parent\.width - width - inset : inset\)/.test(indicator), + 'bar pins the open-panel mark to the desktop-facing edge on vertical bars' +) +assert( + /root\.position === "top" \? parent\.height - height - inset : inset/.test(indicator), + 'bar pins the open-panel mark to the desktop-facing edge on horizontal bars' +) +assert( + /key in activeItem/.test(barSource), + 'bar asks whether a widget declares an indicator hint before reading it' +) +assert( + /width: root\.vertical \? Style\.space\(2\) : slot\.panelIndicatorExtent/.test(indicator) && + /height: root\.vertical \? slot\.panelIndicatorExtent : Style\.space\(2\)/.test(indicator), + 'bar sizes the open-panel mark from the same content hint on both axes' +) assertEqual(bar.normalizePosition('left'), 'left', 'bar accepts valid positions') assertEqual(bar.normalizePosition('sideways'), 'top', 'bar defaults invalid positions') diff --git a/test/shell.d/clock-test.sh b/test/shell.d/clock-test.sh new file mode 100755 index 00000000..ac9ea572 --- /dev/null +++ b/test/shell.d/clock-test.sh @@ -0,0 +1,218 @@ +#!/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') + +// ---- memento mori +// A birth year rather than an age, so the bar keeps counting on its own. +assertEqual(calendar.parseBirthYear('1979', 2026), 1979, 'calendar reads a birth year') +assertEqual(calendar.parseBirthYear(1979, 2026), 1979, 'calendar reads a numeric birth year') +assertEqual(calendar.parseBirthYear(' 1979 ', 2026), 1979, 'calendar trims a birth year') +assertEqual(calendar.parseBirthYear('2026', 2026), 2026, 'calendar accepts the current year') +assertEqual(calendar.parseBirthYear('2027', 2026), 0, 'calendar rejects a birth year in the future') +assertEqual(calendar.parseBirthYear('1905', 2026), 0, 'calendar rejects an implausibly distant birth year') +assertEqual(calendar.parseBirthYear('79', 2026), 0, 'calendar rejects a two-digit year') +assertEqual(calendar.parseBirthYear('', 2026), 0, 'calendar treats a blank birth year as unset') +assertEqual(calendar.parseBirthYear('abc', 2026), 0, 'calendar treats a non-numeric birth year as unset') + +assertEqual(calendar.ageFromBirthYear('1979', 2026), 47, 'calendar derives an age from a birth year') +assertEqual(calendar.ageFromBirthYear('1979', 2027), 48, 'calendar keeps counting as the years pass') +assertEqual(calendar.ageFromBirthYear('2026', 2026), 0, 'calendar makes someone born this year zero') +assertEqual(calendar.ageFromBirthYear('', 2026), 0, 'calendar derives no age without a birth year') + +assertEqual(calendar.parseAge('47'), 47, 'calendar reads an age') +assertEqual(calendar.parseAge(47), 47, 'calendar reads a numeric age') +assertEqual(calendar.parseAge(' 47 '), 47, 'calendar trims an age') +assertEqual(calendar.parseAge(''), 0, 'calendar treats a blank age as unset') +assertEqual(calendar.parseAge('0'), 0, 'calendar treats zero as unset') +assertEqual(calendar.parseAge('-3'), 0, 'calendar treats a negative age as unset') +assertEqual(calendar.parseAge('121'), 0, 'calendar treats an implausible age as unset') +assertEqual(calendar.parseAge('abc'), 0, 'calendar treats a non-numeric age as unset') +assertEqual(calendar.parseAge('4.5'), 0, 'calendar treats a fractional age as unset') +assertEqual(calendar.parseLifeExpectancy('65'), 65, 'calendar reads a life expectancy') +assertEqual(calendar.parseLifeExpectancy(''), 90, 'calendar defaults the life expectancy to ninety') +assertEqual(calendar.parseLifeExpectancy(0), 90, 'calendar defaults an unset life expectancy') +assertEqual(calendar.parseLifeExpectancy('abc'), 90, 'calendar defaults a non-numeric life expectancy') +assertEqual(calendar.parseLifeExpectancy('200'), 90, 'calendar defaults an implausible life expectancy') + +assertEqual(calendar.lifeProgressPercent(45, 90), 50, 'calendar measures a life against the expectancy given') +assertEqual(calendar.lifeProgressPercent(45, 65), 69, 'calendar honours a shorter expectancy') +assertEqual(calendar.lifeProgressPercent(45, ''), 50, 'calendar falls back to ninety when no expectancy is set') +assertEqual(calendar.lifeProgressPercent(90, 90), 100, 'calendar fills the life bar at the expectancy') +assertEqual(calendar.lifeProgressPercent(80, 65), 100, 'calendar never overfills the life bar') +assertEqual(calendar.lifeProgressPercent(0, 90), 0, 'calendar leaves the life bar empty when no age is set') +assertEqual(calendar.daysInYear(2024), 366, 'calendar knows leap years are longer') +assertEqual(calendar.daysInYear(2026), 365, 'calendar knows common years') + +// ---- 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) && /persistSettings\(\{ weekStartDay:/.test(panelSource), 'calendar reads and writes the week start as weekStartDay') +assert(/updateEntryInline/.test(panelSource), 'calendar panel persists the week start to shell.json') +assert(/function moveMonth\(delta\)/.test(panelSource), 'calendar panel steps between months') +assert(!/property bool onToday/.test(panelSource) && !/root\.onToday/.test(panelSource), 'calendar panel avoids the on-prefixed property name QML reads as a signal handler') +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(/Qt\.callLater\(function\(\) \{\s*\n\s*if \(root\.opened\) setCenterHoverRevealSuppressed\(true\)/.test(panelSource), 'calendar claims the shared hover-reveal flag after the popout handoff, so the panel taking over wins') +assert(/function close\(\) \{\s*\n\s*setCenterHoverRevealSuppressed\(false\)/.test(panelSource), 'calendar always releases the shared hover-reveal flag on close') +assert(/width: Math\.max\(calendarScroll\.width, gridColumn\.width\)/.test(panelSource), 'calendar scrolls rather than clipping the grid on a narrow popup') +assert(/enabled: !root\.viewingCurrentMonth/.test(panelSource) && /onClicked: root\.goToToday\(\)/.test(panelSource), 'calendar hero returns to today once the view has stepped away') +assert(!/clampMonth/.test(panelSource), 'calendar steps freely into future months') +assert(/Qt\.formatDate\(root\.today, "MMMM d"\)/.test(panelSource), 'calendar hero spells out today') +assert(/id: yearLabel/.test(panelSource) && /root\.yearDone/.test(panelSource), 'calendar panel shows the year progress bar') + +// The memento mori bar is opt-in: double-tapping the year bar asks for an age, +// and nothing shows until one has been given. +assert(/onDoubleTapped: root\.startEditingLife\(\)/.test(panelSource), 'calendar asks for an age when the year bar is double-tapped') +assert(/persistSettings\(\{ birthYear: born, lifeExpectancy: span \}\)/.test(panelSource), 'calendar saves birth year and expectancy together, so neither lands on a stale copy') +assert(/readonly property int birthYear: Model\.parseBirthYear\(setting\("birthYear", 0\)/.test(panelSource), 'calendar reads the saved birth year back') +assert(/readonly property int age: Model\.ageFromBirthYear\(birthYear/.test(panelSource), 'calendar derives the age from the stored birth year') +assert(/readonly property int lifeExpectancy: Model\.parseLifeExpectancy\(setting\("lifeExpectancy", 0\)\)/.test(panelSource), 'calendar reads the saved expectancy back') +assert(/id: expectancyField/.test(panelSource) && /id: bornField/.test(panelSource), 'calendar offers both inputs') +assert(/visible: root\.editingLife\s*\n\s*anchors\.horizontalCenter: parent\.horizontalCenter/.test(panelSource), 'calendar centers the inputs over the bar they replace') +assert(/visible: root\.birthYear > 0/.test(panelSource), 'calendar hides the life bar until a birth year is known') +assert(/text: "LIFE"/.test(panelSource) && /root\.lifeDone/.test(panelSource), 'calendar shows the life bar') +assert(/text: "Memento Mori"/.test(panelSource), 'calendar names the life bar on hover') +assert(/onDoubleTapped: root\.clearLife\(\)/.test(panelSource), 'calendar puts the life bar away when it is double-tapped') +assert(/persistSettings\(\{ birthYear: 0 \}\)/.test(panelSource), 'calendar clears the birth year to hide the life bar') +assertEqual(calendar.parseBirthYear(0, 2026), 0, 'a cleared birth year reads back as unset') +assert(/blocked: root\.editingLife/.test(panelSource), 'calendar lets the inputs have the keyboard while they are up') +assert(/if \(root\.editingLife\) root\.cancelEditingLife\(\)/.test(panelSource), 'calendar drops a half-finished edit when the panel closes') + +assert(/source: Qt\.resolvedUrl\("Panel\.qml"\)/.test(widgetSource), 'clock widget hosts the calendar panel') +assert(/readonly property bool opened:/.test(widgetSource), 'clock widget exposes the panel open state to shell routing') +assert(/Qt\.RightButton\) root\.cycleFormat\(\)/.test(widgetSource), 'clock right click cycles the label format') +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" diff --git a/test/shell.d/plugin-clone-test.sh b/test/shell.d/plugin-clone-test.sh new file mode 100755 index 00000000..9bafe0b1 --- /dev/null +++ b/test/shell.d/plugin-clone-test.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command jq +require_command python3 + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT +mkdir -p "$TMPDIR/home/.config/omarchy" + +clone() { + HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" PATH="$ROOT/bin:$PATH" \ + omarchy-plugin-clone "$1" "$2" --name "$3" +} + +# A bar widget that pulls in a sibling JS module clones into a directory that +# does not contain it, so the import has to be rewritten back to the bundled +# file or the cloned widget fails to load. +clone omarchy.clock local.clock-clone "Cloned Clock" >/dev/null +widget="$TMPDIR/home/.config/omarchy/plugins/local.clock-clone/Widget.qml" + +[[ -f $widget ]] || fail "clone produces a widget file" +pass "clone produces a widget file" + +grep -q 'moduleName: "local.clock-clone"' "$widget" || + fail "clone rewrites the module name" +pass "clone rewrites the module name" + +while read -r ref; do + [[ $ref == file://* ]] || fail "clone leaves a relative reference behind" "$ref" +done < <(grep -oE '(import|Qt\.resolvedUrl\() *"[^"]+"' "$widget" | + grep -oE '"[^"]+"' | tr -d '"' | grep -E '\.(js|qml)$') +pass "clone resolves every relative QML and JS reference to the bundled file" + +for ref in $(grep -oE 'file://[^"]+\.(js|qml)' "$widget"); do + path=${ref#file://} + [[ -f $path ]] || fail "cloned reference points at a real file" "$ref" +done +pass "cloned references point at files that exist" + +# The bundled widget genuinely has such an import, so the check above is not +# passing by accident. +grep -qE '^import "[^"/][^"]*\.js"' "$ROOT/shell/plugins/panels/clock/BarWidget.qml" || + fail "the clock widget still imports a sibling JS module" +pass "the clock widget still imports a sibling JS module" diff --git a/test/shell.d/weather-test.sh b/test/shell.d/weather-test.sh index 90365e2d..bc94c11b 100644 --- a/test/shell.d/weather-test.sh +++ b/test/shell.d/weather-test.sh @@ -8,6 +8,7 @@ run_node_test <<'JS' const fs = require('fs') const weather = requireFromRoot('shell/plugins/panels/weather/Model.js') const panelSource = fs.readFileSync(root + '/shell/plugins/panels/weather/Panel.qml', 'utf8') +const widgetSource = fs.readFileSync(root + '/shell/plugins/panels/weather/BarWidget.qml', 'utf8') assertDeepEqual( weather.parseWeatherStatus('{"text":"☀","class":"sunny"}'), @@ -115,6 +116,30 @@ assertEqual(weather.currentIcon({ openMeteoWeatherCode: 0, isDay: 0 }, ''), weat assert(weather.iconForOpenMeteoCode(45, true) !== weather.iconForOpenMeteoCode(45, false), 'weather distinguishes nighttime fog from daytime fog') assertEqual(weather.provisionalCurrentIcon({ weatherCode: 113 }, ''), weather.iconForCode(113, false), 'weather uses wttr to fill an empty initial icon') assertEqual(weather.provisionalCurrentIcon({ weatherCode: 113 }, 'night'), 'night', 'weather refresh preserves a resolved day-night icon') +// The bar identifies a panel by the widget in its slot, so the nested panel +// has to present the host widget rather than itself — otherwise the +// open-panel dot never lights and Tab cannot leave the panel. +assert( + panelSource.includes('owner: root.barIdentity'), + 'weather panel gives the bar its host widget as popout identity' +) +assert( + panelSource.includes('switchPanelFrom(root.barIdentity, direction)'), + 'weather panel switches panels as its host widget' +) +assert( + widgetSource.includes('target.hostWidget = root'), + 'weather widget injects itself as the panel host' +) +assert( + widgetSource.includes('readonly property bool popoutSwitchClosing:') && widgetSource.includes('function closeForPopoutSwitch()'), + 'weather widget forwards the popout-switch handshake' +) +assert( + /Qt\.callLater\(function\(\) \{\s*\n\s*if \(root\.opened\) setCenterHoverRevealSuppressed\(true\)/.test(panelSource), + 'weather claims the shared hover-reveal flag after the popout handoff, so the panel taking over wins' +) + assert( panelSource.includes('text: root.label || "—"'), 'weather hero and bar use the same resolved icon'