diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-17 11:34:07 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-17 11:34:07 +0200 |
| commit | 080d3d9aaffa8848d6de8546511f096d8e7c30f5 (patch) | |
| tree | 2663bd5c774d8db7fa024e82546f6cfa04c07e29 /desktop/modules/qtmaildir | |
| parent | b8c0f01f058c87db5924067dbb475aa241c71b46 (diff) | |
| download | quickshell-080d3d9aaffa8848d6de8546511f096d8e7c30f5.tar.gz quickshell-080d3d9aaffa8848d6de8546511f096d8e7c30f5.zip | |
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 <noreply@anthropic.com>
Diffstat (limited to 'desktop/modules/qtmaildir')
| -rw-r--r-- | desktop/modules/qtmaildir/MailAccountsModule.qml | 5 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/MailComposeModule.qml | 5 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/MailGeneralModule.qml | 5 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/MailTagsKeysModule.qml | 5 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/Qtm.qml | 83 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/README.md | 9 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/Setting.qml | 50 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/TagsKeysPage.qml | 58 |
8 files changed, 159 insertions, 61 deletions
diff --git a/desktop/modules/qtmaildir/MailAccountsModule.qml b/desktop/modules/qtmaildir/MailAccountsModule.qml index f634c8d..a77dbac 100644 --- a/desktop/modules/qtmaildir/MailAccountsModule.qml +++ b/desktop/modules/qtmaildir/MailAccountsModule.qml @@ -20,6 +20,7 @@ Module { icon: "\uf0c0" settings: true group: "Mail" + dirty: Qtm.dirty keywords: ["qtmaildir", "account", "address", "maildir", "trash", "spam", "drafts", "sent", "inbox", "msmtp", "send command", "label", "colour", "color", "channel", "mbsync"] searchItems: Qtm.accounts.map(a => ({ text: a + " " + (Qtm.get("account." + a, "address") ?? ""), @@ -29,6 +30,10 @@ Module { page: Component { Page { title: "Accounts" + // One draft behind all four Mail pages: Save writes them all. + actions: Component { + SaveBar { dirty: Qtm.dirty; valid: Qtm.valid; onSave: Qtm.save(); onDiscard: Qtm.discard() } + } AccountsPage { width: parent.width } } } diff --git a/desktop/modules/qtmaildir/MailComposeModule.qml b/desktop/modules/qtmaildir/MailComposeModule.qml index e34fc07..6a9a198 100644 --- a/desktop/modules/qtmaildir/MailComposeModule.qml +++ b/desktop/modules/qtmaildir/MailComposeModule.qml @@ -20,6 +20,7 @@ Module { icon: "\uf040" settings: true group: "Mail" + dirty: Qtm.dirty keywords: ["qtmaildir", "html", "quote", "reply", "send", "undo", "autosave", "attachment", "draft"] searchItems: [["send_html", "Send HTML"], ["quote_position", "Quote"], ["default_account", "Default account"], ["send_delay_ms", "Undo send window"], ["autosave_interval_ms", "Autosave every"], @@ -30,6 +31,10 @@ Module { page: Component { Page { title: "Compose" + // One draft behind all four Mail pages: Save writes them all. + actions: Component { + SaveBar { dirty: Qtm.dirty; valid: Qtm.valid; onSave: Qtm.save(); onDiscard: Qtm.discard() } + } ComposePage { width: parent.width } } } diff --git a/desktop/modules/qtmaildir/MailGeneralModule.qml b/desktop/modules/qtmaildir/MailGeneralModule.qml index b531579..02ecd5a 100644 --- a/desktop/modules/qtmaildir/MailGeneralModule.qml +++ b/desktop/modules/qtmaildir/MailGeneralModule.qml @@ -21,6 +21,7 @@ Module { icon: "\uf0e0" settings: true group: "Mail" + dirty: Qtm.dirty keywords: ["qtmaildir", "startup", "language", "toolbar", "zoom", "date", "sync", "contacts", "calendar", "notmuch", "mimetype", "forward"] readonly property var fields: [ @@ -40,6 +41,10 @@ Module { page: Component { Page { title: "Mail" + // One draft behind all four Mail pages: Save writes them all. + actions: Component { + SaveBar { dirty: Qtm.dirty; valid: Qtm.valid; onSave: Qtm.save(); onDiscard: Qtm.discard() } + } GeneralPage { width: parent.width } } } diff --git a/desktop/modules/qtmaildir/MailTagsKeysModule.qml b/desktop/modules/qtmaildir/MailTagsKeysModule.qml index b4c903c..0c1eade 100644 --- a/desktop/modules/qtmaildir/MailTagsKeysModule.qml +++ b/desktop/modules/qtmaildir/MailTagsKeysModule.qml @@ -20,6 +20,7 @@ Module { icon: "\uf02c" settings: true group: "Mail" + dirty: Qtm.dirty keywords: ["qtmaildir", "tag", "colour", "color", "shortcut", "key", "binding", "action"] searchItems: Qtm.entries("tagcolors").map(e => ({ text: "tag: " + e.key, key: "tag:" + e.key })) .concat(Qtm.entries("keys").map(e => ({ text: e.key + ": " + e.value, key: "key:" + e.key }))) @@ -28,6 +29,10 @@ Module { page: Component { Page { title: "Tags & shortcuts" + // One draft behind all four Mail pages: Save writes them all. + actions: Component { + SaveBar { dirty: Qtm.dirty; valid: Qtm.valid; onSave: Qtm.save(); onDiscard: Qtm.discard() } + } TagsKeysPage { width: parent.width } } } 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.<key>] 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) } diff --git a/desktop/modules/qtmaildir/README.md b/desktop/modules/qtmaildir/README.md index 250ceb3..e74d9cc 100644 --- a/desktop/modules/qtmaildir/README.md +++ b/desktop/modules/qtmaildir/README.md @@ -17,9 +17,12 @@ Four settings pages, under Mail, for qtmaildir's qtmaildir reads the file at startup and never writes it, so editing it under a running qtmaildir is safe; the pages say when it is running and a -change waits for its restart. A field saves on Enter, and emptied it unsets -the key, so qtmaildir's default applies; the placeholders name those -defaults, from qtmaildir's README. Numbers are range-checked before writing. +change waits for its restart. Every field, switch and row edits one draft +shared by the four pages, and Save in any page's header writes it all; +Discard drops it. Emptied, a field unsets the key, so qtmaildir's default +applies; the placeholders name those defaults, from qtmaildir's README. +Numbers are range-checked, and a refused one holds Save back. A save over a +file changed on disk since the draft began is refused rather than lost. `Ini.js` edits the file a line at a time so its comments and order survive: a key unset is commented out with `;`, a commented key set again is diff --git a/desktop/modules/qtmaildir/Setting.qml b/desktop/modules/qtmaildir/Setting.qml index 66d0a46..78103bf 100644 --- a/desktop/modules/qtmaildir/Setting.qml +++ b/desktop/modules/qtmaildir/Setting.qml @@ -12,10 +12,11 @@ import QtQuick import "../.." -// One labelled key of qtmaildir.conf: a text field that saves on Enter. -// Emptied, the key is unset and qtmaildir uses its default, which the -// placeholder names. kind "int" or "float" checks the number and its range -// before writing; a refused value shows why under the row. +// One labelled key of qtmaildir.conf: a text field that edits the draft as +// it is typed; the page's Save writes it. Emptied, the key is unset and +// qtmaildir uses its default, which the placeholder names. kind "int" or +// "float" checks the number and its range: a refused value stays out of the +// draft, shows why under the row, and holds Save back until it is fixed. Column { id: s property string section @@ -36,20 +37,31 @@ Column { readonly property string value: Qtm.get(s.section, s.key) ?? "" property string problem: "" - function commit(t) { + function apply(t) { t = t.trim(); - if (t === "") { s.problem = ""; Qtm.set(s.section, s.key, null); return; } - if (s.kind !== "text") { + let bad = ""; + if (t !== "" && s.kind !== "text") { const n = Number(t); - if (isNaN(n) || (s.kind === "int" && !Number.isInteger(n)) || n < s.min || n > s.max) { - s.problem = s.kind === "int" ? `a whole number from ${s.min} to ${s.max}` - : `a number from ${s.min} to ${s.max}`; - return; - } + if (isNaN(n) || (s.kind === "int" && !Number.isInteger(n)) || n < s.min || n > s.max) + bad = s.kind === "int" ? `a whole number from ${s.min} to ${s.max}` + : `a number from ${s.min} to ${s.max}`; } - s.problem = ""; - Qtm.set(s.section, s.key, t); + s.problem = bad; + Qtm.markInvalid(s.objectName, bad !== ""); + if (bad === "") Qtm.set(s.section, s.key, t === "" ? null : t); + } + + // The field is not bound to the value, which would reset the cursor and + // eat a typed trailing space; it follows the value only when the draft + // moves under it (Discard, a colour picked, a save of another page). + onValueChanged: if (ti.text.trim() !== s.value && s.problem === "") ti.text = s.value + Connections { + target: Qtm + function onInvalidChanged() { if (s.problem !== "" && !Qtm.invalid[s.objectName]) { s.problem = ""; ti.text = s.value; } } } + // A field that goes away cannot hold Save back; the draft keeps its last + // good value. + Component.onDestruction: Qtm.markInvalid(s.objectName, false) Item { width: s.width @@ -69,7 +81,7 @@ Column { visible: s.colour width: s.colour ? 34 : 0 colour: s.value - onPicked: hex => s.commit(hex) + onPicked: hex => s.apply(hex) } Rectangle { anchors { left: lbl.right; leftMargin: 8; right: pick.left; rightMargin: s.colour ? 8 : 0 @@ -84,12 +96,8 @@ Column { selectByMouse: true color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } - text: s.value - onAccepted: if (text !== s.value) s.commit(text) - Keys.onEscapePressed: event => { - if (text !== s.value) text = s.value; - else event.accepted = false; - } + Component.onCompleted: text = s.value + onTextEdited: s.apply(text) Text { anchors.verticalCenter: parent.verticalCenter width: parent.width diff --git a/desktop/modules/qtmaildir/TagsKeysPage.qml b/desktop/modules/qtmaildir/TagsKeysPage.qml index b47e1f2..709e943 100644 --- a/desktop/modules/qtmaildir/TagsKeysPage.qml +++ b/desktop/modules/qtmaildir/TagsKeysPage.qml @@ -15,7 +15,7 @@ import "../.." // [tagcolors] and [keys]: free-form rows, each removable, with an add row. // A row's key is its identity, so changing a tag or a shortcut is a remove -// and an add. +// and an add. Everything edits the shared draft; the page's Save writes it. Column { id: page spacing: 6 @@ -52,6 +52,7 @@ Column { property alias text: ti.text property string placeholder: "" signal accepted + signal edited height: 34 radius: 8 color: Theme.surface @@ -64,6 +65,7 @@ Column { color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } onAccepted: parent.accepted() + onTextEdited: parent.edited() Text { anchors.verticalCenter: parent.verticalCenter visible: parent.text === "" @@ -86,11 +88,12 @@ Column { } Repeater { - model: Qtm.entries("tagcolors") + model: Qtm.tags delegate: Item { id: tagRow - required property var modelData - objectName: "tag:" + modelData.key + required property string modelData + readonly property string colour: Qtm.get("tagcolors", modelData) ?? "" + objectName: "tag:" + modelData width: page.width height: 34 Text { @@ -98,29 +101,35 @@ Column { anchors { left: parent.left; verticalCenter: parent.verticalCenter } width: 226 elide: Text.ElideRight - text: tagRow.modelData.key + text: tagRow.modelData color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } } Box { anchors { left: tagName.right; leftMargin: 8; right: tagPick.left; rightMargin: 8; verticalCenter: parent.verticalCenter } - text: tagRow.modelData.value - onAccepted: if (/^#[0-9a-fA-F]{6}$/.test(text)) Qtm.set("tagcolors", tagRow.modelData.key, text) + text: tagRow.colour + // A half-typed colour stays out of the draft and holds Save. + onEdited: { + const ok = /^#[0-9a-fA-F]{6}$/.test(text); + Qtm.markInvalid("tag:" + tagRow.modelData, !ok); + if (ok) Qtm.set("tagcolors", tagRow.modelData, text); + } + Component.onDestruction: Qtm.markInvalid("tag:" + tagRow.modelData, false) } ColourPick { id: tagPick anchors { right: tagDel.left; rightMargin: 8; verticalCenter: parent.verticalCenter } - colour: tagRow.modelData.value - onPicked: hex => Qtm.set("tagcolors", tagRow.modelData.key, hex) + colour: tagRow.colour + onPicked: hex => { Qtm.markInvalid("tag:" + tagRow.modelData, false); Qtm.set("tagcolors", tagRow.modelData, hex); } } Button { id: tagDel anchors { right: parent.right; verticalCenter: parent.verticalCenter } danger: true - text: page.confirming === "tag:" + tagRow.modelData.key ? "Confirm" : "Remove" + text: page.confirming === "tag:" + tagRow.modelData ? "Confirm" : "Remove" onClicked: { - if (page.confirming === "tag:" + tagRow.modelData.key) { page.confirming = ""; Qtm.remove("tagcolors", tagRow.modelData.key); } - else page.confirming = "tag:" + tagRow.modelData.key; + if (page.confirming === "tag:" + tagRow.modelData) { page.confirming = ""; Qtm.remove("tagcolors", tagRow.modelData); } + else page.confirming = "tag:" + tagRow.modelData; } } } @@ -162,11 +171,12 @@ Column { } Repeater { - model: Qtm.entries("keys") + model: Qtm.shortcuts delegate: Item { id: keyRow - required property var modelData - objectName: "key:" + modelData.key + required property string modelData + readonly property string action: Qtm.get("keys", modelData) ?? "" + objectName: "key:" + modelData width: page.width height: 34 Text { @@ -174,24 +184,28 @@ Column { anchors { left: parent.left; verticalCenter: parent.verticalCenter } width: 226 elide: Text.ElideRight - text: keyRow.modelData.key + text: keyRow.modelData color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } } Combo { anchors { left: keySeq.right; leftMargin: 8; right: keyDel.left; rightMargin: 8; verticalCenter: parent.verticalCenter } - Component.onCompleted: editText = keyRow.modelData.value - onAccepted: if (editText.trim() !== "") Qtm.set("keys", keyRow.modelData.key, editText.trim()) - onActivated: i => Qtm.set("keys", keyRow.modelData.key, page.actions[i]) + Component.onCompleted: editText = keyRow.action + // Only a real change: rewriting an equal value would still + // normalise its line and mark the page changed for nothing. + onEditTextChanged: { + const a = editText.trim(); + if (a !== "" && a !== keyRow.action) Qtm.set("keys", keyRow.modelData, a); + } } Button { id: keyDel anchors { right: parent.right; verticalCenter: parent.verticalCenter } danger: true - text: page.confirming === "key:" + keyRow.modelData.key ? "Confirm" : "Remove" + text: page.confirming === "key:" + keyRow.modelData ? "Confirm" : "Remove" onClicked: { - if (page.confirming === "key:" + keyRow.modelData.key) { page.confirming = ""; Qtm.remove("keys", keyRow.modelData.key); } - else page.confirming = "key:" + keyRow.modelData.key; + if (page.confirming === "key:" + keyRow.modelData) { page.confirming = ""; Qtm.remove("keys", keyRow.modelData); } + else page.confirming = "key:" + keyRow.modelData; } } } |
