Compare commits

..
3 Commits
Author SHA1 Message Date
HarveySaaymanandClaude Opus 5 5bca04c5ac Commit in-flight edits before acting on the draft
Typing a label and clicking Save straight away saved the old value. Fields
commit on editingFinished and spin boxes parse their typed text on focus loss,
so a value that had been typed but not blurred was never in the draft that
Save read -- and the typed text went with the delegate when the draft array
was replaced.

Committing on every keystroke is not the fix: the draft is a var array, so
replacing it re-creates the row delegates and would throw away the field being
typed into. Instead every action that reads or replaces the draft -- Save, add,
remove, the timezone picker, the per-clock seconds toggle -- now moves focus
first, letting each control commit through its own normal path. Focus lands on
the panel's key scope, so Escape still closes the editor.

Verified against the reported case: at the moment Save ran the draft still held
the old label, and the file received the typed one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MhxtL7WzL5XmuTd8GHbcTY
2026-09-04 18:42:20 +02:00
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
HarveySaaymanandClaude Opus 5 602a6db274 Clock bar: an Omarchy 4 bottom bar of world clocks
A `panel` shell plugin drawing its own bottom layer-shell surface, one per
screen, with an exclusive zone so Hyprland tiles above it. Clocks are laid out
as a single timeline around local time: side and position derive from each
zone's live UTC offset, so config order is irrelevant and a DST shift re-sorts
the bar on its own.

Qt's QML engine ships no `Intl`, so an IANA zone cannot be rendered from inside
QML at all. Offsets come from the system's own tzdata via `date +%z` and the
arithmetic is done here, which stays correct across DST and tzdata updates
without encoding any of the rules. The probe runs on a slow timer and on hour
boundaries; the minute tick advances the clocks in between.

Config lives in ~/.config/omarchy/clockbar.json and is edited either by hand or
through a click-to-edit popup with a timezone picker. Both routes write the same
file and the bar reloads from it, so they cannot disagree.

Failures are shown rather than papered over: an unresolvable zone renders
`LABEL --:--` in the urgent colour instead of silently falling back to UTC, and
an unparseable config reddens the bar's hairline and flags the local clock while
the last good config keeps ticking.

