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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user