Clock bar: an Omarchy 4 bottom bar of world clocks

A `panel` shell plugin drawing its own bottom layer-shell surface, one per
screen, with an exclusive zone so Hyprland tiles above it. Clocks are laid out
as a single timeline around local time: side and position derive from each
zone's live UTC offset, so config order is irrelevant and a DST shift re-sorts
the bar on its own.

Qt's QML engine ships no `Intl`, so an IANA zone cannot be rendered from inside
QML at all. Offsets come from the system's own tzdata via `date +%z` and the
arithmetic is done here, which stays correct across DST and tzdata updates
without encoding any of the rules. The probe runs on a slow timer and on hour
boundaries; the minute tick advances the clocks in between.

Config lives in ~/.config/omarchy/clockbar.json and is edited either by hand or
through a click-to-edit popup with a timezone picker. Both routes write the same
file and the bar reloads from it, so they cannot disagree.

Failures are shown rather than papered over: an unresolvable zone renders
`LABEL --:--` in the urgent colour instead of silently falling back to UTC, and
an unparseable config reddens the bar's hairline and flags the local clock while
the last good config keeps ticking.

Ordering, formatting, day offsets and working hours live in ClockModel.js, which
touches no QML types and is covered by tests that run under node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MhxtL7WzL5XmuTd8GHbcTY
This commit is contained in:
Harvey
2026-09-04 17:49:40 +02:00
co-authored by Claude Opus 5
commit b5f4569233
9 changed files with 1479 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
import QtQuick
import Quickshell.Io
import "ClockModel.js" as Model
// Supplies the two things QML cannot work out for itself: the current UTC
// offset of each configured zone, and the list of zones the picker can offer.
//
// Qt's QML engine ships no `Intl`, so there is no in-process way to render an
// IANA zone. Rather than bundle a copy of the tz database, this asks the
// system one through `date`, which keeps the bar correct across DST changes
// and tzdata updates without any code of ours needing to know the rules.
//
// The probe is cheap (one short-lived shell, a few `date` calls) and its
// result stays valid until a transition, so it runs on a slow timer instead of
// on every tick — the clocks themselves are advanced by arithmetic in between.
QtObject {
id: root
// zone name -> "+0200". A zone absent from here failed to resolve and is
// rendered as unknown rather than being silently shown as UTC.
property var offsets: ({})
property string localOffset: ""
property var allZones: []
property bool ready: false
property var zones: []
signal updated()
// Skipping zones tzdata does not have keeps a typo out of the output
// entirely, which is what lets the model tell "unknown zone" apart from
// "genuinely UTC". Zone names arrive as argv, never interpolated into the
// script, and the model has already filtered them to the tzdata name shape.
readonly property string probeScript:
'printf "%s\\t%s\\n" LOCAL "$(date +%z)"\n' +
'for z in "$@"; do\n' +
' [ -e "/usr/share/zoneinfo/$z" ] || continue\n' +
' printf "%s\\t%s\\n" "$z" "$(TZ="$z" date +%z)"\n' +
'done'
// A probe requested while one is in flight is remembered, not dropped.
// Config changes arrive as a burst of binding updates, so the request that
// actually carries the newly added zone is usually not the first one — and
// dropping it left that clock reading "--:--" until the ten-minute timer
// came round.
property bool probePending: false
function probe() {
if (probeProc.running) {
root.probePending = true
return
}
root.probePending = false
probeProc.command = ["sh", "-c", root.probeScript, "sh"].concat(root.zones)
probeProc.running = true
}
onZonesChanged: probe()
property Process probeProc: Process {
// Deferred so `running` has settled to false before the next probe starts.
onExited: if (root.probePending) Qt.callLater(root.probe)
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var report = Model.parseOffsetReport(text)
var local = report["LOCAL"]
delete report["LOCAL"]
if (local) root.localOffset = local
root.offsets = report
root.ready = true
root.updated()
}
}
}
property Process zoneListProc: Process {
command: ["sh", "-c",
// timedatectl is the canonical list; the zoneinfo walk is the fallback
// for a system where systemd is not answering.
"timedatectl list-timezones 2>/dev/null || " +
"find /usr/share/zoneinfo -type f -printf '%P\\n' 2>/dev/null | grep -E '^[A-Z][A-Za-z_+-]*/' | sort"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var out = []
var lines = String(text || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].replace(/^\s+|\s+$/g, "")
if (line) out.push(line)
}
root.allZones = out
}
}
}
// DST transitions land on the hour, so a re-probe shortly after every hour
// boundary catches one within a minute; the ten-minute tick is the safety
// net for tzdata updates and suspend/resume.
property Timer refreshTimer: Timer {
interval: 10 * 60 * 1000
repeat: true
running: true
onTriggered: root.probe()
}
Component.onCompleted: {
zoneListProc.running = true
probe()
}
}