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
+159
View File
@@ -0,0 +1,159 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "ClockModel.js" as Model
// Owns ~/.config/omarchy/clockbar.json: reads it, watches it, writes it back
// when the editor saves.
//
// The file is the source of truth in both directions — the editor writes it
// and the watcher picks the change straight back up, so a hand-edit and an
// edit through the popup are the same operation as far as the bar is
// concerned. That is what makes the two config routes impossible to
// desynchronise.
QtObject {
id: root
readonly property string home: Quickshell.env("HOME")
readonly property string path: home + "/.config/omarchy/clockbar.json"
property var config: defaults()
property bool loaded: false
// Set when the file on disk is present but unparseable. The bar keeps
// rendering the last good config and says so, rather than emptying itself
// and losing the user's clocks behind a stray comma.
property string parseError: ""
signal changed()
function defaults() {
return {
version: 1,
clocks: [
{ label: "LA", timezone: "America/Los_Angeles" },
{ label: "NYC", timezone: "America/New_York" },
{ label: "SG", timezone: "Asia/Singapore" },
{ label: "AUS", timezone: "Australia/Sydney" },
{ label: "NZ", timezone: "Pacific/Auckland" }
],
workingHours: { start: 8, end: 18, workdays: [1, 2, 3, 4, 5] },
dimOutsideWorkingHours: true,
showSeconds: false,
barSize: 0
}
}
// Every field is coerced rather than trusted, so a half-finished hand-edit
// degrades to the default for that one key instead of breaking the bar.
function normalize(raw) {
var base = defaults()
if (!raw || typeof raw !== "object") return base
var out = base
if (Array.isArray(raw.clocks)) {
var clocks = []
for (var i = 0; i < raw.clocks.length; i++) {
var entry = raw.clocks[i]
if (!entry || typeof entry !== "object") continue
var tz = String(entry.timezone || "").replace(/^\s+|\s+$/g, "")
if (!tz) continue
var label = String(entry.label === undefined || entry.label === null ? "" : entry.label).replace(/^\s+|\s+$/g, "")
clocks.push({ label: label || tz, timezone: tz })
}
out.clocks = clocks
}
var wh = raw.workingHours
if (wh && typeof wh === "object") {
var start = Number(wh.start)
var end = Number(wh.end)
if (isFinite(start) && start >= 0 && start <= 24) out.workingHours.start = start
if (isFinite(end) && end >= 0 && end <= 24) out.workingHours.end = end
if (Array.isArray(wh.workdays)) {
var days = []
for (var d = 0; d < wh.workdays.length; d++) {
var n = Number(wh.workdays[d])
if (isFinite(n) && n >= 0 && n <= 6 && days.indexOf(n) === -1) days.push(n)
}
if (days.length) out.workingHours.workdays = days
}
}
if (typeof raw.dimOutsideWorkingHours === "boolean") out.dimOutsideWorkingHours = raw.dimOutsideWorkingHours
if (typeof raw.showSeconds === "boolean") out.showSeconds = raw.showSeconds
var size = Number(raw.barSize)
if (isFinite(size) && size >= 0 && size <= 200) out.barSize = size
return out
}
// Applying what was read. This path NEVER writes.
//
// FileView can emit `loaded` more than once while starting up, and the early
// emission can carry empty text before the read has actually landed. Seeding
// defaults from that and saving them destroyed the real file — the user's
// clocks were replaced by the stock five on the next shell restart. So an
// empty or unparseable read now leaves both the file and the last good
// config alone, and only a confirmed missing file seeds anything.
function apply(raw) {
var text = String(raw || "").replace(/^\s+|\s+$/g, "")
if (!text) {
root.loaded = true
return
}
var parsed
try {
parsed = JSON.parse(text)
} catch (e) {
root.parseError = String(e.message || e)
console.warn("clockbar: " + root.path + " is not valid JSON, keeping the last good config: " + root.parseError)
root.loaded = true
return
}
root.parseError = ""
root.config = normalize(parsed)
root.loaded = true
root.changed()
}
// The one place defaults are written, and only when the file genuinely is
// not there. A permissions error or an unknown failure must not be answered
// by overwriting whatever is on disk.
function seed() {
root.config = defaults()
root.parseError = ""
root.loaded = true
save(root.config)
}
function save(next) {
var clean = normalize(next)
root.config = clean
file.setText(JSON.stringify(clean, null, 2) + "\n")
root.parseError = ""
root.changed()
}
property FileView file: FileView {
path: root.path
watchChanges: true
atomicWrites: true
printErrors: false
onLoaded: root.apply(text())
onLoadFailed: function (error) {
if (error === FileViewError.FileNotFound) {
// First run. Seed the file so the user has something to edit and so
// the watcher has a real file to follow from here on.
root.seed()
return
}
console.warn("clockbar: could not read " + root.path + ": " + FileViewError.toString(error))
root.loaded = true
}
onFileChanged: reload()
}
Component.onCompleted: Qt.callLater(function () { root.file.reload() })
}