Ordering, formatting, day offsets and working hours live in ClockModel.js, which
touches no QML types and is covered by tests that run under node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MhxtL7WzL5XmuTd8GHbcTY
2026-09-04 17:49:40 +02:00
5 changed files with 20 additions and 201 deletions
-83
View File
@@ -214,88 +214,6 @@ function zonesToProbe(config) {
return out
}
// ---------------------------------------------------------- zone catalogue
//
// Builds the picker's list: every valid zone, tagged with the countries it
// covers, so a search for "Ghana" finds Africa/Accra without the user knowing
// the zone is named after a city.
//
// The country data is tzdata's own. `zone1970.tab` lists one canonical zone
// per region and `zone.tab` the older per-country set; together they name
// about 420 of the ~600 zones. The rest are backward-compatibility aliases
// (America/Buenos_Aires, Africa/Asmera, US/Pacific), which tzdata ships as
// hard links to their canonical zone — so an alias is matched to its canonical
// name by inode and inherits its countries. Without that, searching a country
// would silently miss its legacy spellings.
function parseZoneCatalog(raw) {
var section = ""
var countryName = {} // "ZA" -> "South Africa"
var zoneCountries = {} // "Africa/Johannesburg" -> ["South Africa", ...]
var zoneInode = {} // zone -> inode
var inodeZones = {} // inode -> [zones]
var listed = []
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].replace(/\s+$/, "")
if (!line) continue
if (line.charAt(0) === "@") { section = line; continue }
if (line.charAt(0) === "#") continue
var f = line.split("\t")
if (section === "@ISO") {
if (f.length >= 2 && f[0]) countryName[f[0]] = f[1]
} else if (section === "@ZONE") {
// codes \t coordinates \t zone [\t comment]
if (f.length < 3 || !f[2]) continue
var zone = f[2]
var codes = f[0].split(",")
var acc = zoneCountries[zone] || []
for (var c = 0; c < codes.length; c++) {
var name = countryName[codes[c]]
// Union across both tab files, in first-seen order: a zone can appear
// in each with a different country set (Europe/Zurich is CH,DE,LI).
if (name && acc.indexOf(name) === -1) acc.push(name)
}
zoneCountries[zone] = acc
} else if (section === "@INODE") {
if (f.length < 2 || !f[1]) continue
zoneInode[f[1]] = f[0]
if (!inodeZones[f[0]]) inodeZones[f[0]] = []
inodeZones[f[0]].push(f[1])
} else if (section === "@LIST") {
listed.push(line)
}
}
var out = []
for (var j = 0; j < listed.length; j++) {
var z = listed[j]
var countries = zoneCountries[z]
if (!countries || !countries.length) {
// An alias: borrow from the zones sharing its inode. Union rather than
// first-match, because those siblings are the same clock under several
// countries' names and each tab file may name a different subset —
// taking only the first made Africa/Asmera searchable as Ethiopia but
// not as Eritrea.
var siblings = inodeZones[zoneInode[z]] || []
var merged = []
for (var k = 0; k < siblings.length; k++) {
var sc = zoneCountries[siblings[k]] || []
for (var m = 0; m < sc.length; m++) if (merged.indexOf(sc[m]) === -1) merged.push(sc[m])
}
countries = merged
}
out.push({ zone: z, countries: (countries || []).join(", ") })
}
out.sort(function (a, b) { return a.zone < b.zone ? -1 : (a.zone > b.zone ? 1 : 0) })
return out
}
// Parse the probe's "zone<TAB>+0200" lines back into a lookup.
function parseOffsetReport(raw) {
var out = {}
@@ -323,7 +241,6 @@ if (typeof module !== "undefined" && module.exports) {
buildClock: buildClock,
buildBar: buildBar,
zonesToProbe: zonesToProbe,
parseZoneCatalog: parseZoneCatalog,
parseOffsetReport: parseOffsetReport
}
}
+2 -7
View File
@@ -34,15 +34,10 @@ Item {
readonly property color accent: Color.accent
readonly property color urgent: Color.urgent
// The picker matches on label OR description, so putting the countries in
// the description makes a zone findable by country without knowing that,
// say, Ghana's zone is named after Accra. It shows there too, which is the
// other half of the problem — recognising the right zone once you see it.
readonly property var zoneOptions: {
var list = root.zones ? root.zones.allZones : []
var out = []
for (var i = 0; i < list.length; i++)
out.push({ value: list[i].zone, label: list[i].zone, description: list[i].countries })
for (var i = 0; i < list.length; i++) out.push({ value: list[i], label: list[i] })
return out
}
@@ -269,7 +264,7 @@ Item {
showLabel: false
value: row.modelData.timezone
options: root.zoneOptions
placeholderText: "Search by country or zone..."
placeholderText: "Search timezones..."
triggerLabel: row.modelData.timezone || "Choose a timezone"
foreground: root.foreground
accent: root.accent
+4 -18
View File
@@ -33,7 +33,7 @@ tiles windows above it exactly as it does for the top bar.
On any Omarchy 4 machine:
```bash
omarchy plugin add https://git.nerdnation.co.za/NerdNation/OmarchyClockBar.git --enable --yes
omarchy plugin add <git-url-of-this-repo> --enable --yes
```
The plugin lands in `~/.config/omarchy/plugins/nerdnation.clockbar/`, and
@@ -42,17 +42,10 @@ The plugin lands in `~/.config/omarchy/plugins/nerdnation.clockbar/`, and
To update it later, and to remove it:
```bash
omarchy plugin update nerdnation.clockbar && omarchy restart shell
omarchy plugin update nerdnation.clockbar
omarchy plugin remove nerdnation.clockbar
```
**The `omarchy restart shell` is not optional.** `omarchy plugin update` pulls
the new files and calls `rescanPlugins`, but that does not reload a
`keepLoaded` panel plugin: the shell holds the compiled QML it started with, so
the files on disk change and the bar carries on running the old build. It looks
exactly like the update silently failing. Restarting the shell is what actually
applies it.
> Plugins run as unsandboxed QML inside `omarchy-shell`, so `omarchy plugin add`
> clones disabled by default and wants you to read the code first. `--enable`
> skips that.
@@ -63,15 +56,8 @@ Two routes, one source of truth — both read and write
`~/.config/omarchy/clockbar.json`, and the bar reloads from the file either way.
**Click the bar.** Opens an editor with a searchable timezone picker: add,
rename, retime and remove clocks, set seconds per clock, and set the
working-hours window and bar height. Each row shows that zone's current offset
so you can see the effect before saving.
The picker searches **country names as well as zone names**, so you can find a
zone without knowing it is named after a city — typing `Ghana` finds
`Africa/Accra`. Country data is tzdata's own (`zone1970.tab`, `zone.tab`,
`iso3166.tab`); the legacy alias zones tzdata ships as hard links inherit the
countries of the canonical zone they point at.
rename, retime and remove clocks, and set the working-hours window. Each row
shows that zone's current offset so you can see the effect before saving.
**Edit the file.** It hot-reloads on save, no restart:
+14 -22
View File
@@ -20,8 +20,6 @@ QtObject {
// rendered as unknown rather than being silently shown as UTC.
property var offsets: ({})
property string localOffset: ""
// [{ zone, countries }] for the picker: the zone name plus the countries it
// covers, so it can be found by country as well as by name.
property var allZones: []
property bool ready: false
@@ -77,29 +75,23 @@ QtObject {
}
}
// Emits tzdata's own country tables alongside the zone list, tagged into
// sections. The shell only reads files; the joining is done in ClockModel so
// it can be tested. `right/` and `posix/` are pruned because they duplicate
// every zone under a different inode, which would double the output and
// break the alias-by-inode match.
readonly property string catalogScript:
'printf "@ISO\n"\n' +
'cat /usr/share/zoneinfo/iso3166.tab 2>/dev/null\n' +
'printf "@ZONE\n"\n' +
'cat /usr/share/zoneinfo/zone1970.tab /usr/share/zoneinfo/zone.tab 2>/dev/null\n' +
'printf "@INODE\n"\n' +
'find /usr/share/zoneinfo -type f -not -path "*/right/*" -not -path "*/posix/*" ' +
'-printf "%i\\t%P\\n" 2>/dev/null\n' +
'printf "@LIST\n"\n' +
'timedatectl list-timezones 2>/dev/null || ' +
'find /usr/share/zoneinfo -type f -not -path "*/right/*" -not -path "*/posix/*" ' +
'-printf "%P\\n" 2>/dev/null | grep -E "^[A-Z][A-Za-z_+-]*/" | sort'
property Process zoneListProc: Process {
command: ["sh", "-c", root.catalogScript]
command: ["sh", "-c",
// timedatectl is the canonical list; the zoneinfo walk is the fallback
// for a system where systemd is not answering.
"timedatectl list-timezones 2>/dev/null || " +
"find /usr/share/zoneinfo -type f -printf '%P\\n' 2>/dev/null | grep -E '^[A-Z][A-Za-z_+-]*/' | sort"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.allZones = Model.parseZoneCatalog(text)
onStreamFinished: {
var out = []
var lines = String(text || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].replace(/^\s+|\s+$/g, "")
if (line) out.push(line)
}
root.allZones = out
}
}
}
-71
View File
@@ -249,75 +249,4 @@ test("an unresolved clock keeps the width of the format it asked for", function
assert.strictEqual(bar.right[1].time, "--:--:--")
})
// A miniature stand-in for the tzdata files, so these tests do not depend on
// the host's tzdata version. Africa/Asmera is a hard link to Africa/Nairobi
// (inode 100) and carries no country row of its own.
var CATALOG = [
"@ISO",
"# a comment that must be skipped",
"ZA\tSouth Africa",
"LS\tLesotho",
"ER\tEritrea",
"ET\tEthiopia",
"CH\tSwitzerland",
"DE\tGermany",
"@ZONE",
"ZA,LS\t-2615+02800\tAfrica/Johannesburg",
"CH,DE\t+4723+00832\tEurope/Zurich\tBusingen",
"ER\t+1520+03853\tAfrica/Asmara",
"ET\t+0901+03842\tAfrica/Nairobi",
"@INODE",
"100\tAfrica/Nairobi",
"100\tAfrica/Asmara",
"100\tAfrica/Asmera",
"200\tAfrica/Johannesburg",
"300\tEtc/GMT+5",
"@LIST",
"Africa/Johannesburg",
"Europe/Zurich",
"Africa/Asmera",
"Etc/GMT+5"
].join("\n")
test("the catalogue tags zones with their countries", function () {
var cat = M.parseZoneCatalog(CATALOG)
var by = {}
cat.forEach(function (c) { by[c.zone] = c.countries })
assert.strictEqual(by["Africa/Johannesburg"], "South Africa, Lesotho", "all codes on the row")
assert.strictEqual(by["Europe/Zurich"], "Switzerland, Germany")
})
test("a legacy alias inherits countries from its hard-link siblings", function () {
var by = {}
M.parseZoneCatalog(CATALOG).forEach(function (c) { by[c.zone] = c.countries })
// Asmera has no row of its own; it shares inode 100 with Asmara and Nairobi,
// so it must be findable by BOTH of their countries, not just the first.
assert.ok(by["Africa/Asmera"].indexOf("Eritrea") !== -1, "Eritrea, via Asmara")
assert.ok(by["Africa/Asmera"].indexOf("Ethiopia") !== -1, "Ethiopia, via Nairobi")
})
test("zones with no country are listed, not dropped", function () {
var cat = M.parseZoneCatalog(CATALOG)
var etc = cat.filter(function (c) { return c.zone === "Etc/GMT+5" })[0]
assert.ok(etc, "still offered in the picker")
assert.strictEqual(etc.countries, "", "just has nothing to match a country search")
})
test("the catalogue is sorted and only contains listed zones", function () {
var cat = M.parseZoneCatalog(CATALOG)
var names = cat.map(function (c) { return c.zone })
assert.deepStrictEqual(names, names.slice().sort(), "sorted by zone")
assert.strictEqual(names.indexOf("Africa/Nairobi"), -1,
"Nairobi supplies country data but was not in @LIST, so it is not offered")
assert.strictEqual(names.length, 4)
})
test("a malformed or empty catalogue yields an empty list, not a crash", function () {
assert.deepStrictEqual(M.parseZoneCatalog(""), [])
assert.deepStrictEqual(M.parseZoneCatalog(null), [])
assert.deepStrictEqual(M.parseZoneCatalog("garbage\nwith no sections"), [])
assert.deepStrictEqual(M.parseZoneCatalog("@LIST\nAfrica/Cairo"),
[{ zone: "Africa/Cairo", countries: "" }], "a list with no country data still works")
})
console.log(passed + " passed" + (process.exitCode ? ", some FAILED" : ""))