Finding a zone meant already knowing its name, which is the wrong way round when the thing you know is the country -- Ghana's zone is named after Accra, and nothing in the picker said so. Zones now carry the countries they cover, drawn from tzdata's own tables: zone1970.tab and zone.tab for the zone-to-country-code rows, iso3166.tab for the names. The picker already matched on description as well as label, so putting the countries there makes them searchable and shows them in the list. Those tables only name about 420 of the ~600 zones; the rest are backward-compatibility aliases like America/Buenos_Aires and US/Pacific, which tzdata ships as hard links to their canonical zone. Matching an alias to its canonical name by inode lifts coverage to 553, and the 45 left are the Etc/GMT pseudo-zones, which have no country by definition. The inherited countries are the union of every zone sharing the inode, not the first one found: those siblings are one clock under several countries' names, and each table names a different subset, so first-match made Africa/Asmera findable as Ethiopia but not as Eritrea. Joining is done in ClockModel against a fixture, so it does not depend on the host's tzdata version; the shell side only reads the files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MhxtL7WzL5XmuTd8GHbcTY
503 lines
17 KiB
QML
503 lines
17 KiB
QML
import QtQuick
|
|
import QtQuick.Layouts
|
|
import Quickshell
|
|
import Quickshell.Wayland
|
|
import qs.Commons
|
|
import qs.Ui
|
|
import "ClockModel.js" as Model
|
|
|
|
// The click-to-edit surface for the clock bar: add, rename, retime and remove
|
|
// clocks without touching JSON.
|
|
//
|
|
// It edits a draft copy and only writes on Save, so an abandoned edit leaves
|
|
// the file untouched — and because the write goes through the same Config
|
|
// object the file watcher feeds, the bar updates from disk either way. There
|
|
// is no second source of truth for the two config routes to disagree about.
|
|
Item {
|
|
id: root
|
|
|
|
property var configFile: null
|
|
property var zones: null
|
|
|
|
readonly property bool opened: controller.open
|
|
property var draft: []
|
|
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
|
|
readonly property color muted: Color.muted
|
|
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 })
|
|
return out
|
|
}
|
|
|
|
QtObject {
|
|
id: controller
|
|
property bool open: false
|
|
}
|
|
|
|
function open() {
|
|
loadDraft()
|
|
controller.open = true
|
|
}
|
|
|
|
function close() { controller.open = false }
|
|
|
|
// A deep copy: editing the live config array in place would push
|
|
// half-finished rows straight onto the bar.
|
|
function loadDraft() {
|
|
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++) {
|
|
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. That rebuild is also
|
|
// why edits are committed on focus loss rather than on every keystroke:
|
|
// replacing the array re-creates the row delegates, which would throw away
|
|
// the field you are typing into.
|
|
function replaceDraft(next) { root.draft = next }
|
|
|
|
// Run an action that reads or replaces the draft, but only after every
|
|
// control has committed what is currently in it.
|
|
//
|
|
// A text field commits on `editingFinished` and a spin box parses its typed
|
|
// text on focus loss, so a value typed and then acted on directly — type a
|
|
// label, click Save — was never in the draft the action read, and the typed
|
|
// text was lost with the delegate. Moving focus first makes each control
|
|
// commit through its own normal path; focus goes back to the panel's key
|
|
// scope so Escape keeps working.
|
|
function withPendingEdits(action) {
|
|
keyScope.forceActiveFocus()
|
|
Qt.callLater(action)
|
|
}
|
|
|
|
// 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 = {}
|
|
for (var k in next[index]) row[k] = next[index][k]
|
|
row[key] = value
|
|
next[index] = row
|
|
replaceDraft(next)
|
|
}
|
|
|
|
function removeRow(index) {
|
|
var next = root.draft.slice()
|
|
next.splice(index, 1)
|
|
replaceDraft(next)
|
|
}
|
|
|
|
function addRow() {
|
|
var next = root.draft.slice()
|
|
next.push({ label: "", timezone: "" })
|
|
replaceDraft(next)
|
|
}
|
|
|
|
// A row is only saveable once it names a zone; the label falls back to the
|
|
// zone's own name so a clock can never render blank.
|
|
function saveableRows() {
|
|
var out = []
|
|
for (var i = 0; i < root.draft.length; i++) {
|
|
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, "")
|
|
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
|
|
}
|
|
|
|
function save() {
|
|
if (!root.configFile) return
|
|
var cfg = root.configFile.config
|
|
root.configFile.save({
|
|
version: 1,
|
|
clocks: saveableRows(),
|
|
workingHours: {
|
|
start: root.workStart,
|
|
end: root.workEnd,
|
|
workdays: cfg && cfg.workingHours ? cfg.workingHours.workdays : [1, 2, 3, 4, 5]
|
|
},
|
|
dimOutsideWorkingHours: root.dimOutside,
|
|
showSeconds: root.defaultSeconds,
|
|
local: { showSeconds: root.localSeconds },
|
|
barSize: root.barSize
|
|
})
|
|
close()
|
|
}
|
|
|
|
// ------------------------------------------------------------ the surface
|
|
|
|
PanelWindow {
|
|
id: window
|
|
visible: root.opened
|
|
anchors { top: true; bottom: true; left: true; right: true }
|
|
color: "transparent"
|
|
WlrLayershell.namespace: "nerdnation-clockbar-editor"
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
|
exclusionMode: ExclusionMode.Ignore
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
color: Qt.rgba(0, 0, 0, 0.45)
|
|
}
|
|
|
|
// Click-away closes; the card swallows its own clicks so a click inside
|
|
// does not fall through to this one.
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: root.close()
|
|
}
|
|
|
|
BorderSurface {
|
|
id: card
|
|
anchors.centerIn: parent
|
|
width: Style.space(560)
|
|
height: Math.min(parent.height - Style.space(80), content.implicitHeight + card.contentTopInset + card.contentBottomInset)
|
|
radius: Style.cornerRadius
|
|
color: root.background
|
|
borderSpec: Border.localOrSurfaceSpec("popups", "border", Color.popups.border, Color.popups.border, Math.max(1, Style.space(2)))
|
|
padding: Style.spacing.popupPadding
|
|
|
|
MouseArea { anchors.fill: parent; onClicked: {} }
|
|
|
|
Item {
|
|
id: keyScope
|
|
anchors.fill: parent
|
|
anchors.margins: card.padding
|
|
focus: true
|
|
Keys.onEscapePressed: root.close()
|
|
|
|
ColumnLayout {
|
|
id: content
|
|
anchors.fill: parent
|
|
spacing: Style.space(10)
|
|
|
|
PanelSectionHeader {
|
|
Layout.fillWidth: true
|
|
text: "Clock bar"
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: "Clocks are placed automatically: behind your local time to the left, ahead to the right."
|
|
color: root.muted
|
|
wrapMode: Text.WordWrap
|
|
font.family: Style.font.family
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
|
|
// ------------------------------------------------------- the rows
|
|
|
|
Flickable {
|
|
Layout.fillWidth: true
|
|
// Tall enough for the rows it has, up to a cap that keeps the card
|
|
// on screen with a long clock list; past that it scrolls.
|
|
Layout.preferredHeight: Math.min(rows.implicitHeight, Style.space(300))
|
|
contentHeight: rows.implicitHeight
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
ColumnLayout {
|
|
id: rows
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
|
|
Repeater {
|
|
model: root.draft
|
|
|
|
delegate: RowLayout {
|
|
id: row
|
|
required property int index
|
|
required property var modelData
|
|
|
|
Layout.fillWidth: true
|
|
spacing: Style.space(8)
|
|
|
|
TextField {
|
|
Layout.preferredWidth: Style.space(66)
|
|
text: row.modelData.label
|
|
placeholderText: "Label"
|
|
foreground: root.foreground
|
|
accent: root.accent
|
|
onEditingFinished: root.updateRow(row.index, "label", text)
|
|
}
|
|
|
|
SearchableDropdown {
|
|
Layout.fillWidth: true
|
|
showLabel: false
|
|
value: row.modelData.timezone
|
|
options: root.zoneOptions
|
|
placeholderText: "Search by country or zone..."
|
|
triggerLabel: row.modelData.timezone || "Choose a timezone"
|
|
foreground: root.foreground
|
|
accent: root.accent
|
|
onChanged: function (value) {
|
|
var i = row.index
|
|
root.withPendingEdits(function () { root.updateRow(i, "timezone", value) })
|
|
}
|
|
}
|
|
|
|
// The live offset, so the effect of a choice is visible
|
|
// before saving rather than only once the bar redraws.
|
|
Text {
|
|
Layout.preferredWidth: Style.space(52)
|
|
horizontalAlignment: Text.AlignRight
|
|
text: {
|
|
var off = root.zones && root.zones.offsets ? root.zones.offsets[row.modelData.timezone] : null
|
|
return off ? off : (row.modelData.timezone ? "—" : "")
|
|
}
|
|
color: root.muted
|
|
font.family: Style.font.family
|
|
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: {
|
|
var i = row.index
|
|
var next = !root.rowShowsSeconds(row.modelData)
|
|
root.withPendingEdits(function () { root.updateRow(i, "showSeconds", next) })
|
|
}
|
|
}
|
|
|
|
Button {
|
|
iconText: ""
|
|
tooltipText: "Remove this clock"
|
|
foreground: root.foreground
|
|
accent: root.urgent
|
|
onClicked: {
|
|
var i = row.index
|
|
root.withPendingEdits(function () { root.removeRow(i) })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
visible: root.draft.length === 0
|
|
text: "No clocks yet — add one below."
|
|
color: root.muted
|
|
font.family: Style.font.family
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
}
|
|
}
|
|
|
|
Button {
|
|
Layout.alignment: Qt.AlignLeft
|
|
text: "+ Add clock"
|
|
bordered: true
|
|
foreground: root.foreground
|
|
accent: root.accent
|
|
onClicked: root.withPendingEdits(root.addRow)
|
|
}
|
|
|
|
PanelSeparator { Layout.fillWidth: true }
|
|
|
|
// -------------------------------------------------- working hours
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: Style.space(8)
|
|
|
|
ToggleSwitch {
|
|
checked: root.dimOutside
|
|
foreground: root.foreground
|
|
accent: root.accent
|
|
onToggled: root.dimOutside = !root.dimOutside
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: "Dim clocks outside working hours"
|
|
color: root.foreground
|
|
elide: Text.ElideRight
|
|
font.family: Style.font.family
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: Style.space(8)
|
|
opacity: root.dimOutside ? 1.0 : 0.45
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: "Working hours (local to each clock)"
|
|
color: root.muted
|
|
elide: Text.ElideRight
|
|
font.family: Style.font.family
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
|
|
NumberField {
|
|
Layout.preferredWidth: Style.spacing.numberFieldWidth
|
|
enabled: root.dimOutside
|
|
value: root.workStart
|
|
from: 0
|
|
to: 24
|
|
foreground: root.foreground
|
|
accent: root.accent
|
|
onModified: function (v) { root.workStart = v }
|
|
}
|
|
|
|
Text {
|
|
text: "to"
|
|
color: root.muted
|
|
font.family: Style.font.family
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
|
|
NumberField {
|
|
Layout.preferredWidth: Style.spacing.numberFieldWidth
|
|
enabled: root.dimOutside
|
|
value: root.workEnd
|
|
from: 0
|
|
to: 24
|
|
foreground: root.foreground
|
|
accent: root.accent
|
|
onModified: function (v) { root.workEnd = v }
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: root.configFile ? root.configFile.path : ""
|
|
color: root.muted
|
|
elide: Text.ElideMiddle
|
|
font.family: Style.font.family
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
|
|
Button {
|
|
text: "Cancel"
|
|
bordered: true
|
|
foreground: root.foreground
|
|
accent: root.accent
|
|
onClicked: root.close()
|
|
}
|
|
|
|
Button {
|
|
text: "Save"
|
|
bordered: true
|
|
selected: true
|
|
foreground: root.foreground
|
|
accent: root.accent
|
|
onClicked: root.withPendingEdits(root.save)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|