diff options
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; } } } |
