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:
2026-09-04 17:49:40 +02:00
co-authored by Claude Opus 5
commit 602a6db274
9 changed files with 1479 additions and 0 deletions
+266
View File
@@ -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
}
}
}
}