From 080d3d9aaffa8848d6de8546511f096d8e7c30f5 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 17 Oct 2026 11:34:07 +0200 Subject: settings: pages edit a draft, and Save writes it The qtmaildir, Assistant and Ask presets pages wrote a field on Enter, and a switch or chip the moment it was clicked, so a half-finished change reached the file and a slip could not be taken back. They now edit a draft as fields are typed, and a Save and Discard pair in the page header (SaveBar, through Page.actions) writes or drops it. Save waits while a field holds a value that cannot be written, and a draft over a file that changed on disk meanwhile is refused at Save rather than written over it. A dot in the sidebar marks a page with unsaved edits; drafts survive moving between pages. The four Mail pages share one draft, since they are one file. Tool switches are staged too and renamed on Save; model Load, Prefer and Restart stay immediate, being actions rather than settings. Live editing needed two rules. A field is no longer bound to its value, which would reset the cursor and eat a typed trailing space; it follows the value only when the draft moves under it. And a Repeater's model is now the set of row keys (or a count), assigned only when that set changes: an array rebuilt from the draft on each keystroke recreates every row, including the one being typed in, which loses its focus. Git, autostart's rows, HyprSunset, HyprIdle and User keep their own buttons. Co-Authored-By: Claude Opus 5.5 --- desktop/modules/qtmaildir/Qtm.qml | 83 ++++++++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 15 deletions(-) (limited to 'desktop/modules/qtmaildir/Qtm.qml') diff --git a/desktop/modules/qtmaildir/Qtm.qml b/desktop/modules/qtmaildir/Qtm.qml index 744bcb4..3f6175b 100644 --- a/desktop/modules/qtmaildir/Qtm.qml +++ b/desktop/modules/qtmaildir/Qtm.qml @@ -19,35 +19,83 @@ import "Ini.js" as I // qtmaildir.conf, shared by the four Mail settings pages. qtmaildir reads // it at startup and never writes it, so editing it under a running // qtmaildir is safe and applies on its next launch. +// +// The pages edit a draft, never the file: every field writes to it as it is +// typed, and save() writes it out. The draft is shared, so one Save writes +// what all four pages changed, and it survives moving between pages. Singleton { id: root readonly property string path: (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/qtmaildir/qtmaildir.conf" + // The file as last read or written. property string text: "" + // The file as the draft started from, and the draft. + property string base: "" + property string draft: "" + readonly property bool dirty: root.draft !== root.base + // Fields holding a value that cannot be written, by section/key; Save + // waits until there are none. + property var invalid: ({}) + readonly property bool valid: Object.keys(root.invalid).length === 0 property string error: "" // Whether qtmaildir runs now, so a page can say a change waits for a // restart. Rechecked on every refresh(). property bool running: false - // Keys of the [account.] sections, in file order. - readonly property var accounts: I.sections(root.text).filter(s => s.startsWith("account.")).map(s => s.slice(8)) + // The row keys the pages repeat over: account keys, tags, shortcuts. + // Assigned only when the set changes, never bound to the draft: a new + // array rebuilds every row of a Repeater, and since every keystroke + // makes a new draft, a bound model would rebuild the row being typed in + // and drop its focus after each character. Rows read their values live. + property var accounts: [] + property var tags: [] + property var shortcuts: [] + function same(a, b) { return JSON.stringify(a) === JSON.stringify(b); } + onDraftChanged: { + const acc = I.sections(root.draft).filter(s => s.startsWith("account.")).map(s => s.slice(8)); + const tg = I.entries(root.draft, "tagcolors").map(e => e.key); + const sc = I.entries(root.draft, "keys").map(e => e.key); + if (!root.same(acc, root.accounts)) root.accounts = acc; + if (!root.same(tg, root.tags)) root.tags = tg; + if (!root.same(sc, root.shortcuts)) root.shortcuts = sc; + } - function get(section, key) { return I.get(root.text, section, key); } - function entries(section) { return I.entries(root.text, section); } + function get(section, key) { return I.get(root.draft, section, key); } + function entries(section) { return I.entries(root.draft, section); } // val null unsets: the line is commented out where it stands. - function set(section, key, val) { root.write(I.set(root.text, section, key, val)); } - function remove(section, key) { root.write(I.remove(root.text, section, key)); } - function addAccount(key, pairs) { root.write(I.addSection(root.text, "account." + key, pairs)); } - function removeAccount(key) { root.write(I.removeSection(root.text, "account." + key)); } + function set(section, key, val) { root.draft = I.set(root.draft, section, key, val); } + function remove(section, key) { root.draft = I.remove(root.draft, section, key); } + function addAccount(key, pairs) { root.draft = I.addSection(root.draft, "account." + key, pairs); } + function removeAccount(key) { root.draft = I.removeSection(root.draft, "account." + key); } + + function markInvalid(id, bad) { + if (!!root.invalid[id] === bad) return; + const next = Object.assign({}, root.invalid); + if (bad) next[id] = true; else delete next[id]; + root.invalid = next; + } + // Refused when the file changed on disk since the draft began: writing + // would discard that change unseen. Discard then shows the new file. // Never empty, since a header always remains, so FileView.setText is safe // here (AGENTS.md: it writes nothing for empty text on an unloaded path). - function write(t) { - if (t === root.text) return; + function save() { + if (root.text !== root.base) { + root.error = "qtmaildir.conf changed on disk since these edits began. Discard to load it, then redo them."; + return; + } + root.error = ""; + root.text = root.draft; + root.base = root.draft; + fv.setText(root.draft); + } + + function discard() { root.error = ""; - root.text = t; - fv.setText(t); + root.invalid = ({}); + root.base = root.text; + root.draft = root.text; } function refresh() { @@ -62,10 +110,15 @@ Singleton { atomicWrites: true watchChanges: true printErrors: false - // Its own writes come back here too; reparsing the same text is a no-op. + // Its own writes come back here too, as the text already held. onFileChanged: reload() - onLoaded: root.text = text() - onLoadFailed: root.text = "" + // A clean draft follows the file; a dirty one is kept, and save() + // notices the file moved under it. + onLoaded: { + root.text = text(); + if (!root.dirty) { root.base = root.text; root.draft = root.text; } + } + onLoadFailed: if (!root.dirty) { root.text = ""; root.base = ""; root.draft = ""; } onSaveFailed: err => root.error = "Cannot write " + root.path + ": " + FileViewError.toString(err) } -- cgit v1.2.3