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
This commit is contained in:
2026-09-04 18:02:36 +02:00
co-authored by Claude Opus 5
parent 602a6db274
commit b3123ecb3b
6 changed files with 249 additions and 29 deletions
+30 -3
View File
@@ -84,6 +84,29 @@ function isWorkingHours(wall, hours) {
return hour >= start || hour < end
}
// Seconds are decided per clock, falling back to the config-wide default.
// An explicit `false` on a clock has to beat a `true` default, so the check is
// for the property's presence, not its truthiness.
function showsSeconds(entry, fallback) {
if (entry && typeof entry.showSeconds === "boolean") return entry.showSeconds
return fallback === true
}
// Whether anything on the bar is showing seconds, which is what decides
// between a per-second and a per-minute tick. Ticking every second when
// nothing displays them wakes the whole shell sixty times as often for no
// visible change.
function anyShowsSeconds(config) {
var cfg = config || {}
var fallback = cfg.showSeconds === true
if (showsSeconds(cfg.local, fallback)) return true
var entries = Array.isArray(cfg.clocks) ? cfg.clocks : []
for (var i = 0; i < entries.length; i++) {
if (showsSeconds(entries[i], fallback)) return true
}
return false
}
function isValidLabel(label) {
return typeof label === "string" && label.replace(/^\s+|\s+$/g, "").length > 0
}
@@ -93,6 +116,7 @@ function isValidLabel(label) {
// show the label struck through instead of quietly lying about the time.
function buildClock(entry, offsets, utcMs, localOffset, options) {
var opts = options || {}
var withSeconds = showsSeconds(entry, opts.showSeconds)
var timezone = String(entry && entry.timezone ? entry.timezone : "")
var label = isValidLabel(entry && entry.label) ? String(entry.label).replace(/^\s+|\s+$/g, "") : timezone
var offset = parseOffset(offsets ? offsets[timezone] : null)
@@ -100,7 +124,8 @@ function buildClock(entry, offsets, utcMs, localOffset, options) {
if (offset === null) {
return {
label: label, timezone: timezone, known: false,
time: "--:--", marker: "", delta: 0, relativeMinutes: 0, working: false
time: withSeconds ? "--:--:--" : "--:--",
marker: "", delta: 0, relativeMinutes: 0, working: false
}
}
@@ -112,7 +137,7 @@ function buildClock(entry, offsets, utcMs, localOffset, options) {
label: label,
timezone: timezone,
known: true,
time: formatTime(wall, opts.showSeconds === true),
time: formatTime(wall, withSeconds),
marker: dayMarker(delta),
delta: delta,
relativeMinutes: offset - localOffset,
@@ -165,7 +190,7 @@ function buildBar(config, offsets, utcMs, localOffsetRaw) {
// of the trailing group where they are visible but never mis-ordered.
right: right.concat(unknown),
center: {
time: formatTime(localWall, options.showSeconds),
time: formatTime(localWall, showsSeconds(cfg.local, options.showSeconds)),
date: localWall.getUTCFullYear() + "-" + pad2(localWall.getUTCMonth() + 1) + "-" + pad2(localWall.getUTCDate()),
offsetMinutes: localOffset
}
@@ -211,6 +236,8 @@ if (typeof module !== "undefined" && module.exports) {
dayDelta: dayDelta,
dayMarker: dayMarker,
isWorkingHours: isWorkingHours,
showsSeconds: showsSeconds,
anyShowsSeconds: anyShowsSeconds,
buildClock: buildClock,
buildBar: buildBar,
zonesToProbe: zonesToProbe,
+26 -13
View File
@@ -33,15 +33,28 @@ Item {
// 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: Style.space(12)
readonly property int clockGap: Style.space(16)
readonly property int labelGap: Style.space(6)
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 }
@@ -50,12 +63,12 @@ Item {
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.
// 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: root.config && root.config.showSeconds ? SystemClock.Seconds : SystemClock.Minutes
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.
@@ -117,7 +130,7 @@ Item {
// 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
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.
@@ -129,7 +142,7 @@ Item {
text: entry.clock.time
color: entry.tint
font.family: Style.font.family
font.pixelSize: Style.font.body
font.pixelSize: root.timeFontSize
}
Text {
@@ -142,7 +155,7 @@ Item {
text: entry.clock.marker
color: entry.tint
font.family: Style.font.family
font.pixelSize: Style.font.caption
font.pixelSize: root.labelFontSize
opacity: 0.7
}
}
@@ -184,7 +197,7 @@ Item {
// 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
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
}
@@ -215,7 +228,7 @@ Item {
text: root.bar.center.time
color: root.accent
font.family: Style.font.family
font.pixelSize: Style.font.body
font.pixelSize: root.timeFontSize
font.bold: true
}
@@ -228,7 +241,7 @@ Item {
text: "config error"
color: root.urgent
font.family: Style.font.family
font.pixelSize: Style.font.caption
font.pixelSize: root.labelFontSize
}
}
+17 -2
View File
@@ -38,7 +38,10 @@ QtObject {
],
workingHours: { start: 8, end: 18, workdays: [1, 2, 3, 4, 5] },
dimOutsideWorkingHours: true,
// The bar-wide default for seconds. Any clock, and the centre clock via
// `local`, may override it.
showSeconds: false,
local: { showSeconds: false },
barSize: 0
}
}
@@ -58,7 +61,12 @@ QtObject {
var tz = String(entry.timezone || "").replace(/^\s+|\s+$/g, "")
if (!tz) continue
var label = String(entry.label === undefined || entry.label === null ? "" : entry.label).replace(/^\s+|\s+$/g, "")
clocks.push({ label: label || tz, timezone: tz })
var clock = { label: label || tz, timezone: tz }
// Carried through only when the clock actually sets it, so a clock
// with no opinion keeps inheriting the bar-wide default rather than
// being frozen to whatever it happened to be when this was written.
if (typeof entry.showSeconds === "boolean") clock.showSeconds = entry.showSeconds
clocks.push(clock)
}
out.clocks = clocks
}
@@ -81,8 +89,15 @@ QtObject {
if (typeof raw.dimOutsideWorkingHours === "boolean") out.dimOutsideWorkingHours = raw.dimOutsideWorkingHours
if (typeof raw.showSeconds === "boolean") out.showSeconds = raw.showSeconds
if (raw.local && typeof raw.local === "object" && typeof raw.local.showSeconds === "boolean")
out.local.showSeconds = raw.local.showSeconds
// 0 means "follow the theme". The ceiling is a guard against a typo
// turning the bar into a full-screen panel with no way back to the
// editor that would undo it.
var size = Number(raw.barSize)
if (isFinite(size) && size >= 0 && size <= 200) out.barSize = size
if (isFinite(size) && size > 0) out.barSize = Math.min(120, Math.max(12, Math.round(size)))
else if (isFinite(size)) out.barSize = 0
return out
}
+93 -7
View File
@@ -24,6 +24,9 @@ Item {
property int workStart: 8
property int workEnd: 18
property bool dimOutside: true
property bool defaultSeconds: false
property bool localSeconds: false
property int barSize: 0
readonly property color background: Color.popups.background
readonly property color foreground: Color.popups.text
@@ -56,24 +59,38 @@ Item {
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 || "") })
for (var i = 0; i < clocks.length; i++) {
var row = { label: String(clocks[i].label || ""), timezone: String(clocks[i].timezone || "") }
if (typeof clocks[i].showSeconds === "boolean") row.showSeconds = clocks[i].showSeconds
copy.push(row)
}
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
root.defaultSeconds = cfg ? cfg.showSeconds === true : false
root.localSeconds = Model.showsSeconds(cfg ? cfg.local : null, root.defaultSeconds)
root.barSize = cfg && cfg.barSize > 0 ? cfg.barSize : 0
}
// What a row's toggle shows: its own setting when it has one, otherwise the
// bar-wide default it is inheriting.
function rowShowsSeconds(row) { return Model.showsSeconds(row, root.defaultSeconds) }
// 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 }
// Copies every key the row already had. Rebuilding it from a fixed pair of
// fields silently dropped anything else on it — editing a label would have
// thrown away that clock's own seconds setting.
function updateRow(index, key, value) {
var next = root.draft.slice()
if (!next[index]) return
var row = { label: next[index].label, timezone: next[index].timezone }
var row = {}
for (var k in next[index]) row[k] = next[index][k]
row[key] = value
next[index] = row
replaceDraft(next)
@@ -99,7 +116,11 @@ Item {
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 })
var row = { label: label || tz.split("/").pop().replace(/_/g, " "), timezone: tz }
// Only clocks the user actually set keep an explicit flag; the rest go
// on inheriting the bar-wide default.
if (typeof root.draft[i].showSeconds === "boolean") row.showSeconds = root.draft[i].showSeconds
out.push(row)
}
return out
}
@@ -116,8 +137,9 @@ Item {
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
showSeconds: root.defaultSeconds,
local: { showSeconds: root.localSeconds },
barSize: root.barSize
})
close()
}
@@ -211,7 +233,7 @@ Item {
spacing: Style.space(8)
TextField {
Layout.preferredWidth: Style.space(74)
Layout.preferredWidth: Style.space(66)
text: row.modelData.label
placeholderText: "Label"
foreground: root.foreground
@@ -245,6 +267,18 @@ Item {
font.pixelSize: Style.font.caption
}
Button {
text: "SS"
bordered: true
selected: root.rowShowsSeconds(row.modelData)
tooltipText: root.rowShowsSeconds(row.modelData)
? "Showing seconds — click for HH:MM"
: "Show seconds on this clock"
foreground: root.foreground
accent: root.accent
onClicked: root.updateRow(row.index, "showSeconds", !root.rowShowsSeconds(row.modelData))
}
Button {
iconText: "󰅙"
tooltipText: "Remove this clock"
@@ -346,6 +380,58 @@ Item {
PanelSeparator { Layout.fillWidth: true }
PanelSectionHeader {
Layout.fillWidth: true
text: "Bar"
}
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
Text {
Layout.fillWidth: true
text: "Seconds on your local clock"
color: root.foreground
elide: Text.ElideRight
font.family: Style.font.family
font.pixelSize: Style.font.body
}
ToggleSwitch {
checked: root.localSeconds
foreground: root.foreground
accent: root.accent
onToggled: root.localSeconds = !root.localSeconds
}
}
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
Text {
Layout.fillWidth: true
text: "Bar height — 0 follows the theme. Text scales with it."
color: root.muted
elide: Text.ElideRight
font.family: Style.font.family
font.pixelSize: Style.font.caption
}
NumberField {
Layout.preferredWidth: Style.spacing.numberFieldWidth
value: root.barSize
from: 0
to: 120
foreground: root.foreground
accent: root.accent
onModified: function (v) { root.barSize = v }
}
}
PanelSeparator { Layout.fillWidth: true }
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
+16 -4
View File
@@ -21,8 +21,12 @@ tiles windows above it exactly as it does for the top bar.
carries no label.
- **Dimmed outside working hours**, evaluated in each clock's *own* timezone
(weekends included), so a glance tells you who is actually around.
- **Seconds per clock.** Any clock can show `HH:MM:SS` on its own, including
your local one. The bar only drops to a per-second tick when something is
actually displaying seconds.
- **Themed.** Colours, fonts, spacing and bar height all come from the active
Omarchy theme; it follows a theme switch with no restart.
Omarchy theme; it follows a theme switch with no restart. Set your own bar
height and the type and spacing scale with it.
## Install
@@ -65,11 +69,12 @@ shows that zone's current offset so you can see the effect before saving.
{ "label": "NYC", "timezone": "America/New_York" },
{ "label": "SG", "timezone": "Asia/Singapore" },
{ "label": "AUS", "timezone": "Australia/Sydney" },
{ "label": "NZ", "timezone": "Pacific/Auckland" }
{ "label": "NZ", "timezone": "Pacific/Auckland", "showSeconds": true }
],
"workingHours": { "start": 8, "end": 18, "workdays": [1, 2, 3, 4, 5] },
"dimOutsideWorkingHours": true,
"showSeconds": false,
"local": { "showSeconds": false },
"barSize": 0
}
```
@@ -78,11 +83,18 @@ shows that zone's current offset so you can see the effect before saving.
|---|---|
| `clocks[].label` | Shown left of the time. Defaults to the last part of the zone name. |
| `clocks[].timezone` | Any IANA zone (`timedatectl list-timezones`). |
| `clocks[].showSeconds` | Seconds on this clock. Omit it to inherit the bar-wide default below. |
| `workingHours.start` / `.end` | Hours, 0–24, in each clock's own zone. A window that wraps midnight (`22`–`6`) is understood. |
| `workingHours.workdays` | Days counted as working. `0` = Sunday. |
| `dimOutsideWorkingHours` | Mute clocks outside that window. |
| `showSeconds` | `HH:MM:SS` and a per-second tick, instead of `HH:MM`. |
| `barSize` | Bar height in px. `0` follows the theme's bar height. |
| `showSeconds` | The bar-wide default for seconds, for clocks that do not set their own. |
| `local.showSeconds` | Seconds on your local clock, which has no entry in `clocks[]`. |
| `barSize` | Bar height in px. `0` follows the theme's. Otherwise 12–120; text and spacing scale with it. |
Seconds resolve per clock: a clock's own `showSeconds` wins, otherwise the
bar-wide default applies. An explicit `false` on a clock beats a `true`
default. The bar ticks once a minute unless something on it is showing
seconds — nothing pays for a per-second redraw it does not use.
There is no `left` / `right` setting: which side a clock lands on is a fact
about its offset, not a preference.
+67
View File
@@ -182,4 +182,71 @@ test("an empty config renders an empty bar, not a crash", function () {
assert.deepStrictEqual(M.buildBar(null, null, NOW, null).center.time, "15:24")
})
test("seconds are per clock, with a config-wide default", function () {
var cfg = {
showSeconds: false,
clocks: [
{ label: "LA", timezone: "America/Los_Angeles", showSeconds: true },
{ label: "NYC", timezone: "America/New_York" }
]
}
var bar = M.buildBar(cfg, OFFSETS, NOW, SAST)
var byLabel = {}
bar.left.forEach(function (c) { byLabel[c.label] = c })
assert.strictEqual(byLabel.LA.time, "08:24:00", "opted in")
assert.strictEqual(byLabel.NYC.time, "11:24", "inherits the default")
assert.strictEqual(bar.center.time, "17:24", "centre inherits the default")
})
test("an explicit false on a clock beats a true default", function () {
var cfg = {
showSeconds: true,
clocks: [
{ label: "LA", timezone: "America/Los_Angeles", showSeconds: false },
{ label: "NYC", timezone: "America/New_York" }
]
}
var bar = M.buildBar(cfg, OFFSETS, NOW, SAST)
var byLabel = {}
bar.left.forEach(function (c) { byLabel[c.label] = c })
assert.strictEqual(byLabel.LA.time, "08:24", "opted out")
assert.strictEqual(byLabel.NYC.time, "11:24:00")
assert.strictEqual(bar.center.time, "17:24:00")
})
test("the centre clock carries its own seconds override", function () {
var cfg = {
showSeconds: false,
local: { showSeconds: true },
clocks: [{ label: "LA", timezone: "America/Los_Angeles" }]
}
var bar = M.buildBar(cfg, OFFSETS, NOW, SAST)
assert.strictEqual(bar.center.time, "17:24:00")
assert.strictEqual(bar.left[0].time, "08:24", "clocks are unaffected by it")
})
test("anyShowsSeconds drives the tick rate", function () {
assert.strictEqual(M.anyShowsSeconds({ clocks: [{ label: "A", timezone: "X" }] }), false)
assert.strictEqual(M.anyShowsSeconds({ showSeconds: true, clocks: [] }), true, "the default alone counts")
assert.strictEqual(M.anyShowsSeconds({ local: { showSeconds: true }, clocks: [] }), true, "the centre alone counts")
assert.strictEqual(M.anyShowsSeconds({
clocks: [{ label: "A", timezone: "X" }, { label: "B", timezone: "Y", showSeconds: true }]
}), true, "one clock is enough")
assert.strictEqual(M.anyShowsSeconds({
showSeconds: true,
local: { showSeconds: false },
clocks: [{ label: "A", timezone: "X", showSeconds: false }]
}), false, "everything opted out")
assert.strictEqual(M.anyShowsSeconds(null), false)
})
test("an unresolved clock keeps the width of the format it asked for", function () {
var bar = M.buildBar({ clocks: [
{ label: "A", timezone: "Not/AZone" },
{ label: "B", timezone: "Not/AZone", showSeconds: true }
] }, {}, NOW, SAST)
assert.strictEqual(bar.right[0].time, "--:--")
assert.strictEqual(bar.right[1].time, "--:--:--")
})
console.log(passed + " passed" + (process.exitCode ? ", some FAILED" : ""))