Files
OmarchyClockBar/Editor.qml
T
HarveySaaymanandClaude Opus 5 602a6db274 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
2026-09-04 17:49:40 +02:00

384 lines
12 KiB
QML

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()
}
}
}
}
}
}
}