diff --git a/ClockModel.js b/ClockModel.js index 4d18f1b..0765576 100644 --- a/ClockModel.js +++ b/ClockModel.js @@ -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, diff --git a/Clockbar.qml b/Clockbar.qml index af614b0..1c43b86 100644 --- a/Clockbar.qml +++ b/Clockbar.qml @@ -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 } } diff --git a/Config.qml b/Config.qml index 0ffb20e..657bfa0 100644 --- a/Config.qml +++ b/Config.qml @@ -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 } diff --git a/Editor.qml b/Editor.qml index 502de56..1b79b0b 100644 --- a/Editor.qml +++ b/Editor.qml @@ -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) diff --git a/README.md b/README.md index bcf9c02..3fa2a8c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/tests/model.test.js b/tests/model.test.js index 60090db..d186b74 100644 --- a/tests/model.test.js +++ b/tests/model.test.js @@ -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" : ""))