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 | |
| 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')
| -rw-r--r-- | desktop/Module.qml | 3 | ||||
| -rw-r--r-- | desktop/Page.qml | 9 | ||||
| -rw-r--r-- | desktop/README.md | 13 | ||||
| -rw-r--r-- | desktop/SaveBar.qml | 43 | ||||
| -rw-r--r-- | desktop/Settings.qml | 7 | ||||
| -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 | ||||
| -rw-r--r-- | desktop/shell.qml | 2 |
19 files changed, 429 insertions, 161 deletions
diff --git a/desktop/Module.qml b/desktop/Module.qml index 85f924b..ba48023 100644 --- a/desktop/Module.qml +++ b/desktop/Module.qml @@ -46,6 +46,9 @@ QtObject { // Groups appear in the order their first module is declared. property string group: "" + // A settings page holding unsaved edits, marked in the sidebar. + property bool dirty: false + // Words the settings window's search matches besides the label: the // settings buried inside the page, which is not loaded until opened. property list<string> keywords: [] diff --git a/desktop/Page.qml b/desktop/Page.qml index b29158e..eb1c5c0 100644 --- a/desktop/Page.qml +++ b/desktop/Page.qml @@ -27,6 +27,10 @@ Item { // user page opens the git page). signal go(string name) + // Shown at the right of the header, fixed while the content scrolls: a + // settings page's SaveBar. + property Component actions: null + // The height the content area shows, for content that scrolls itself // (the wallpaper list) and so wants a fixed height, not the page's scroll. readonly property real viewHeight: scroll.height @@ -75,6 +79,11 @@ Item { color: Theme.text } } + + Loader { + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + sourceComponent: page.actions + } } Rectangle { diff --git a/desktop/README.md b/desktop/README.md index 40b5e92..1bd2b7e 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -35,6 +35,19 @@ gear beside the user header opens it, as do SUPER+Return and IPC: qs -p ./desktop ipc call settings open autostart qs -p ./desktop ipc call settings toggle +A page that edits a file edits a draft: its fields change the draft as they +are typed, and nothing is written until Save in the page's header +(`SaveBar.qml`, through `Page.actions`). Discard drops the draft, a dot in +the sidebar marks a page holding one, and drafts survive moving between +pages. A draft over a file that changed on disk meanwhile is refused at +Save rather than written over it. Two rules make live editing work: a field +is not bound to its value (a binding resets the cursor and eats a typed +trailing space), and a Repeater's model is the set of row keys, assigned +only when that set changes, never an array rebuilt from the draft, which +would recreate the row being typed in on every keystroke and drop its +focus. Git, autostart's rows, HyprSunset, HyprIdle and User keep their own +buttons. + The sidebar's edge drags to resize it, between 160 and 420px; the width is saved in the preferences file on release. diff --git a/desktop/SaveBar.qml b/desktop/SaveBar.qml new file mode 100644 index 0000000..aa64868 --- /dev/null +++ b/desktop/SaveBar.qml @@ -0,0 +1,43 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick + +// Save and Discard for a settings page that edits a draft: its fields change +// the draft, and nothing reaches the file until Save. Sits in the Page header +// through Page.actions. note says what is wrong when Save is held back. +Row { + id: bar + property bool dirty: false + property bool valid: true + property string note: "" + signal save + signal discard + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: bar.dirty + text: !bar.valid ? (bar.note || "fix the fields marked red") : "unsaved changes" + color: bar.valid ? Theme.yellow : Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Button { + text: "Discard" + enabled: bar.dirty + onClicked: bar.discard() + } + Button { + text: "Save" + enabled: bar.dirty && bar.valid + onClicked: bar.save() + } +} diff --git a/desktop/Settings.qml b/desktop/Settings.qml index 745d6c4..4905bcd 100644 --- a/desktop/Settings.qml +++ b/desktop/Settings.qml @@ -216,6 +216,13 @@ Scope { text: entry.modelData.mod.label font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: Theme.text + // Unsaved changes in that page's draft. + Rectangle { + visible: entry.modelData.mod.dirty + anchors { left: parent.right; leftMargin: 6; verticalCenter: parent.verticalCenter } + width: 6; height: 6; radius: 3 + color: Theme.yellow + } } // Which buried setting matched, when the label did not. Text { 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; } } } diff --git a/desktop/shell.qml b/desktop/shell.qml index 1cd1f7e..943d01e 100644 --- a/desktop/shell.qml +++ b/desktop/shell.qml @@ -93,7 +93,7 @@ ShellRoot { } // Pages set once and left alone, the modules marked `settings`, in a - // window of their own. See Settings.qml. + // window of their own, edited as drafts behind a SaveBar. See Settings.qml. Settings { id: settings modules: drawer.modules |
