diff options
Diffstat (limited to 'desktop/modules')
| -rw-r--r-- | desktop/modules/assistant/AssistantModule.qml | 96 | ||||
| -rw-r--r-- | desktop/modules/assistant/AssistantPage.qml | 94 | ||||
| -rw-r--r-- | desktop/modules/assistant/PresetsModule.qml | 30 | ||||
| -rw-r--r-- | desktop/modules/assistant/PresetsPage.qml | 60 | ||||
| -rw-r--r-- | desktop/modules/assistant/README.md | 13 | ||||
| -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 |
13 files changed, 353 insertions, 160 deletions
diff --git a/desktop/modules/assistant/AssistantModule.qml b/desktop/modules/assistant/AssistantModule.qml index 8253cc6..f738ea6 100644 --- a/desktop/modules/assistant/AssistantModule.qml +++ b/desktop/modules/assistant/AssistantModule.qml @@ -42,10 +42,29 @@ Module { readonly property var labels: ({ llm: "Server", system: "System prompt", tool_rounds: "Tool rounds", temperature: "Temperature", thinking: "Thinking" }) - // config.toml as last read or written; "" when there is none. + // config.toml as last read or written; "" when there is none. The page + // edits draft, which save() writes; base is the file the draft began + // from, so a save over a file changed meanwhile is refused. property string text: "" - readonly property var values: C.scalars(am.text, am.keys) - readonly property var envList: C.env(am.text) + property string base: "" + property string draft: "" + readonly property var values: C.scalars(am.draft, am.keys) + readonly property var envList: C.env(am.draft) + // The [env] keys, assigned only when the set changes: a model bound to the + // draft would rebuild every row on each keystroke and drop the focus of + // the one being typed in. Rows read their values from envValue(). + property var envKeys: [] + onDraftChanged: { + const k = C.env(am.draft).map(e => e.key); + if (JSON.stringify(k) !== JSON.stringify(am.envKeys)) am.envKeys = k; + } + function envValue(key) { return (am.envList.find(e => e.key === key) ?? { value: "" }).value; } + // Tool switches flipped but not saved: name -> wanted on. + property var toolsWanted: ({}) + // Fields holding a value that cannot be written; Save waits for none. + property var invalid: ({}) + readonly property bool valid: Object.keys(am.invalid).length === 0 + dirty: am.draft !== am.base || Object.keys(am.toolsWanted).length > 0 // The daemon's built-in values, from assistant.py --defaults. property var defaults: ({}) // [{name, on, description}] @@ -87,8 +106,12 @@ Module { watchChanges: true printErrors: false onFileChanged: reload() - onLoaded: am.text = text() - onLoadFailed: am.text = "" + // A clean draft follows the file; a dirty one is kept for save(). + onLoaded: { + am.text = text(); + if (am.draft === am.base) { am.base = am.text; am.draft = am.text; } + } + onLoadFailed: if (am.draft === am.base) { am.text = ""; am.base = ""; am.draft = ""; } } // One Process per call, so calls never share one (a running Process @@ -130,15 +153,40 @@ Module { + "with open(p + '.tmp', 'w') as f:\n f.write(t)\n" + "os.replace(p + '.tmp', p)\n" - function save(t) { + // The config first, then the tool renames, each only when there is + // something to do; any failure stops there and says why. + function save() { + if (am.text !== am.base) { + am.error = "config.toml changed on disk since these edits began. Discard to load it, then redo them."; + return; + } + am.error = ""; + const t = am.draft; + const tools = () => am.applyTools(Object.keys(am.toolsWanted), () => { am.pending = true; am.listTools(); }); + if (t === am.text) { tools(); return; } am.run([am.python, "-c", am.writer, am.cfgPath], t, (code, out, err) => { - if (code === 0) { am.text = t; am.pending = true; am.error = ""; } - else am.error = err.trim() || "config.toml could not be written"; + if (code !== 0) { am.error = err.trim() || "config.toml could not be written"; return; } + am.text = t; + am.base = t; + tools(); }); } + function discard() { + am.error = ""; + am.invalid = ({}); + am.toolsWanted = ({}); + am.base = am.text; + am.draft = am.text; + } + function markInvalid(id, bad) { + if (!!am.invalid[id] === bad) return; + const next = Object.assign({}, am.invalid); + if (bad) next[id] = true; else delete next[id]; + am.invalid = next; + } // toml is the value written as TOML, or null to fall back to the default. - function setKey(key, toml) { am.save(C.setScalar(am.text, key, toml)); } - function setEnv(key, value) { am.save(C.setEnv(am.text, key, value)); } + function setKey(key, toml) { am.draft = C.setScalar(am.draft, key, toml); } + function setEnv(key, value) { am.draft = C.setEnv(am.draft, key, value); } function quote(s) { return C.quote(s); } // Every tools/*.toml is loaded; *.toml.off is the same file switched off. @@ -158,14 +206,27 @@ Module { }); } + // Whether a tool is on in the draft: its wanted state, else the file's. + function toolOn(t) { return am.toolsWanted[t.name] ?? t.on; } function toggleTool(t) { - const on = am.toolsDir + "/" + t.name + ".toml"; + const next = Object.assign({}, am.toolsWanted); + if (!am.toolOn(t) === t.on) delete next[t.name]; else next[t.name] = !am.toolOn(t); + am.toolsWanted = next; + } + // One rename at a time, then done(). -n never overwrites a file that + // already holds the other name. + function applyTools(names, done) { + if (names.length === 0) { done(); return; } + const name = names[0]; + const on = am.toolsDir + "/" + name + ".toml"; const off = on + ".off"; - // -n: never overwrite a file that already holds the other name. - am.run(["mv", "-n", t.on ? on : off, t.on ? off : on], null, (code, out, err) => { - if (code === 0) { am.pending = true; am.error = ""; } - else am.error = err.trim() || "could not switch " + t.name; - am.listTools(); + const want = am.toolsWanted[name]; + am.run(["mv", "-n", want ? off : on, want ? on : off], null, (code, out, err) => { + if (code !== 0) { am.error = err.trim() || "could not switch " + name; am.listTools(); return; } + const next = Object.assign({}, am.toolsWanted); + delete next[name]; + am.toolsWanted = next; + am.applyTools(names.slice(1), done); }); } @@ -240,6 +301,9 @@ Module { page: Component { Page { title: "Assistant" + actions: Component { + SaveBar { dirty: am.dirty; valid: am.valid; onSave: am.save(); onDiscard: am.discard() } + } AssistantPage { width: parent.width; asst: am } } } diff --git a/desktop/modules/assistant/AssistantPage.qml b/desktop/modules/assistant/AssistantPage.qml index c6b63fc..832e9be 100644 --- a/desktop/modules/assistant/AssistantPage.qml +++ b/desktop/modules/assistant/AssistantPage.qml @@ -14,9 +14,9 @@ import QtQuick.Controls import "../.." // The view over AssistantModule: the model settings, the tools' environment, -// the tools themselves, and the restart that applies them. A field saves on -// Enter; emptied, it falls back to the daemon's default, which it shows as -// its placeholder. +// the tools themselves, and the restart that applies them. Every field and +// switch edits the module's draft; the header's Save writes it. Emptied, a +// field falls back to the daemon's default, which it shows as placeholder. Column { id: page spacing: 8 @@ -44,20 +44,24 @@ Column { color: Theme.text } - // A one-line input that saves on Enter and undoes on Escape; edited - // reports every keystroke, for the add row. + // A one-line input reporting every keystroke, which the page turns into + // a draft edit. It is not bound to value, which would reset the cursor + // and eat a trailing space; it follows the value only when the draft + // moves under it (Discard, an add). bad outlines a refused value. component Field: Rectangle { id: field property string value: "" property string placeholder: "" - signal commit(string text) + property bool bad: false signal edited(string text) - // Commit on Enter even when unchanged: the add row's fields track - // their value as they are typed, so they never differ from it. - property bool live: false + signal accepted height: 34 radius: 8 color: Theme.surface + border.width: field.bad ? 1 : 0 + border.color: Theme.red + onValueChanged: if (ti.text.trim() !== field.value.trim()) ti.text = field.value + onBadChanged: if (!field.bad) ti.text = field.value TextInput { id: ti anchors { fill: parent; leftMargin: 10; rightMargin: 10 } @@ -66,13 +70,9 @@ Column { selectByMouse: true color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } - text: field.value + Component.onCompleted: text = field.value onTextEdited: field.edited(text) - onAccepted: if (field.live || text !== field.value) field.commit(text) - Keys.onEscapePressed: event => { - if (text !== field.value) text = field.value; - else event.accepted = false; - } + onAccepted: field.accepted() Text { anchors.verticalCenter: parent.verticalCenter width: parent.width @@ -126,7 +126,7 @@ Column { anchors { left: parent.left; right: restartBtn.left; rightMargin: 8; verticalCenter: parent.verticalCenter } wrapMode: Text.Wrap text: page.asst.pending ? "Saved. The assistant reads these at startup: restart it to apply." - : "The assistant reads these at startup." + : "The assistant reads these at startup: Save, then restart it." color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } } @@ -245,7 +245,7 @@ Column { anchors.fill: parent value: page.v.llm ?? "" placeholder: page.d.llm ?? "" - onCommit: t => page.asst.setKey("llm", t.trim() ? page.asst.quote(t.trim()) : null) + onEdited: t => page.asst.setKey("llm", t.trim() ? page.asst.quote(t.trim()) : null) } } @@ -258,16 +258,18 @@ Column { width: 120 value: page.v.temperature !== undefined ? String(page.v.temperature) : "" placeholder: "the model's preset" - onCommit: t => { + bad: page.asst.invalid["key:temperature"] ?? false + onEdited: t => { const x = Number(t.trim()); - if (t.trim() === "") page.asst.setKey("temperature", null); - else if (!isNaN(x) && x >= 0 && x <= 2) page.asst.setKey("temperature", String(x)); - else page.asst.error = "Temperature is a number from 0 to 2."; + const ok = t.trim() === "" || (!isNaN(x) && x >= 0 && x <= 2); + page.asst.markInvalid("key:temperature", !ok); + if (ok) page.asst.setKey("temperature", t.trim() === "" ? null : String(x)); } } Small { anchors { left: temp.right; leftMargin: 10; verticalCenter: parent.verticalCenter } text: "0 to 2; empty uses the llama-server preset's" + color: temp.bad ? Theme.red : Theme.subtext } } @@ -280,16 +282,18 @@ Column { width: 120 value: page.v.tool_rounds !== undefined ? String(page.v.tool_rounds) : "" placeholder: page.d.tool_rounds !== undefined ? String(page.d.tool_rounds) : "" - onCommit: t => { + bad: page.asst.invalid["key:tool_rounds"] ?? false + onEdited: t => { const n = Number(t.trim()); - if (t.trim() === "") page.asst.setKey("tool_rounds", null); - else if (Number.isInteger(n) && n >= 0 && n <= 20) page.asst.setKey("tool_rounds", String(n)); - else page.asst.error = "Tool rounds is a whole number from 0 to 20."; + const ok = t.trim() === "" || (Number.isInteger(n) && n >= 0 && n <= 20); + page.asst.markInvalid("key:tool_rounds", !ok); + if (ok) page.asst.setKey("tool_rounds", t.trim() === "" ? null : String(n)); } } Small { anchors { left: rounds.right; leftMargin: 10; verticalCenter: parent.verticalCenter } - text: "tool round trips per question" + text: "a whole number, 0 to 20: tool round trips per question" + color: rounds.bad ? Theme.red : Theme.subtext } } @@ -338,6 +342,11 @@ Column { background: null padding: 0 text: page.shown("system") + // Into the draft as it is typed; the default, or nothing, + // unsets the key rather than copying the default in. + onTextChanged: if (prompt.text !== page.shown("system")) + page.asst.setKey("system", prompt.text.trim() === "" || prompt.text === page.d.system + ? null : page.asst.quote(prompt.text)) } } } @@ -345,12 +354,6 @@ Column { Row { spacing: 8 Button { - text: "Save prompt" - enabled: prompt.text.trim() !== "" && prompt.text !== page.shown("system") - onClicked: page.asst.setKey("system", prompt.text === page.d.system ? null - : page.asst.quote(prompt.text)) - } - Button { text: "Use the default" enabled: page.v.system !== undefined onClicked: page.asst.setKey("system", null) @@ -374,12 +377,12 @@ Column { property string confirming: "" Repeater { - model: page.asst.envList + model: page.asst.envKeys delegate: Item { id: envRow - required property var modelData - objectName: "env:" + modelData.key + required property string modelData + objectName: "env:" + modelData width: page.width height: 34 @@ -388,25 +391,25 @@ Column { anchors { left: parent.left; verticalCenter: parent.verticalCenter } width: 120 elide: Text.ElideRight - text: envRow.modelData.key + text: envRow.modelData color: Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } } Field { anchors { left: envKey.right; leftMargin: 8; right: envDel.left; rightMargin: 8; top: parent.top; bottom: parent.bottom } - value: envRow.modelData.value - onCommit: t => page.asst.setEnv(envRow.modelData.key, t) + value: page.asst.envValue(envRow.modelData) + onEdited: t => page.asst.setEnv(envRow.modelData, t) } Button { id: envDel anchors { right: parent.right; verticalCenter: parent.verticalCenter } danger: true - text: page.confirming === envRow.modelData.key ? "Confirm" : "Remove" + text: page.confirming === envRow.modelData ? "Confirm" : "Remove" onClicked: { - if (page.confirming === envRow.modelData.key) { + if (page.confirming === envRow.modelData) { page.confirming = ""; - page.asst.setEnv(envRow.modelData.key, null); - } else page.confirming = envRow.modelData.key; + page.asst.setEnv(envRow.modelData, null); + } else page.confirming = envRow.modelData; } } } @@ -439,9 +442,8 @@ Column { anchors { left: keyField.right; leftMargin: 8; right: addBtn.left; rightMargin: 8; top: parent.top; bottom: parent.bottom } value: page.newValue placeholder: "value" - live: true onEdited: t => page.newValue = t - onCommit: page.addEnv() + onAccepted: page.addEnv() } Button { id: addBtn @@ -474,7 +476,7 @@ Column { Switch { id: toolSw anchors { left: parent.left; verticalCenter: parent.verticalCenter } - checked: toolRow.modelData.on + checked: page.asst.toolOn(toolRow.modelData) onToggled: page.asst.toggleTool(toolRow.modelData) } Column { @@ -482,7 +484,7 @@ Column { anchors { left: toolSw.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter } Text { text: toolRow.modelData.name - color: toolRow.modelData.on ? Theme.text : Theme.subtext + color: page.asst.toolOn(toolRow.modelData) ? Theme.text : Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } } Small { diff --git a/desktop/modules/assistant/PresetsModule.qml b/desktop/modules/assistant/PresetsModule.qml index 8617b02..1c62f73 100644 --- a/desktop/modules/assistant/PresetsModule.qml +++ b/desktop/modules/assistant/PresetsModule.qml @@ -15,8 +15,10 @@ import "../../assistant/Presets.js" as Presets // The assistant canvas's Ask row: the canned questions offered for the // staged files. They live in Prefs and the canvas reads them live, so -// unlike the daemon's settings these need no restart. +// unlike the daemon's settings these need no restart. The page edits a +// draft here; Save hands it to Prefs. Module { + id: pm name: "presets" label: "Ask presets" // nf-fa-lightbulb_o, present in Inconsolata Nerd Font. @@ -24,12 +26,34 @@ Module { settings: true group: "Assistant" keywords: ["assistant", "canvas", "ask", "question", "prompt", "summarize", "files"] - searchItems: Presets.list(Prefs.presets).map((p, i) => ({ text: p.label + ": " + p.prompt, key: "preset:" + i })) + searchItems: pm.shown.map((p, i) => ({ text: p.label + ": " + p.prompt, key: "preset:" + i })) + + // null while there are no edits: the page then shows Prefs' list. + property var draft: null + // Reset to defaults was pressed: Save drops the saved list rather than + // saving a copy of the defaults, so later default changes still arrive. + property bool resetting: false + readonly property var shown: pm.draft ?? Presets.list(Prefs.presets) + // Presets with an empty label or question, by index; Save waits. + readonly property bool valid: pm.shown.every(p => p.label.trim() !== "" && p.prompt.trim() !== "") + dirty: pm.draft !== null + + function edit(list) { pm.draft = list; pm.resetting = false; } + function reset() { pm.draft = Presets.list(null).map(p => Object.assign({}, p, { kinds: p.kinds.slice() })); pm.resetting = true; } + function save() { + if (pm.resetting) Prefs.resetPresets(); else Prefs.setPresets(pm.draft); + pm.discard(); + } + function discard() { pm.draft = null; pm.resetting = false; } page: Component { Page { title: "Ask presets" - PresetsPage { width: parent.width } + actions: Component { + SaveBar { dirty: pm.dirty; valid: pm.valid; note: "every preset needs a label and a question" + onSave: pm.save(); onDiscard: pm.discard() } + } + PresetsPage { width: parent.width; owner: pm } } } } diff --git a/desktop/modules/assistant/PresetsPage.qml b/desktop/modules/assistant/PresetsPage.qml index ec103cf..56d507c 100644 --- a/desktop/modules/assistant/PresetsPage.qml +++ b/desktop/modules/assistant/PresetsPage.qml @@ -13,14 +13,17 @@ import QtQuick import "../.." import "../../assistant/Presets.js" as Presets -// One card per preset: its label and question, saved on Enter, and the file -// kinds it is offered for. Every change saves the whole list to Prefs; Reset -// drops the saved list, which brings the built-in one back. +// One card per preset: its label and question and the file kinds it is +// offered for. Every change edits the module's draft; the header's Save +// hands the list to Prefs. Reset fills the draft with the built-in list. Column { id: page spacing: 10 - readonly property var list: Presets.list(Prefs.presets) + // The module, holding the draft. Not named pm, its id (it would bind to + // itself, AGENTS.md). + required property QtObject owner + readonly property var list: owner.shown // The kinds a preset can name: the four the canvas chats about, and // "many", offered whenever two or more files are staged. readonly property var kinds: ["pdf", "text", "code", "image", "many"] @@ -38,13 +41,13 @@ Column { if (r) out.push(r); }); page.error = ""; - page.confirming = -1; - Prefs.setPresets(out); + if (fn === null || out.length !== page.list.length) page.confirming = -1; + page.owner.edit(out); } function add() { - Prefs.setPresets(page.list.concat([{ label: "New preset", icon: Presets.FALLBACK, - kinds: ["text"], prompt: "Summarize this." }])); + page.owner.edit(page.list.concat([{ label: "New preset", icon: Presets.FALLBACK, + kinds: ["text"], prompt: "Summarize this." }])); } component Small: Text { @@ -52,27 +55,30 @@ Column { font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } + // Reports every keystroke into the draft. Not bound to value, which + // would reset the cursor; it follows the value only when the draft moves + // under it (Discard, Reset). Empty is outlined, since Save refuses it. component Field: Rectangle { id: field property string value: "" property string placeholder: "" - signal commit(string text) + signal edited(string text) height: 32 radius: 8 color: Qt.alpha(Theme.base, 0.6) + border.width: ti.text.trim() === "" ? 1 : 0 + border.color: Theme.red + onValueChanged: if (ti.text !== field.value) ti.text = field.value TextInput { + id: ti anchors { fill: parent; leftMargin: 10; rightMargin: 10 } verticalAlignment: TextInput.AlignVCenter clip: true selectByMouse: true color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - text: field.value - onAccepted: if (text !== field.value) field.commit(text) - Keys.onEscapePressed: event => { - if (text !== field.value) text = field.value; - else event.accepted = false; - } + Component.onCompleted: text = field.value + onTextEdited: field.edited(text) Text { anchors.verticalCenter: parent.verticalCenter visible: parent.text === "" @@ -87,8 +93,8 @@ Column { width: page.width wrapMode: Text.Wrap text: "Offered on the assistant canvas for the staged files: a preset shows when every staged file is " - + "one of its kinds, or, with \"many\", when two or more are staged. Enter saves a field; " - + "changes apply at once." + + "one of its kinds, or, with \"many\", when two or more are staged. Save applies the " + + "changes, which the canvas picks up at once." } Text { @@ -101,12 +107,14 @@ Column { } Repeater { - model: page.list + // A count, not the list: a new list on every keystroke would rebuild + // every card and drop the focus of the field being typed in. + model: page.list.length delegate: Rectangle { id: card - required property var modelData required property int index + readonly property var modelData: page.list[index] ?? { label: "", prompt: "", kinds: [], icon: "" } objectName: "preset:" + index width: page.width height: body.implicitHeight + 20 @@ -133,10 +141,7 @@ Column { anchors { left: glyph.right; leftMargin: 6; right: del.left; rightMargin: 8; top: parent.top; bottom: parent.bottom } value: card.modelData.label placeholder: "label" - onCommit: t => { - if (t.trim() === "") { page.error = "A preset needs a label."; return; } - page.edit(card.index, c => Object.assign(c, { label: t.trim() })); - } + onEdited: t => page.edit(card.index, c => Object.assign(c, { label: t })) } Button { id: del @@ -154,10 +159,7 @@ Column { width: parent.width value: card.modelData.prompt placeholder: "the question asked" - onCommit: t => { - if (t.trim() === "") { page.error = "A preset needs a question."; return; } - page.edit(card.index, c => Object.assign(c, { prompt: t.trim() })); - } + onEdited: t => page.edit(card.index, c => Object.assign(c, { prompt: t })) } // A kind toggles on click; the last one cannot be cleared, @@ -208,8 +210,8 @@ Column { Button { text: "Reset to defaults" danger: true - enabled: Prefs.presets !== null - onClicked: Prefs.resetPresets() + enabled: !page.owner.resetting && (Prefs.presets !== null || page.owner.dirty) + onClicked: page.owner.reset() } } } diff --git a/desktop/modules/assistant/README.md b/desktop/modules/assistant/README.md index 004ec05..3c35dc6 100644 --- a/desktop/modules/assistant/README.md +++ b/desktop/modules/assistant/README.md @@ -5,11 +5,14 @@ canvas (`../../assistant/`). **Assistant** edits the daemon's `~/.config/desktop-assistant/config.toml` and switches its tools. The model fields (server, temperature, tool rounds, -thinking) save on Enter, and an emptied field falls back to the daemon's -default, shown as its placeholder; the defaults come from `assistant.py ---defaults`, so they are never copied here. The system prompt has its own -box, Save and "Use the default". The tool environment (`[env]`) is one row -per key, with an add row below. A tool's switch renames `tools/<name>.toml` +thinking), the system prompt, the `[env]` rows and the tool switches all +edit a draft, and the header's Save writes it: the config first, then the +tool renames. An emptied field falls back to the daemon's default, shown as +its placeholder; the defaults come from `assistant.py --defaults`, so they +are never copied here. A refused number is outlined and holds Save back. +The system prompt also has "Use the default". The tool environment +(`[env]`) is one row per key, with an add row below. A tool's switch, once +saved, renames `tools/<name>.toml` to `<name>.toml.off`, which the daemon's `*.toml` glob skips. The page also lists llama-server's models with their state. The daemon 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; } } } |
