Files
OmarchyClockBar/Clockbar.qml
T
HarveySaaymanandClaude Opus 5 b3123ecb3b Seconds per clock, and type that scales with the bar height
Seconds move from one bar-wide switch to a per-clock setting. A clock's own
`showSeconds` wins, the top-level one is the default for clocks with no
opinion, and the centre clock gets `local.showSeconds` since it has no entry in
`clocks[]`. An explicit false on a clock beats a true default, so the check is
for the property's presence rather than its truthiness.

The tick rate follows what is actually displayed: `anyShowsSeconds()` decides
between a per-second and a per-minute SystemClock, so a bar showing no seconds
does not wake the shell sixty times as often to redraw an identical minute.

`barSize` is now editable from the popup, and everything drawn on the bar is
sized against the theme's own bar height rather than pinned to the theme's
12px type. A taller bar carries proportionally larger text, gaps and rule; at
the theme's own height the scale is exactly 1, so nothing moves. Heights are
clamped to 12-120 rather than rejected, so a value the editor can produce can
never be one the config layer silently discards.

Fixes a latent bug this surfaced: updateRow() rebuilt a draft row from a fixed
pair of fields, so editing a clock's label would have thrown away its own
seconds setting. It now copies every key the row had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MhxtL7WzL5XmuTd8GHbcTY
2026-09-04 18:02:36 +02:00

280 lines
9.4 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
// Everything drawn on the bar is sized against the theme's own bar height,
// so a taller bar carries proportionally larger type and gaps rather than
// the theme's 26px-sized text marooned in the middle of it. At the default
// height this is exactly 1, which leaves the theme's own tokens untouched.
readonly property real barScale: Style.bar.sizeHorizontal > 0
? barSize / Style.bar.sizeHorizontal
: 1
function scaled(px) { return Math.max(1, Math.round(px * root.barScale)) }
readonly property int timeFontSize: scaled(Style.font.body)
readonly property int labelFontSize: scaled(Style.font.caption)
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: scaled(Style.space(12))
readonly property int clockGap: scaled(Style.space(16))
readonly property int labelGap: scaled(Style.space(6))
Config { id: configFile }
Zones {
id: zones
zones: Model.zonesToProbe(root.config)
}
// Seconds are opt-in per clock, so the tick only drops to per-second when
// something on the bar is actually displaying them — otherwise this wakes
// the whole shell sixty times as often to redraw an identical minute.
SystemClock {
id: clock
precision: Model.anyShowsSeconds(root.config) ? 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: root.labelFontSize
// 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: root.timeFontSize
}
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: root.labelFontSize
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 !== "" ? root.scaled(2) : Math.max(1, root.scaled(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: root.timeFontSize
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: root.labelFontSize
}
}
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
}
}
}
}