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