Merge pull request #6390 from basecamp/clock-calendar

Add a calendar popup to the clock
This commit is contained in:
David Heinemeier Hansson
2026-07-26 22:11:29 -07:00
committed by GitHub
18 changed files with 1657 additions and 104 deletions
+13
View File
@@ -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.
+1
View File
@@ -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" })
+3
View File
@@ -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)
+2 -1
View File
@@ -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
+28 -4
View File
@@ -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)
+24
View File
@@ -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,
+2 -2
View File
@@ -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
-92
View File
@@ -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
}
}
}
}
}
+175
View File
@@ -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
}
}
}
}
}
+285
View File
@@ -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
}
}
+749
View File
@@ -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)
}
}
}
}
}
}
}
}
@@ -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
}
@@ -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
+23 -2
View File
@@ -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
+48
View File
@@ -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')
+218
View File
@@ -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"
+48
View File
@@ -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"
+25
View File
@@ -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'