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
267 lines
8.7 KiB
QML
267 lines
8.7 KiB
QML
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
|
|
}
|
|
}
|
|
}
|
|
}
|