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