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