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,2 @@
|
|||||||
|
# Screenshots and scratch output, not part of the plugin
|
||||||
|
*.png
|
||||||
+219
@@ -0,0 +1,219 @@
|
|||||||
|
// Pure layout/formatting logic for the clock bar.
|
||||||
|
//
|
||||||
|
// Deliberately free of QML types so it can be unit-tested under node (see
|
||||||
|
// tests/model.test.js). The QML side owns IO — reading config, probing tzdata,
|
||||||
|
// ticking the clock — and hands the results in here as plain values.
|
||||||
|
//
|
||||||
|
// Everything is driven by UTC offsets rather than by conversion, because Qt's
|
||||||
|
// QML engine has no `Intl`, so there is no way to render an IANA zone from
|
||||||
|
// inside QML. Offsets come from `date +%z` against the system tzdata, which
|
||||||
|
// makes them DST-correct for the moment they were probed; the bar re-probes
|
||||||
|
// often enough that a transition is picked up within minutes.
|
||||||
|
|
||||||
|
var MINUTES = 60 * 1000
|
||||||
|
|
||||||
|
// "+0200" / "-0730" / "+02" -> minutes east of UTC. null when unparseable, so
|
||||||
|
// a zone that tzdata did not recognise can be shown as broken rather than
|
||||||
|
// silently rendered as UTC.
|
||||||
|
function parseOffset(raw) {
|
||||||
|
var s = String(raw === undefined || raw === null ? "" : raw).replace(/^\s+|\s+$/g, "")
|
||||||
|
var m = s.match(/^([+-])(\d{2}):?(\d{2})?$/)
|
||||||
|
if (!m) return null
|
||||||
|
var sign = m[1] === "-" ? -1 : 1
|
||||||
|
var hours = parseInt(m[2], 10)
|
||||||
|
var mins = m[3] ? parseInt(m[3], 10) : 0
|
||||||
|
if (!isFinite(hours) || !isFinite(mins) || mins > 59) return null
|
||||||
|
return sign * (hours * 60 + mins)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wall clock in a zone, expressed as a Date whose *UTC* fields carry that
|
||||||
|
// zone's local time. Reading it back with getUTC* keeps the arithmetic in one
|
||||||
|
// frame of reference no matter what the host machine's own zone is.
|
||||||
|
function wallClock(utcMs, offsetMinutes) {
|
||||||
|
return new Date(utcMs + offsetMinutes * MINUTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(n) {
|
||||||
|
return (n < 10 ? "0" : "") + n
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(wall, withSeconds) {
|
||||||
|
var out = pad2(wall.getUTCHours()) + ":" + pad2(wall.getUTCMinutes())
|
||||||
|
return withSeconds ? out + ":" + pad2(wall.getUTCSeconds()) : out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whole-day difference between two wall clocks, from their calendar dates
|
||||||
|
// alone. Comparing the dates rather than subtracting the times means a clock
|
||||||
|
// 30 minutes and one day apart still reports exactly 1.
|
||||||
|
function dayDelta(wall, localWall) {
|
||||||
|
var a = Date.UTC(wall.getUTCFullYear(), wall.getUTCMonth(), wall.getUTCDate())
|
||||||
|
var b = Date.UTC(localWall.getUTCFullYear(), localWall.getUTCMonth(), localWall.getUTCDate())
|
||||||
|
return Math.round((a - b) / (24 * 60 * MINUTES))
|
||||||
|
}
|
||||||
|
|
||||||
|
// "+1" / "-1" / "" — shown to the right of the time. Anything beyond a single
|
||||||
|
// day is still rendered honestly (no real zone pair reaches it, but a bad
|
||||||
|
// hand-edited offset should not silently truncate).
|
||||||
|
function dayMarker(delta) {
|
||||||
|
if (!delta) return ""
|
||||||
|
return (delta > 0 ? "+" : "-") + Math.abs(delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWorkdays(raw) {
|
||||||
|
if (!raw || !raw.length) return [1, 2, 3, 4, 5]
|
||||||
|
var out = []
|
||||||
|
for (var i = 0; i < raw.length; i++) {
|
||||||
|
var n = Number(raw[i])
|
||||||
|
if (isFinite(n) && n >= 0 && n <= 6 && out.indexOf(n) === -1) out.push(n)
|
||||||
|
}
|
||||||
|
return out.length ? out : [1, 2, 3, 4, 5]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Working hours are evaluated in the clock's *own* zone — the question the bar
|
||||||
|
// answers is "is this person around?", not "is this hour within my day?".
|
||||||
|
// A window that wraps midnight (22 -> 6) is treated as spanning the boundary.
|
||||||
|
function isWorkingHours(wall, hours) {
|
||||||
|
var start = hours && isFinite(Number(hours.start)) ? Number(hours.start) : 8
|
||||||
|
var end = hours && isFinite(Number(hours.end)) ? Number(hours.end) : 18
|
||||||
|
var days = normalizeWorkdays(hours && hours.workdays)
|
||||||
|
if (days.indexOf(wall.getUTCDay()) === -1) return false
|
||||||
|
|
||||||
|
var hour = wall.getUTCHours() + wall.getUTCMinutes() / 60
|
||||||
|
if (start === end) return true
|
||||||
|
if (start < end) return hour >= start && hour < end
|
||||||
|
return hour >= start || hour < end
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidLabel(label) {
|
||||||
|
return typeof label === "string" && label.replace(/^\s+|\s+$/g, "").length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// One rendered clock. `offsets` maps zone -> "+0200" as probed from tzdata;
|
||||||
|
// a zone missing from it (or unparseable) yields `known: false` so the bar can
|
||||||
|
// show the label struck through instead of quietly lying about the time.
|
||||||
|
function buildClock(entry, offsets, utcMs, localOffset, options) {
|
||||||
|
var opts = options || {}
|
||||||
|
var timezone = String(entry && entry.timezone ? entry.timezone : "")
|
||||||
|
var label = isValidLabel(entry && entry.label) ? String(entry.label).replace(/^\s+|\s+$/g, "") : timezone
|
||||||
|
var offset = parseOffset(offsets ? offsets[timezone] : null)
|
||||||
|
|
||||||
|
if (offset === null) {
|
||||||
|
return {
|
||||||
|
label: label, timezone: timezone, known: false,
|
||||||
|
time: "--:--", marker: "", delta: 0, relativeMinutes: 0, working: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var wall = wallClock(utcMs, offset)
|
||||||
|
var localWall = wallClock(utcMs, localOffset)
|
||||||
|
var delta = dayDelta(wall, localWall)
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: label,
|
||||||
|
timezone: timezone,
|
||||||
|
known: true,
|
||||||
|
time: formatTime(wall, opts.showSeconds === true),
|
||||||
|
marker: dayMarker(delta),
|
||||||
|
delta: delta,
|
||||||
|
relativeMinutes: offset - localOffset,
|
||||||
|
working: isWorkingHours(wall, opts.workingHours)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort key: how far ahead of (or behind) local this clock is. Equal offsets
|
||||||
|
// tie-break on label so the bar does not reshuffle between ticks.
|
||||||
|
function compareClocks(a, b) {
|
||||||
|
if (a.relativeMinutes !== b.relativeMinutes) return a.relativeMinutes - b.relativeMinutes
|
||||||
|
return a.label < b.label ? -1 : (a.label > b.label ? 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole bar: one continuous timeline with local time at its centre.
|
||||||
|
// Behind-local runs out to the left, ahead-of-local out to the right, each
|
||||||
|
// group ordered so that reading left to right always moves forward in time.
|
||||||
|
// Side and position fall out of the live offset, so a DST shift re-sorts the
|
||||||
|
// bar on the next probe with no config change.
|
||||||
|
function buildBar(config, offsets, utcMs, localOffsetRaw) {
|
||||||
|
var cfg = config || {}
|
||||||
|
var localOffset = parseOffset(localOffsetRaw)
|
||||||
|
if (localOffset === null) localOffset = 0
|
||||||
|
|
||||||
|
var options = {
|
||||||
|
workingHours: cfg.workingHours,
|
||||||
|
showSeconds: cfg.showSeconds === true
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries = Array.isArray(cfg.clocks) ? cfg.clocks : []
|
||||||
|
var left = []
|
||||||
|
var right = []
|
||||||
|
var unknown = []
|
||||||
|
|
||||||
|
for (var i = 0; i < entries.length; i++) {
|
||||||
|
var clock = buildClock(entries[i], offsets, utcMs, localOffset, options)
|
||||||
|
if (!clock.known) unknown.push(clock)
|
||||||
|
else if (clock.relativeMinutes < 0) left.push(clock)
|
||||||
|
else right.push(clock)
|
||||||
|
}
|
||||||
|
|
||||||
|
left.sort(compareClocks)
|
||||||
|
right.sort(compareClocks)
|
||||||
|
|
||||||
|
var localWall = wallClock(utcMs, localOffset)
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: left,
|
||||||
|
// Unresolvable zones have no place on a timeline, so they sit at the end
|
||||||
|
// of the trailing group where they are visible but never mis-ordered.
|
||||||
|
right: right.concat(unknown),
|
||||||
|
center: {
|
||||||
|
time: formatTime(localWall, options.showSeconds),
|
||||||
|
date: localWall.getUTCFullYear() + "-" + pad2(localWall.getUTCMonth() + 1) + "-" + pad2(localWall.getUTCDate()),
|
||||||
|
offsetMinutes: localOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every zone the bar needs an offset for, deduplicated, for the probe command.
|
||||||
|
function zonesToProbe(config) {
|
||||||
|
var entries = config && Array.isArray(config.clocks) ? config.clocks : []
|
||||||
|
var seen = {}
|
||||||
|
var out = []
|
||||||
|
for (var i = 0; i < entries.length; i++) {
|
||||||
|
var tz = entries[i] && entries[i].timezone ? String(entries[i].timezone) : ""
|
||||||
|
// Zone names reach a shell command, so anything outside the tzdata naming
|
||||||
|
// shape is dropped here rather than quoted and hoped for.
|
||||||
|
if (!tz || !/^[A-Za-z0-9_+\-]+(\/[A-Za-z0-9_+\-]+)*$/.test(tz)) continue
|
||||||
|
if (seen[tz]) continue
|
||||||
|
seen[tz] = true
|
||||||
|
out.push(tz)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the probe's "zone<TAB>+0200" lines back into a lookup.
|
||||||
|
function parseOffsetReport(raw) {
|
||||||
|
var out = {}
|
||||||
|
var lines = String(raw || "").split("\n")
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
var line = lines[i].replace(/^\s+|\s+$/g, "")
|
||||||
|
if (!line) continue
|
||||||
|
var tab = line.indexOf("\t")
|
||||||
|
if (tab <= 0) continue
|
||||||
|
out[line.substring(0, tab)] = line.substring(tab + 1).replace(/^\s+|\s+$/g, "")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = {
|
||||||
|
parseOffset: parseOffset,
|
||||||
|
wallClock: wallClock,
|
||||||
|
formatTime: formatTime,
|
||||||
|
dayDelta: dayDelta,
|
||||||
|
dayMarker: dayMarker,
|
||||||
|
isWorkingHours: isWorkingHours,
|
||||||
|
buildClock: buildClock,
|
||||||
|
buildBar: buildBar,
|
||||||
|
zonesToProbe: zonesToProbe,
|
||||||
|
parseOffsetReport: parseOffsetReport
|
||||||
|
}
|
||||||
|
}
|
||||||
+266
@@ -0,0 +1,266 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import qs.Commons
|
||||||
|
import qs.Ui
|
||||||
|
import "ClockModel.js" as Model
|
||||||
|
|
||||||
|
// A second bar along the bottom edge of every screen, carrying world clocks
|
||||||
|
// laid out as a single timeline: furthest behind local time at the far left,
|
||||||
|
// furthest ahead at the far right, local time dead centre.
|
||||||
|
//
|
||||||
|
// It is a `panel` plugin rather than a set of bar widgets because it is its
|
||||||
|
// own layer-shell surface — the built-in bar owns the top edge and only one
|
||||||
|
// bar plugin is active at a time, so sharing it was never an option. Being a
|
||||||
|
// separate surface also means it reserves its own space and survives
|
||||||
|
// `omarchy refresh shell` untouched.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// Injected by the shell host when the plugin is mounted.
|
||||||
|
property string omarchyPath: ""
|
||||||
|
property var shell: null
|
||||||
|
property var manifest: null
|
||||||
|
|
||||||
|
readonly property var config: configFile.config
|
||||||
|
readonly property var bar: Model.buildBar(config, zones.offsets, root.nowMs, zones.localOffset)
|
||||||
|
|
||||||
|
property double nowMs: Date.now()
|
||||||
|
property int lastProbedHour: -1
|
||||||
|
|
||||||
|
// Follows the theme's bar height unless the config pins one, so the two bars
|
||||||
|
// stay the same weight when the theme or font size changes.
|
||||||
|
readonly property int barSize: config && config.barSize > 0 ? config.barSize : Style.bar.sizeHorizontal
|
||||||
|
|
||||||
|
readonly property color background: Color.bar.background
|
||||||
|
readonly property color foreground: Color.bar.text
|
||||||
|
readonly property color muted: Color.muted
|
||||||
|
readonly property color accent: Color.accent
|
||||||
|
readonly property color urgent: Color.urgent
|
||||||
|
|
||||||
|
readonly property int edgePadding: Style.space(12)
|
||||||
|
readonly property int clockGap: Style.space(16)
|
||||||
|
readonly property int labelGap: Style.space(6)
|
||||||
|
|
||||||
|
Config { id: configFile }
|
||||||
|
|
||||||
|
Zones {
|
||||||
|
id: zones
|
||||||
|
zones: Model.zonesToProbe(root.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minute precision is all the display needs; seconds are opt-in because
|
||||||
|
// ticking every second wakes the whole shell sixty times as often for a
|
||||||
|
// readout nobody reads that closely.
|
||||||
|
SystemClock {
|
||||||
|
id: clock
|
||||||
|
precision: root.config && root.config.showSeconds ? SystemClock.Seconds : SystemClock.Minutes
|
||||||
|
onDateChanged: {
|
||||||
|
root.nowMs = date.getTime()
|
||||||
|
// Offsets only move at a transition, and transitions land on the hour.
|
||||||
|
var hour = date.getHours()
|
||||||
|
if (hour !== root.lastProbedHour) {
|
||||||
|
root.lastProbedHour = hour
|
||||||
|
zones.probe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditor() { editorLoader.active = true; if (editorLoader.item) editorLoader.item.open() }
|
||||||
|
function closeEditor() { if (editorLoader.item) editorLoader.item.close() }
|
||||||
|
readonly property bool editorOpen: editorLoader.item ? editorLoader.item.opened : false
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
id: editorLoader
|
||||||
|
active: false
|
||||||
|
asynchronous: true
|
||||||
|
source: Qt.resolvedUrl("Editor.qml")
|
||||||
|
onLoaded: {
|
||||||
|
item.configFile = configFile
|
||||||
|
item.zones = zones
|
||||||
|
item.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "nerdnation.clockbar"
|
||||||
|
|
||||||
|
function edit(): void { root.openEditor() }
|
||||||
|
function open(): void { root.openEditor() }
|
||||||
|
function close(): void { root.closeEditor() }
|
||||||
|
function toggle(): void { root.editorOpen ? root.closeEditor() : root.openEditor() }
|
||||||
|
function refresh(): void { zones.probe() }
|
||||||
|
function reload(): void { configFile.file.reload() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- one clock
|
||||||
|
|
||||||
|
component Clock: Row {
|
||||||
|
id: entry
|
||||||
|
|
||||||
|
required property var clock
|
||||||
|
|
||||||
|
readonly property bool dimmed: root.config && root.config.dimOutsideWorkingHours
|
||||||
|
&& entry.clock.known && !entry.clock.working
|
||||||
|
readonly property color tint: !entry.clock.known ? root.urgent
|
||||||
|
: (entry.dimmed ? root.muted : root.foreground)
|
||||||
|
|
||||||
|
spacing: root.labelGap
|
||||||
|
anchors.verticalCenter: parent ? parent.verticalCenter : undefined
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: entry.clock.label
|
||||||
|
color: entry.tint
|
||||||
|
// The label is a fixed caption next to a changing number, so it sits a
|
||||||
|
// step down the scale — present for identification, not competing with
|
||||||
|
// the time for the glance.
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
// Held back from the time it captions, but only once: a dimmed clock is
|
||||||
|
// already carrying the muted tint, and compounding the two put its label
|
||||||
|
// under the legibility floor.
|
||||||
|
opacity: entry.dimmed || !entry.clock.known ? 1.0 : 0.75
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: entry.clock.time
|
||||||
|
color: entry.tint
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
// Collapsed rather than blanked, so a clock with no marker does not
|
||||||
|
// carry an empty slot plus its Row spacing. The group re-flows when a
|
||||||
|
// marker appears at midnight, which is the cheaper trade against dead
|
||||||
|
// width on every clock, every day.
|
||||||
|
visible: entry.clock.marker !== ""
|
||||||
|
text: entry.clock.marker
|
||||||
|
color: entry.tint
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
opacity: 0.7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ the surface
|
||||||
|
|
||||||
|
component ClockPanel: PanelWindow {
|
||||||
|
id: panel
|
||||||
|
|
||||||
|
color: root.background
|
||||||
|
// Bottom edge, full width, reserving its own height so Hyprland tiles
|
||||||
|
// windows above it exactly as it does for the top bar.
|
||||||
|
anchors { bottom: true; left: true; right: true }
|
||||||
|
implicitHeight: root.barSize
|
||||||
|
exclusionMode: ExclusionMode.Auto
|
||||||
|
surfaceFormat.opaque: false
|
||||||
|
WlrLayershell.namespace: "nerdnation-clockbar"
|
||||||
|
WlrLayershell.layer: WlrLayer.Top
|
||||||
|
|
||||||
|
ScreenMoveRemap {
|
||||||
|
id: remapGuard
|
||||||
|
window: panel
|
||||||
|
}
|
||||||
|
|
||||||
|
visible: !remapGuard.remapping
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: surfaceHover
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
onClicked: root.openEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hairline along the top edge separates the bar from the window below
|
||||||
|
// it, which otherwise meets it as one flat field of colour. It doubles as
|
||||||
|
// the config-error signal: full-width chrome is the one thing on the bar
|
||||||
|
// that cannot be overlapped by a long list of clocks.
|
||||||
|
Rectangle {
|
||||||
|
anchors { top: parent.top; left: parent.left; right: parent.right }
|
||||||
|
height: configFile.parseError !== "" ? 2 : 1
|
||||||
|
color: configFile.parseError !== "" ? root.urgent : root.foreground
|
||||||
|
opacity: configFile.parseError !== "" ? 0.9 : 0.12
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: leftGroup
|
||||||
|
anchors { left: parent.left; leftMargin: root.edgePadding; verticalCenter: parent.verticalCenter }
|
||||||
|
spacing: root.clockGap
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.bar.left
|
||||||
|
delegate: Clock {
|
||||||
|
required property var modelData
|
||||||
|
clock: modelData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local time is centred on the screen rather than between its neighbours,
|
||||||
|
// so it holds still while the groups either side change width.
|
||||||
|
Row {
|
||||||
|
id: centerGroup
|
||||||
|
anchors { horizontalCenter: parent.horizontalCenter; verticalCenter: parent.verticalCenter }
|
||||||
|
spacing: root.labelGap
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.bar.center.time
|
||||||
|
color: root.accent
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only while clockbar.json will not parse. The clocks either side are
|
||||||
|
// the last good config still ticking, which is worth keeping on screen —
|
||||||
|
// but not worth passing off as current.
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: configFile.parseError !== ""
|
||||||
|
text: "config error"
|
||||||
|
color: root.urgent
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: rightGroup
|
||||||
|
anchors { right: parent.right; rightMargin: root.edgePadding; verticalCenter: parent.verticalCenter }
|
||||||
|
spacing: root.clockGap
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.bar.right
|
||||||
|
delegate: Clock {
|
||||||
|
required property var modelData
|
||||||
|
clock: modelData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A broken config file is signalled on the hairline (see above) rather
|
||||||
|
// than in a text label. The earlier label was anchored to the right edge
|
||||||
|
// with the same margin as the right-hand clock group, so it rendered on
|
||||||
|
// top of those clocks — and any fixed position on a bar whose contents
|
||||||
|
// grow with the config has that same collision waiting for it.
|
||||||
|
}
|
||||||
|
|
||||||
|
Variants {
|
||||||
|
model: Quickshell.screens
|
||||||
|
|
||||||
|
delegate: Component {
|
||||||
|
ClockPanel {
|
||||||
|
required property var modelData
|
||||||
|
screen: modelData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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() })
|
||||||
|
}
|
||||||
+383
@@ -0,0 +1,383 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import qs.Commons
|
||||||
|
import qs.Ui
|
||||||
|
import "ClockModel.js" as Model
|
||||||
|
|
||||||
|
// The click-to-edit surface for the clock bar: add, rename, retime and remove
|
||||||
|
// clocks without touching JSON.
|
||||||
|
//
|
||||||
|
// It edits a draft copy and only writes on Save, so an abandoned edit leaves
|
||||||
|
// the file untouched — and because the write goes through the same Config
|
||||||
|
// object the file watcher feeds, the bar updates from disk either way. There
|
||||||
|
// is no second source of truth for the two config routes to disagree about.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var configFile: null
|
||||||
|
property var zones: null
|
||||||
|
|
||||||
|
readonly property bool opened: controller.open
|
||||||
|
property var draft: []
|
||||||
|
property int workStart: 8
|
||||||
|
property int workEnd: 18
|
||||||
|
property bool dimOutside: true
|
||||||
|
|
||||||
|
readonly property color background: Color.popups.background
|
||||||
|
readonly property color foreground: Color.popups.text
|
||||||
|
readonly property color muted: Color.muted
|
||||||
|
readonly property color accent: Color.accent
|
||||||
|
readonly property color urgent: Color.urgent
|
||||||
|
|
||||||
|
readonly property var zoneOptions: {
|
||||||
|
var list = root.zones ? root.zones.allZones : []
|
||||||
|
var out = []
|
||||||
|
for (var i = 0; i < list.length; i++) out.push({ value: list[i], label: list[i] })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
id: controller
|
||||||
|
property bool open: false
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
loadDraft()
|
||||||
|
controller.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() { controller.open = false }
|
||||||
|
|
||||||
|
// A deep copy: editing the live config array in place would push
|
||||||
|
// half-finished rows straight onto the bar.
|
||||||
|
function loadDraft() {
|
||||||
|
var cfg = root.configFile ? root.configFile.config : null
|
||||||
|
var clocks = cfg && Array.isArray(cfg.clocks) ? cfg.clocks : []
|
||||||
|
var copy = []
|
||||||
|
for (var i = 0; i < clocks.length; i++)
|
||||||
|
copy.push({ label: String(clocks[i].label || ""), timezone: String(clocks[i].timezone || "") })
|
||||||
|
root.draft = copy
|
||||||
|
if (cfg && cfg.workingHours) {
|
||||||
|
root.workStart = Number(cfg.workingHours.start)
|
||||||
|
root.workEnd = Number(cfg.workingHours.end)
|
||||||
|
}
|
||||||
|
root.dimOutside = cfg ? cfg.dimOutsideWorkingHours !== false : true
|
||||||
|
}
|
||||||
|
|
||||||
|
// QML only re-renders a var array when the reference changes, so every
|
||||||
|
// mutation rebuilds it rather than editing in place.
|
||||||
|
function replaceDraft(next) { root.draft = next }
|
||||||
|
|
||||||
|
function updateRow(index, key, value) {
|
||||||
|
var next = root.draft.slice()
|
||||||
|
if (!next[index]) return
|
||||||
|
var row = { label: next[index].label, timezone: next[index].timezone }
|
||||||
|
row[key] = value
|
||||||
|
next[index] = row
|
||||||
|
replaceDraft(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(index) {
|
||||||
|
var next = root.draft.slice()
|
||||||
|
next.splice(index, 1)
|
||||||
|
replaceDraft(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
var next = root.draft.slice()
|
||||||
|
next.push({ label: "", timezone: "" })
|
||||||
|
replaceDraft(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A row is only saveable once it names a zone; the label falls back to the
|
||||||
|
// zone's own name so a clock can never render blank.
|
||||||
|
function saveableRows() {
|
||||||
|
var out = []
|
||||||
|
for (var i = 0; i < root.draft.length; i++) {
|
||||||
|
var tz = String(root.draft[i].timezone || "").replace(/^\s+|\s+$/g, "")
|
||||||
|
if (!tz) continue
|
||||||
|
var label = String(root.draft[i].label || "").replace(/^\s+|\s+$/g, "")
|
||||||
|
out.push({ label: label || tz.split("/").pop().replace(/_/g, " "), timezone: tz })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
if (!root.configFile) return
|
||||||
|
var cfg = root.configFile.config
|
||||||
|
root.configFile.save({
|
||||||
|
version: 1,
|
||||||
|
clocks: saveableRows(),
|
||||||
|
workingHours: {
|
||||||
|
start: root.workStart,
|
||||||
|
end: root.workEnd,
|
||||||
|
workdays: cfg && cfg.workingHours ? cfg.workingHours.workdays : [1, 2, 3, 4, 5]
|
||||||
|
},
|
||||||
|
dimOutsideWorkingHours: root.dimOutside,
|
||||||
|
showSeconds: cfg ? cfg.showSeconds === true : false,
|
||||||
|
barSize: cfg ? cfg.barSize : 0
|
||||||
|
})
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ the surface
|
||||||
|
|
||||||
|
PanelWindow {
|
||||||
|
id: window
|
||||||
|
visible: root.opened
|
||||||
|
anchors { top: true; bottom: true; left: true; right: true }
|
||||||
|
color: "transparent"
|
||||||
|
WlrLayershell.namespace: "nerdnation-clockbar-editor"
|
||||||
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||||
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
color: Qt.rgba(0, 0, 0, 0.45)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click-away closes; the card swallows its own clicks so a click inside
|
||||||
|
// does not fall through to this one.
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
onClicked: root.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
BorderSurface {
|
||||||
|
id: card
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: Style.space(560)
|
||||||
|
height: Math.min(parent.height - Style.space(80), content.implicitHeight + card.contentTopInset + card.contentBottomInset)
|
||||||
|
radius: Style.cornerRadius
|
||||||
|
color: root.background
|
||||||
|
borderSpec: Border.localOrSurfaceSpec("popups", "border", Color.popups.border, Color.popups.border, Math.max(1, Style.space(2)))
|
||||||
|
padding: Style.spacing.popupPadding
|
||||||
|
|
||||||
|
MouseArea { anchors.fill: parent; onClicked: {} }
|
||||||
|
|
||||||
|
Item {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: card.padding
|
||||||
|
focus: true
|
||||||
|
Keys.onEscapePressed: root.close()
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: content
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: Style.space(10)
|
||||||
|
|
||||||
|
PanelSectionHeader {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Clock bar"
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Clocks are placed automatically: behind your local time to the left, ahead to the right."
|
||||||
|
color: root.muted
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- the rows
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
// Tall enough for the rows it has, up to a cap that keeps the card
|
||||||
|
// on screen with a long clock list; past that it scrolls.
|
||||||
|
Layout.preferredHeight: Math.min(rows.implicitHeight, Style.space(300))
|
||||||
|
contentHeight: rows.implicitHeight
|
||||||
|
clip: true
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: rows
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(6)
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.draft
|
||||||
|
|
||||||
|
delegate: RowLayout {
|
||||||
|
id: row
|
||||||
|
required property int index
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Style.space(8)
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
Layout.preferredWidth: Style.space(74)
|
||||||
|
text: row.modelData.label
|
||||||
|
placeholderText: "Label"
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
onEditingFinished: root.updateRow(row.index, "label", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchableDropdown {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
showLabel: false
|
||||||
|
value: row.modelData.timezone
|
||||||
|
options: root.zoneOptions
|
||||||
|
placeholderText: "Search timezones..."
|
||||||
|
triggerLabel: row.modelData.timezone || "Choose a timezone"
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
onChanged: function (value) { root.updateRow(row.index, "timezone", value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The live offset, so the effect of a choice is visible
|
||||||
|
// before saving rather than only once the bar redraws.
|
||||||
|
Text {
|
||||||
|
Layout.preferredWidth: Style.space(52)
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
text: {
|
||||||
|
var off = root.zones && root.zones.offsets ? root.zones.offsets[row.modelData.timezone] : null
|
||||||
|
return off ? off : (row.modelData.timezone ? "—" : "")
|
||||||
|
}
|
||||||
|
color: root.muted
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
iconText: ""
|
||||||
|
tooltipText: "Remove this clock"
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.urgent
|
||||||
|
onClicked: root.removeRow(row.index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: root.draft.length === 0
|
||||||
|
text: "No clocks yet — add one below."
|
||||||
|
color: root.muted
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Layout.alignment: Qt.AlignLeft
|
||||||
|
text: "+ Add clock"
|
||||||
|
bordered: true
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
onClicked: root.addRow()
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelSeparator { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
// -------------------------------------------------- working hours
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Style.space(8)
|
||||||
|
|
||||||
|
ToggleSwitch {
|
||||||
|
checked: root.dimOutside
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
onToggled: root.dimOutside = !root.dimOutside
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Dim clocks outside working hours"
|
||||||
|
color: root.foreground
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Style.space(8)
|
||||||
|
opacity: root.dimOutside ? 1.0 : 0.45
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Working hours (local to each clock)"
|
||||||
|
color: root.muted
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
|
||||||
|
NumberField {
|
||||||
|
Layout.preferredWidth: Style.spacing.numberFieldWidth
|
||||||
|
enabled: root.dimOutside
|
||||||
|
value: root.workStart
|
||||||
|
from: 0
|
||||||
|
to: 24
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
onModified: function (v) { root.workStart = v }
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "to"
|
||||||
|
color: root.muted
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
}
|
||||||
|
|
||||||
|
NumberField {
|
||||||
|
Layout.preferredWidth: Style.spacing.numberFieldWidth
|
||||||
|
enabled: root.dimOutside
|
||||||
|
value: root.workEnd
|
||||||
|
from: 0
|
||||||
|
to: 24
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
onModified: function (v) { root.workEnd = v }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelSeparator { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Style.space(8)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: root.configFile ? root.configFile.path : ""
|
||||||
|
color: root.muted
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Cancel"
|
||||||
|
bordered: true
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
onClicked: root.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Save"
|
||||||
|
bordered: true
|
||||||
|
selected: true
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
onClicked: root.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Clock bar
|
||||||
|
|
||||||
|
A second bar along the bottom edge of every screen, carrying world clocks laid
|
||||||
|
out as **one timeline**: furthest behind your local time at the far left,
|
||||||
|
furthest ahead at the far right, your own time dead centre.
|
||||||
|
|
||||||
|
```
|
||||||
|
LA 08:24 NYC 11:24 LDN 16:24 17:24 SG 23:24 TOK 00:24 +1 AUS 01:24 +1 NZ 03:24 +1
|
||||||
|
```
|
||||||
|
|
||||||
|
An Omarchy 4 shell plugin (Quickshell). It reserves its own space, so Hyprland
|
||||||
|
tiles windows above it exactly as it does for the top bar.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **Timeline layout.** Side and position are derived from each zone's live UTC
|
||||||
|
offset, not from config order. Reading left to right always moves forward in
|
||||||
|
time, and a DST shift re-sorts the bar on its own.
|
||||||
|
- **24-hour times**, with `+1` / `-1` against clocks on a different date to you.
|
||||||
|
- **Labels** to the left of each time. The centre clock is your local time and
|
||||||
|
carries no label.
|
||||||
|
- **Dimmed outside working hours**, evaluated in each clock's *own* timezone
|
||||||
|
(weekends included), so a glance tells you who is actually around.
|
||||||
|
- **Themed.** Colours, fonts, spacing and bar height all come from the active
|
||||||
|
Omarchy theme; it follows a theme switch with no restart.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
On any Omarchy 4 machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
omarchy plugin add <git-url-of-this-repo> --enable --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
The plugin lands in `~/.config/omarchy/plugins/nerdnation.clockbar/`, and
|
||||||
|
`~/.config/omarchy/clockbar.json` is seeded on first run.
|
||||||
|
|
||||||
|
To update it later, and to remove it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
omarchy plugin update nerdnation.clockbar
|
||||||
|
omarchy plugin remove nerdnation.clockbar
|
||||||
|
```
|
||||||
|
|
||||||
|
> Plugins run as unsandboxed QML inside `omarchy-shell`, so `omarchy plugin add`
|
||||||
|
> clones disabled by default and wants you to read the code first. `--enable`
|
||||||
|
> skips that.
|
||||||
|
|
||||||
|
## Configuring it
|
||||||
|
|
||||||
|
Two routes, one source of truth — both read and write
|
||||||
|
`~/.config/omarchy/clockbar.json`, and the bar reloads from the file either way.
|
||||||
|
|
||||||
|
**Click the bar.** Opens an editor with a searchable timezone picker: add,
|
||||||
|
rename, retime and remove clocks, and set the working-hours window. Each row
|
||||||
|
shows that zone's current offset so you can see the effect before saving.
|
||||||
|
|
||||||
|
**Edit the file.** It hot-reloads on save, no restart:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Key | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `clocks[].label` | Shown left of the time. Defaults to the last part of the zone name. |
|
||||||
|
| `clocks[].timezone` | Any IANA zone (`timedatectl list-timezones`). |
|
||||||
|
| `workingHours.start` / `.end` | Hours, 0–24, in each clock's own zone. A window that wraps midnight (`22`–`6`) is understood. |
|
||||||
|
| `workingHours.workdays` | Days counted as working. `0` = Sunday. |
|
||||||
|
| `dimOutsideWorkingHours` | Mute clocks outside that window. |
|
||||||
|
| `showSeconds` | `HH:MM:SS` and a per-second tick, instead of `HH:MM`. |
|
||||||
|
| `barSize` | Bar height in px. `0` follows the theme's bar height. |
|
||||||
|
|
||||||
|
There is no `left` / `right` setting: which side a clock lands on is a fact
|
||||||
|
about its offset, not a preference.
|
||||||
|
|
||||||
|
**When something is wrong**, the bar says so rather than lying. A zone tzdata
|
||||||
|
cannot resolve renders as `LABEL --:--` in the theme's urgent colour instead of
|
||||||
|
silently falling back to UTC. A `clockbar.json` that will not parse turns the
|
||||||
|
bar's top hairline red and puts `config error` beside your local time, while
|
||||||
|
the last good config keeps ticking — and the broken file is never overwritten.
|
||||||
|
|
||||||
|
## IPC
|
||||||
|
|
||||||
|
```bash
|
||||||
|
omarchy-shell nerdnation.clockbar edit # open the editor
|
||||||
|
omarchy-shell nerdnation.clockbar toggle
|
||||||
|
omarchy-shell nerdnation.clockbar refresh # re-probe timezone offsets
|
||||||
|
omarchy-shell nerdnation.clockbar reload # re-read clockbar.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Bind the editor to a key in `~/.config/hypr/bindings.lua` if you want one.
|
||||||
|
|
||||||
|
## How it works, and why
|
||||||
|
|
||||||
|
Qt's QML engine ships **no `Intl`**, so there is no way to render an IANA
|
||||||
|
timezone from inside QML — the usual `Intl.DateTimeFormat({ timeZone })` route
|
||||||
|
does not exist. Rather than bundle a copy of the tz database, the plugin asks
|
||||||
|
the system's own tzdata for each zone's current UTC offset (`TZ=<zone> date +%z`)
|
||||||
|
and then does the arithmetic itself. That keeps it correct across DST changes
|
||||||
|
and tzdata updates without knowing any of the rules.
|
||||||
|
|
||||||
|
The probe is one short-lived shell, and its answer stays valid until a
|
||||||
|
transition, so it runs on a slow timer — every ten minutes, on each hour
|
||||||
|
boundary (where transitions land), and whenever the config changes. In between,
|
||||||
|
the clocks advance by arithmetic on the minute tick.
|
||||||
|
|
||||||
|
`ClockModel.js` holds all of that logic and touches no QML types, so it runs
|
||||||
|
unchanged under node:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tests/model.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|---|---|
|
||||||
|
| `manifest.json` | Plugin manifest — `kind: panel`, `keepLoaded` |
|
||||||
|
| `Clockbar.qml` | Entry point: the bottom layer-shell surface, one per screen |
|
||||||
|
| `Editor.qml` | The click-to-edit popup |
|
||||||
|
| `Config.qml` | `clockbar.json` — read, watch, write |
|
||||||
|
| `Zones.qml` | Timezone offset probe and the zone list for the picker |
|
||||||
|
| `ClockModel.js` | Ordering, formatting, day offsets, working hours (pure JS) |
|
||||||
|
| `tests/model.test.js` | Unit tests for the above |
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "nerdnation.clockbar",
|
||||||
|
"name": "Clock bar",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Harvey",
|
||||||
|
"description": "A second bar along the bottom of every screen showing world clocks, laid out as one timeline around your local time.",
|
||||||
|
"kinds": [
|
||||||
|
"panel"
|
||||||
|
],
|
||||||
|
"keepLoaded": true,
|
||||||
|
"entryPoints": {
|
||||||
|
"panel": "Clockbar.qml"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Unit tests for ClockModel.js. Run with: node tests/model.test.js
|
||||||
|
// No dependencies — the model is plain ES5 so it runs identically here and in
|
||||||
|
// Qt's QML engine.
|
||||||
|
|
||||||
|
var assert = require("assert")
|
||||||
|
var M = require("../ClockModel.js")
|
||||||
|
|
||||||
|
var passed = 0
|
||||||
|
function test(name, fn) {
|
||||||
|
try { fn(); passed++ }
|
||||||
|
catch (e) { console.error("FAIL: " + name + "\n " + e.message); process.exitCode = 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2026-09-04T15:24:00Z — a Friday. Johannesburg is +0200, so local is 17:24.
|
||||||
|
var NOW = Date.UTC(2026, 8, 4, 15, 24, 0)
|
||||||
|
var SAST = "+0200"
|
||||||
|
|
||||||
|
var OFFSETS = {
|
||||||
|
"America/Los_Angeles": "-0700", // PDT
|
||||||
|
"America/New_York": "-0400", // EDT
|
||||||
|
"Asia/Singapore": "+0800",
|
||||||
|
"Australia/Sydney": "+1000", // AEST
|
||||||
|
"Pacific/Auckland": "+1200" // NZST
|
||||||
|
}
|
||||||
|
|
||||||
|
var CONFIG = {
|
||||||
|
clocks: [
|
||||||
|
{ label: "NZ", timezone: "Pacific/Auckland" },
|
||||||
|
{ label: "LA", timezone: "America/Los_Angeles" },
|
||||||
|
{ label: "AUS", timezone: "Australia/Sydney" },
|
||||||
|
{ label: "NYC", timezone: "America/New_York" },
|
||||||
|
{ label: "SG", timezone: "Asia/Singapore" }
|
||||||
|
],
|
||||||
|
workingHours: { start: 8, end: 18, workdays: [1, 2, 3, 4, 5] }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parseOffset handles the tzdata forms", function () {
|
||||||
|
assert.strictEqual(M.parseOffset("+0200"), 120)
|
||||||
|
assert.strictEqual(M.parseOffset("-0700"), -420)
|
||||||
|
assert.strictEqual(M.parseOffset("+0545"), 345) // Kathmandu
|
||||||
|
assert.strictEqual(M.parseOffset("-0330"), -210) // Newfoundland
|
||||||
|
assert.strictEqual(M.parseOffset("+00:00"), 0)
|
||||||
|
assert.strictEqual(M.parseOffset(""), null)
|
||||||
|
assert.strictEqual(M.parseOffset("garbage"), null)
|
||||||
|
assert.strictEqual(M.parseOffset(null), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("times are correct against known offsets", function () {
|
||||||
|
var bar = M.buildBar(CONFIG, OFFSETS, NOW, SAST)
|
||||||
|
var byLabel = {}
|
||||||
|
bar.left.concat(bar.right).forEach(function (c) { byLabel[c.label] = c })
|
||||||
|
|
||||||
|
assert.strictEqual(bar.center.time, "17:24", "local")
|
||||||
|
assert.strictEqual(byLabel.LA.time, "08:24")
|
||||||
|
assert.strictEqual(byLabel.NYC.time, "11:24")
|
||||||
|
assert.strictEqual(byLabel.SG.time, "23:24")
|
||||||
|
assert.strictEqual(byLabel.AUS.time, "01:24")
|
||||||
|
assert.strictEqual(byLabel.NZ.time, "03:24")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clocks land on the correct side of local", function () {
|
||||||
|
var bar = M.buildBar(CONFIG, OFFSETS, NOW, SAST)
|
||||||
|
assert.deepStrictEqual(bar.left.map(function (c) { return c.label }), ["LA", "NYC"])
|
||||||
|
assert.deepStrictEqual(bar.right.map(function (c) { return c.label }), ["SG", "AUS", "NZ"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("config order does not affect rendered order", function () {
|
||||||
|
var shuffled = { clocks: CONFIG.clocks.slice().reverse(), workingHours: CONFIG.workingHours }
|
||||||
|
var a = M.buildBar(CONFIG, OFFSETS, NOW, SAST)
|
||||||
|
var b = M.buildBar(shuffled, OFFSETS, NOW, SAST)
|
||||||
|
var names = function (bar) { return bar.left.concat(bar.right).map(function (c) { return c.label }) }
|
||||||
|
assert.deepStrictEqual(names(a), names(b))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("day markers show +1 and -1", function () {
|
||||||
|
var bar = M.buildBar(CONFIG, OFFSETS, NOW, SAST)
|
||||||
|
var byLabel = {}
|
||||||
|
bar.left.concat(bar.right).forEach(function (c) { byLabel[c.label] = c })
|
||||||
|
assert.strictEqual(byLabel.AUS.marker, "+1", "Sydney is already tomorrow")
|
||||||
|
assert.strictEqual(byLabel.NZ.marker, "+1")
|
||||||
|
assert.strictEqual(byLabel.SG.marker, "", "Singapore is still today")
|
||||||
|
assert.strictEqual(byLabel.LA.marker, "", "LA is still today at 08:24")
|
||||||
|
|
||||||
|
// Same zones, but local is 01:24 on the 5th — LA is then on the 4th.
|
||||||
|
var early = Date.UTC(2026, 8, 4, 23, 24, 0)
|
||||||
|
var lateBar = M.buildBar(CONFIG, OFFSETS, early, SAST)
|
||||||
|
var late = {}
|
||||||
|
lateBar.left.concat(lateBar.right).forEach(function (c) { late[c.label] = c })
|
||||||
|
assert.strictEqual(lateBar.center.time, "01:24")
|
||||||
|
assert.strictEqual(late.LA.marker, "-1", "LA is yesterday")
|
||||||
|
assert.strictEqual(late.NYC.marker, "-1")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("working hours are evaluated in the clock's own zone", function () {
|
||||||
|
var bar = M.buildBar(CONFIG, OFFSETS, NOW, SAST)
|
||||||
|
var byLabel = {}
|
||||||
|
bar.left.concat(bar.right).forEach(function (c) { byLabel[c.label] = c })
|
||||||
|
assert.strictEqual(byLabel.LA.working, true, "08:24 Fri in LA")
|
||||||
|
assert.strictEqual(byLabel.NYC.working, true, "11:24 Fri in NYC")
|
||||||
|
assert.strictEqual(byLabel.SG.working, false, "23:24 Fri in Singapore")
|
||||||
|
assert.strictEqual(byLabel.NZ.working, false, "03:24 Sat in Auckland")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("weekends count as outside working hours", function () {
|
||||||
|
// 2026-09-05 is a Saturday. 10:00 local.
|
||||||
|
var sat = Date.UTC(2026, 8, 5, 8, 0, 0)
|
||||||
|
var bar = M.buildBar({ clocks: [{ label: "JHB", timezone: "Africa/Johannesburg" }] },
|
||||||
|
{ "Africa/Johannesburg": "+0200" }, sat, SAST)
|
||||||
|
assert.strictEqual(bar.right[0].time, "10:00")
|
||||||
|
assert.strictEqual(bar.right[0].working, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a window that wraps midnight is handled", function () {
|
||||||
|
var hours = { start: 22, end: 6, workdays: [0, 1, 2, 3, 4, 5, 6] }
|
||||||
|
assert.strictEqual(M.isWorkingHours(new Date(Date.UTC(2026, 8, 4, 23, 0)), hours), true)
|
||||||
|
assert.strictEqual(M.isWorkingHours(new Date(Date.UTC(2026, 8, 4, 3, 0)), hours), true)
|
||||||
|
assert.strictEqual(M.isWorkingHours(new Date(Date.UTC(2026, 8, 4, 12, 0)), hours), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("an unresolvable zone is flagged, not rendered as UTC", function () {
|
||||||
|
var bar = M.buildBar({ clocks: [{ label: "BAD", timezone: "Not/AZone" }] }, {}, NOW, SAST)
|
||||||
|
var clock = bar.right[0]
|
||||||
|
assert.strictEqual(clock.known, false)
|
||||||
|
assert.strictEqual(clock.time, "--:--")
|
||||||
|
assert.strictEqual(bar.left.length, 0, "unknown zones never sort into the timeline")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("half-hour and quarter-hour zones sort correctly", function () {
|
||||||
|
var cfg = {
|
||||||
|
clocks: [
|
||||||
|
{ label: "IND", timezone: "Asia/Kolkata" },
|
||||||
|
{ label: "NPL", timezone: "Asia/Kathmandu" },
|
||||||
|
{ label: "SG", timezone: "Asia/Singapore" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
var offs = { "Asia/Kolkata": "+0530", "Asia/Kathmandu": "+0545", "Asia/Singapore": "+0800" }
|
||||||
|
var bar = M.buildBar(cfg, offs, NOW, SAST)
|
||||||
|
assert.deepStrictEqual(bar.right.map(function (c) { return c.label }), ["IND", "NPL", "SG"])
|
||||||
|
assert.strictEqual(bar.right[0].time, "20:54")
|
||||||
|
assert.strictEqual(bar.right[1].time, "21:09")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a DST shift re-sorts the bar with no config change", function () {
|
||||||
|
// Sydney +1000 sits behind Auckland +1200; in the southern summer both move
|
||||||
|
// but keep order, while LA leaving PDT widens its gap from local.
|
||||||
|
var winter = M.buildBar(CONFIG, OFFSETS, NOW, SAST)
|
||||||
|
assert.strictEqual(winter.left[0].label, "LA")
|
||||||
|
assert.strictEqual(winter.left[0].relativeMinutes, -540)
|
||||||
|
|
||||||
|
var pst = {}
|
||||||
|
Object.keys(OFFSETS).forEach(function (k) { pst[k] = OFFSETS[k] })
|
||||||
|
pst["America/Los_Angeles"] = "-0800"
|
||||||
|
var summer = M.buildBar(CONFIG, pst, NOW, SAST)
|
||||||
|
assert.strictEqual(summer.left[0].relativeMinutes, -600)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("zonesToProbe dedupes and rejects shell-unsafe names", function () {
|
||||||
|
var zones = M.zonesToProbe({
|
||||||
|
clocks: [
|
||||||
|
{ label: "A", timezone: "Europe/London" },
|
||||||
|
{ label: "B", timezone: "Europe/London" },
|
||||||
|
{ label: "C", timezone: "; rm -rf /" },
|
||||||
|
{ label: "D", timezone: "$(whoami)" },
|
||||||
|
{ label: "E", timezone: "" },
|
||||||
|
{ label: "F", timezone: "America/Argentina/Buenos_Aires" },
|
||||||
|
{ label: "G", timezone: "Etc/GMT+5" }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
assert.deepStrictEqual(zones, ["Europe/London", "America/Argentina/Buenos_Aires", "Etc/GMT+5"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseOffsetReport reads the probe output", function () {
|
||||||
|
var got = M.parseOffsetReport("Europe/London\t+0100\nAsia/Tokyo\t+0900\n\n")
|
||||||
|
assert.deepStrictEqual(got, { "Europe/London": "+0100", "Asia/Tokyo": "+0900" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("an empty config renders an empty bar, not a crash", function () {
|
||||||
|
var bar = M.buildBar({}, {}, NOW, SAST)
|
||||||
|
assert.deepStrictEqual(bar.left, [])
|
||||||
|
assert.deepStrictEqual(bar.right, [])
|
||||||
|
assert.strictEqual(bar.center.time, "17:24")
|
||||||
|
assert.deepStrictEqual(M.buildBar(null, null, NOW, null).center.time, "15:24")
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(passed + " passed" + (process.exitCode ? ", some FAILED" : ""))
|
||||||
Reference in New Issue
Block a user