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
175 lines
6.2 KiB
QML
175 lines
6.2 KiB
QML
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import "ClockModel.js" as Model
|
|
|
|
// Owns ~/.config/omarchy/clockbar.json: reads it, watches it, writes it back
|
|
// when the editor saves.
|
|
//
|
|
// The file is the source of truth in both directions — the editor writes it
|
|
// and the watcher picks the change straight back up, so a hand-edit and an
|
|
// edit through the popup are the same operation as far as the bar is
|
|
// concerned. That is what makes the two config routes impossible to
|
|
// desynchronise.
|
|
QtObject {
|
|
id: root
|
|
|
|
readonly property string home: Quickshell.env("HOME")
|
|
readonly property string path: home + "/.config/omarchy/clockbar.json"
|
|
|
|
property var config: defaults()
|
|
property bool loaded: false
|
|
// Set when the file on disk is present but unparseable. The bar keeps
|
|
// rendering the last good config and says so, rather than emptying itself
|
|
// and losing the user's clocks behind a stray comma.
|
|
property string parseError: ""
|
|
|
|
signal changed()
|
|
|
|
function defaults() {
|
|
return {
|
|
version: 1,
|
|
clocks: [
|
|
{ label: "LA", timezone: "America/Los_Angeles" },
|
|
{ label: "NYC", timezone: "America/New_York" },
|
|
{ label: "SG", timezone: "Asia/Singapore" },
|
|
{ label: "AUS", timezone: "Australia/Sydney" },
|
|
{ label: "NZ", timezone: "Pacific/Auckland" }
|
|
],
|
|
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
|
|
}
|
|
}
|
|
|
|
// Every field is coerced rather than trusted, so a half-finished hand-edit
|
|
// degrades to the default for that one key instead of breaking the bar.
|
|
function normalize(raw) {
|
|
var base = defaults()
|
|
if (!raw || typeof raw !== "object") return base
|
|
|
|
var out = base
|
|
if (Array.isArray(raw.clocks)) {
|
|
var clocks = []
|
|
for (var i = 0; i < raw.clocks.length; i++) {
|
|
var entry = raw.clocks[i]
|
|
if (!entry || typeof entry !== "object") continue
|
|
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, "")
|
|
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
|
|
}
|
|
|
|
var wh = raw.workingHours
|
|
if (wh && typeof wh === "object") {
|
|
var start = Number(wh.start)
|
|
var end = Number(wh.end)
|
|
if (isFinite(start) && start >= 0 && start <= 24) out.workingHours.start = start
|
|
if (isFinite(end) && end >= 0 && end <= 24) out.workingHours.end = end
|
|
if (Array.isArray(wh.workdays)) {
|
|
var days = []
|
|
for (var d = 0; d < wh.workdays.length; d++) {
|
|
var n = Number(wh.workdays[d])
|
|
if (isFinite(n) && n >= 0 && n <= 6 && days.indexOf(n) === -1) days.push(n)
|
|
}
|
|
if (days.length) out.workingHours.workdays = days
|
|
}
|
|
}
|
|
|
|
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) out.barSize = Math.min(120, Math.max(12, Math.round(size)))
|
|
else if (isFinite(size)) out.barSize = 0
|
|
|
|
return out
|
|
}
|
|
|
|
// Applying what was read. This path NEVER writes.
|
|
//
|
|
// FileView can emit `loaded` more than once while starting up, and the early
|
|
// emission can carry empty text before the read has actually landed. Seeding
|
|
// defaults from that and saving them destroyed the real file — the user's
|
|
// clocks were replaced by the stock five on the next shell restart. So an
|
|
// empty or unparseable read now leaves both the file and the last good
|
|
// config alone, and only a confirmed missing file seeds anything.
|
|
function apply(raw) {
|
|
var text = String(raw || "").replace(/^\s+|\s+$/g, "")
|
|
if (!text) {
|
|
root.loaded = true
|
|
return
|
|
}
|
|
|
|
var parsed
|
|
try {
|
|
parsed = JSON.parse(text)
|
|
} catch (e) {
|
|
root.parseError = String(e.message || e)
|
|
console.warn("clockbar: " + root.path + " is not valid JSON, keeping the last good config: " + root.parseError)
|
|
root.loaded = true
|
|
return
|
|
}
|
|
|
|
root.parseError = ""
|
|
root.config = normalize(parsed)
|
|
root.loaded = true
|
|
root.changed()
|
|
}
|
|
|
|
// The one place defaults are written, and only when the file genuinely is
|
|
// not there. A permissions error or an unknown failure must not be answered
|
|
// by overwriting whatever is on disk.
|
|
function seed() {
|
|
root.config = defaults()
|
|
root.parseError = ""
|
|
root.loaded = true
|
|
save(root.config)
|
|
}
|
|
|
|
function save(next) {
|
|
var clean = normalize(next)
|
|
root.config = clean
|
|
file.setText(JSON.stringify(clean, null, 2) + "\n")
|
|
root.parseError = ""
|
|
root.changed()
|
|
}
|
|
|
|
property FileView file: FileView {
|
|
path: root.path
|
|
watchChanges: true
|
|
atomicWrites: true
|
|
printErrors: false
|
|
onLoaded: root.apply(text())
|
|
onLoadFailed: function (error) {
|
|
if (error === FileViewError.FileNotFound) {
|
|
// First run. Seed the file so the user has something to edit and so
|
|
// the watcher has a real file to follow from here on.
|
|
root.seed()
|
|
return
|
|
}
|
|
console.warn("clockbar: could not read " + root.path + ": " + FileViewError.toString(error))
|
|
root.loaded = true
|
|
}
|
|
onFileChanged: reload()
|
|
}
|
|
|
|
Component.onCompleted: Qt.callLater(function () { root.file.reload() })
|
|
}
|