aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/qtmaildir
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules/qtmaildir')
-rw-r--r--desktop/modules/qtmaildir/MailAccountsModule.qml5
-rw-r--r--desktop/modules/qtmaildir/MailComposeModule.qml5
-rw-r--r--desktop/modules/qtmaildir/MailGeneralModule.qml5
-rw-r--r--desktop/modules/qtmaildir/MailTagsKeysModule.qml5
-rw-r--r--desktop/modules/qtmaildir/Qtm.qml83
-rw-r--r--desktop/modules/qtmaildir/README.md9
-rw-r--r--desktop/modules/qtmaildir/Setting.qml50
-rw-r--r--desktop/modules/qtmaildir/TagsKeysPage.qml58
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;
}
}
}