Files
OmarchyClockBar/Editor.qml
T
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

498 lines
16 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
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], label: list[i] })
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 timezones..."
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)
}
}
}
}
}
}
}