diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-17 11:08:49 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-17 11:08:49 +0200 |
| commit | c6fc5bbef45bcfa4f8d5e50ca7401aa1f5b6bc7d (patch) | |
| tree | 775fa89b284c68d30205c58d83fae55336676f60 /desktop/modules | |
| parent | 8e9d8b9ac1b593032dea9eca312fa1495c0c8858 (diff) | |
| download | quickshell-c6fc5bbef45bcfa4f8d5e50ca7401aa1f5b6bc7d.tar.gz quickshell-c6fc5bbef45bcfa4f8d5e50ca7401aa1f5b6bc7d.zip | |
settings: qtmaildir's config, in four Mail pages
qtmaildir is configured through qtmaildir.conf alone, which it reads at
startup and never writes. Four settings pages under a new Mail group
edit it: Mail ([general], [sync], [completion]), Compose, Accounts (a
card per account, add and remove) and Tags & shortcuts ([tagcolors] and
[keys]). Fields save on Enter and unset when emptied, with qtmaildir's
defaults as placeholders and numbers range-checked; the pages say when
qtmaildir is running and a change waits for its restart.
Ini.js edits the file line by line so its comments survive. Headers are
only whole [name] lines: a section is never cut at the next "[", since
values such as [Gmail]/Bozze hold brackets, the trap AGENTS.md records.
The shortcut actions are copied from qtmaildir's KeyMap::knownActions()
rather than exported by it, since a CLI flag there would have meant
translations and a changelog entry; the field also takes free text, and
the copy is marked as a ponytail ceiling.
The settings sidebar now clips, which a list long enough to scroll
showed it did not: entries drew over the search box.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/modules')
| -rw-r--r-- | desktop/modules/qtmaildir/AccountsPage.qml | 129 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/BoolSetting.qml | 53 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/ChoiceSetting.qml | 75 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/ComposePage.qml | 33 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/GeneralPage.qml | 68 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/Heading.qml | 20 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/Ini.js | 138 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/MailAccountsModule.qml | 35 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/MailComposeModule.qml | 36 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/MailGeneralModule.qml | 46 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/MailTagsKeysModule.qml | 34 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/Note.qml | 35 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/Qtm.qml | 77 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/README.md | 38 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/Setting.qml | 104 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/TagsKeysPage.qml | 213 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/test_ini.js | 93 |
17 files changed, 1227 insertions, 0 deletions
diff --git a/desktop/modules/qtmaildir/AccountsPage.qml b/desktop/modules/qtmaildir/AccountsPage.qml new file mode 100644 index 0000000..2f82154 --- /dev/null +++ b/desktop/modules/qtmaildir/AccountsPage.qml @@ -0,0 +1,129 @@ +// 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 +import "../.." + +// One card per [account.<key>]: every key qtmaildir reads for an account. +// Add appends a section with the folders most servers use; Remove asks once +// and deletes the section. The key itself is the account's identity and is +// not edited here. +Column { + id: page + spacing: 10 + Note {} + + property string confirming: "" + property string newKey: "" + readonly property bool keyOk: /^[A-Za-z0-9._-]+$/.test(page.newKey) && !Qtm.accounts.includes(page.newKey) + + Repeater { + model: Qtm.accounts + + delegate: Rectangle { + id: card + required property string modelData + readonly property string sec: "account." + modelData + objectName: "account:" + modelData + width: page.width + height: body.implicitHeight + 24 + radius: 10 + color: Qt.alpha(Theme.surface, 0.35) + + Column { + id: body + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 12 } + spacing: 6 + + Item { + width: parent.width + height: 32 + Rectangle { + id: swatch + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 14; height: 14; radius: 4 + color: Qtm.get(card.sec, "color") || Theme.overlay + } + Text { + anchors { left: swatch.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: card.modelData + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } + } + Button { + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + danger: true + text: page.confirming === card.modelData ? "Confirm" : "Remove" + onClicked: { + if (page.confirming === card.modelData) { page.confirming = ""; Qtm.removeAccount(card.modelData); } + else page.confirming = card.modelData; + } + } + } + + Setting { section: card.sec; key: "name"; label: "Name" } + Setting { section: card.sec; key: "address"; label: "Address" } + Setting { section: card.sec; key: "maildir"; label: "Maildir"; hint: "Relative to notmuch's mail root." } + Setting { section: card.sec; key: "inbox"; label: "Inbox"; placeholder: "Inbox" } + Setting { section: card.sec; key: "sent"; label: "Sent"; placeholder: "unset: no Sent button" } + Setting { section: card.sec; key: "drafts"; label: "Drafts"; placeholder: "unset: no Drafts button" } + Setting { section: card.sec; key: "trash"; label: "Trash"; placeholder: "required for Delete" } + Setting { section: card.sec; key: "spam"; label: "Spam"; placeholder: "required for Mark spam" } + Setting { section: card.sec; key: "send_command"; label: "Send command"; placeholder: "unset: receive only" + hint: "Run without a shell: an absolute path and plain arguments, e.g. /usr/bin/msmtp -a work -t." } + Setting { section: card.sec; key: "label"; label: "Chip label"; placeholder: card.modelData } + Setting { section: card.sec; key: "color"; label: "Chip colour"; placeholder: "generated" + hint: "#rrggbb" } + Setting { section: card.sec; key: "channel"; label: "mbsync channel"; placeholder: card.modelData } + } + } + } + + Heading { text: "Add an account" } + Item { + width: page.width + height: 34 + Rectangle { + anchors { left: parent.left; right: addBtn.left; rightMargin: 8; top: parent.top; bottom: parent.bottom } + radius: 8 + color: Theme.surface + TextInput { + anchors { fill: parent; leftMargin: 10; rightMargin: 10 } + verticalAlignment: TextInput.AlignVCenter + clip: true + selectByMouse: true + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + text: page.newKey + onTextEdited: page.newKey = text + onAccepted: addBtn.clicked(null) + Text { + anchors.verticalCenter: parent.verticalCenter + visible: parent.text === "" + text: "account key: letters, digits, dot, dash, underscore" + color: Theme.overlay + font: parent.font + } + } + } + Button { + id: addBtn + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + text: "Add" + enabled: page.keyOk + onClicked: { + if (!page.keyOk) return; + Qtm.addAccount(page.newKey, [["maildir", page.newKey], ["trash", "Trash"], ["spam", "Spam"]]); + page.newKey = ""; + } + } + } +} diff --git a/desktop/modules/qtmaildir/BoolSetting.qml b/desktop/modules/qtmaildir/BoolSetting.qml new file mode 100644 index 0000000..f5f8feb --- /dev/null +++ b/desktop/modules/qtmaildir/BoolSetting.qml @@ -0,0 +1,53 @@ +// 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 +import "../.." + +// A true/false key, as a switch showing the effective value: the file's, else +// the default. +Item { + id: s + property string section + property string key + property string label + property bool fallback: false + property string hint: "" + objectName: section + "/" + key + width: parent ? parent.width : 0 + height: 34 + + readonly property var raw: Qtm.get(s.section, s.key) + readonly property bool on: s.raw === undefined ? s.fallback : s.raw === "true" + + Text { + id: lbl + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 160 + elide: Text.ElideRight + text: s.label + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Switch { + id: sw + anchors { left: lbl.right; leftMargin: 8; verticalCenter: parent.verticalCenter } + checked: s.on + onToggled: Qtm.set(s.section, s.key, checked ? "true" : "false") + } + Text { + anchors { left: sw.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter } + elide: Text.ElideRight + text: s.hint + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + } +} diff --git a/desktop/modules/qtmaildir/ChoiceSetting.qml b/desktop/modules/qtmaildir/ChoiceSetting.qml new file mode 100644 index 0000000..01de9da --- /dev/null +++ b/desktop/modules/qtmaildir/ChoiceSetting.qml @@ -0,0 +1,75 @@ +// 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 +import "../.." + +// A key with a few allowed values, as chips. The first chip, "Default", +// unsets the key; the default it stands for is in its label. +Column { + id: s + property string section + property string key + property string label + // [{value, label}] + property var options: [] + property string fallbackLabel: "Default" + objectName: section + "/" + key + width: parent ? parent.width : 0 + + readonly property string value: Qtm.get(s.section, s.key) ?? "" + + Item { + width: s.width + height: Math.max(34, chips.height + 6) + Text { + id: lbl + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 160 + elide: Text.ElideRight + text: s.label + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Flow { + id: chips + anchors { left: lbl.right; leftMargin: 8; right: parent.right; verticalCenter: parent.verticalCenter } + spacing: 6 + Repeater { + model: [{ value: "", label: s.fallbackLabel }].concat(s.options) + Rectangle { + id: chip + required property var modelData + readonly property bool on: s.value === modelData.value + implicitWidth: chipText.implicitWidth + 20 + implicitHeight: 26 + radius: 13 + color: on ? Qt.alpha(Theme.accent, 0.85) + : chipArea.containsMouse ? Qt.alpha(Theme.accent, 0.22) : Qt.alpha(Theme.surface, 0.8) + Text { + id: chipText + anchors.centerIn: parent + text: chip.modelData.label + color: chip.on ? Theme.base : Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + MouseArea { + id: chipArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Qtm.set(s.section, s.key, chip.modelData.value === "" ? null : chip.modelData.value) + } + } + } + } + } +} diff --git a/desktop/modules/qtmaildir/ComposePage.qml b/desktop/modules/qtmaildir/ComposePage.qml new file mode 100644 index 0000000..c40706f --- /dev/null +++ b/desktop/modules/qtmaildir/ComposePage.qml @@ -0,0 +1,33 @@ +// 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 +import "../.." + +// [compose]. Placeholders are qtmaildir's defaults. +Column { + spacing: 6 + Note {} + + BoolSetting { section: "compose"; key: "send_html"; label: "Send HTML"; fallback: true + hint: "an HTML part beside the plain text, for New and Forward" } + ChoiceSetting { section: "compose"; key: "quote_position"; label: "Quote"; fallbackLabel: "Below the reply" + options: [{ value: "above", label: "Above the reply" }] } + ChoiceSetting { section: "compose"; key: "default_account"; label: "Default account"; fallbackLabel: "None" + options: Qtm.accounts.filter(a => Qtm.get("account." + a, "send_command")) + .map(a => ({ value: a, label: Qtm.get("account." + a, "label") || a })) } + Setting { section: "compose"; key: "send_delay_ms"; label: "Undo send window"; placeholder: "5000" + kind: "int"; min: 0; max: 120000; hint: "Milliseconds before the send command runs. 0 sends at once." } + Setting { section: "compose"; key: "autosave_interval_ms"; label: "Autosave every"; placeholder: "30000" + kind: "int"; min: 1000; max: 3600000; hint: "Milliseconds, at least 1000." } + Setting { section: "compose"; key: "attachment_warn_bytes"; label: "Warn attaching over"; placeholder: "26214400" + kind: "int"; min: 0; max: 1e12; hint: "Bytes. 26214400 is 25 MiB, most providers' limit." } +} diff --git a/desktop/modules/qtmaildir/GeneralPage.qml b/desktop/modules/qtmaildir/GeneralPage.qml new file mode 100644 index 0000000..2eca3e7 --- /dev/null +++ b/desktop/modules/qtmaildir/GeneralPage.qml @@ -0,0 +1,68 @@ +// 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 +import "../.." + +// [general], [sync] and [completion]. Placeholders are qtmaildir's defaults, +// from its README. +Column { + spacing: 6 + Note {} + + Heading { text: "Startup" } + Setting { section: "general"; key: "startup_query"; label: "Startup query"; placeholder: "Unread" + hint: "A saved query or a built-in filter by name: Unread, Inbox, Important, Sent, Drafts, Trash, Spam." } + ChoiceSetting { section: "general"; key: "startup_account"; label: "Startup account"; fallbackLabel: "All accounts" + options: Qtm.accounts.map(a => ({ value: a, label: Qtm.get("account." + a, "label") || a })) } + Setting { section: "general"; key: "language"; label: "Language"; placeholder: "system" + hint: "A locale name, it or it_IT; system follows $LANG." } + + Heading { text: "Appearance" } + Setting { section: "general"; key: "toolbar_icon_size"; label: "Toolbar icon size"; placeholder: "24" + kind: "int"; min: 16; max: 64; hint: "Pixels, 16 to 64." } + Setting { section: "general"; key: "message_zoom"; label: "Message zoom"; placeholder: "1.0" + kind: "float"; min: 0.5; max: 3; hint: "The starting zoom, 0.5 to 3. Ctrl+wheel is remembered on its own." } + Setting { section: "general"; key: "date_format"; label: "Date format"; placeholder: "the system locale's short format" + hint: "A QDateTime pattern: yyyy-MM-dd hh:mm." } + Setting { section: "general"; key: "forward_prefixes"; label: "Forward prefixes" + hint: "Extra subject prefixes, comma-separated, added to Fwd, Fw, WG, ENC, RV, TR." } + + Heading { text: "Behaviour" } + Setting { section: "general"; key: "mark_read_delay_ms"; label: "Mark read after"; placeholder: "2000" + kind: "int"; min: -1; max: 600000; hint: "Milliseconds an opened thread stays unread. 0 at once, -1 never." } + BoolSetting { section: "general"; key: "completion_on_focus"; label: "Complete on focus"; fallback: false + hint: "open the completion popup when the empty query bar takes focus" } + + Heading { text: "Sync" } + Setting { section: "sync"; key: "command"; label: "Sync command" + placeholder: "unset: the Sync button is disabled" } + Setting { section: "sync"; key: "log"; label: "Sync log"; placeholder: "the path mailsync.sh writes" } + Setting { section: "general"; key: "auto_sync_delay_ms"; label: "Sync after tagging"; placeholder: "2000" + kind: "int"; min: -1; max: 600000; hint: "Milliseconds after the last tag change. 0 at once, -1 never." } + ChoiceSetting { section: "general"; key: "sync_on_exit"; label: "On quit with changes"; fallbackLabel: "Ask" + options: [{ value: "always", label: "Always sync" }, { value: "never", label: "Never" }] } + + Heading { text: "Contacts and calendar" } + Setting { section: "general"; key: "contacts_dir"; label: "Contacts"; placeholder: "off" + hint: "A vCard directory, e.g. ~/.local/share/vdirsyncer/contacts/. Not ~/.local/share/contacts/, Akonadi's." } + Setting { section: "general"; key: "calendars_dir"; label: "Calendars"; placeholder: "off" + hint: "The vdir root, e.g. ~/.local/share/calendars/." } + Setting { section: "general"; key: "default_calendar"; label: "Default calendar"; placeholder: "the first collection" + hint: "The collection's directory name, where new events go." } + Setting { section: "general"; key: "calendar_sync_command"; label: "Calendar sync" + placeholder: "flock -w 60 /tmp/vdirsyncer.lock vdirsyncer sync calendars" } + + Heading { text: "Advanced" } + Setting { section: "general"; key: "notmuch_config"; label: "notmuch config"; placeholder: "notmuch's own lookup" } + Setting { section: "completion"; key: "extra_mimetypes"; label: "Extra mimetypes" + hint: "Added after mimetype: in the query bar. type|description, comma-separated." } +} diff --git a/desktop/modules/qtmaildir/Heading.qml b/desktop/modules/qtmaildir/Heading.qml new file mode 100644 index 0000000..b1076b3 --- /dev/null +++ b/desktop/modules/qtmaildir/Heading.qml @@ -0,0 +1,20 @@ +// 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 +import "../.." + +Text { + topPadding: 14 + bottomPadding: 2 + font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true } + color: Theme.text +} diff --git a/desktop/modules/qtmaildir/Ini.js b/desktop/modules/qtmaildir/Ini.js new file mode 100644 index 0000000..0b23e51 --- /dev/null +++ b/desktop/modules/qtmaildir/Ini.js @@ -0,0 +1,138 @@ +// 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. + +.pragma library + +// Pure helpers for qtmaildir.conf, tested by test_ini.js under node. +// +// The file is walked a line at a time, never cut at the next "[": values +// hold brackets ([Gmail]/Bozze), and a section body taken as "everything up +// to the next [" once lost three accounts their labels (AGENTS.md). A header +// is a line that is only "[name]". Comments and order are kept: a key unset +// is commented out in place, a commented key that is set again is activated, +// and anything this file does not touch passes through. +// +// Values follow QSettings' INI reading: an unquoted ; starts a comment, and +// a "quoted" value may hold one. + +const HEADER = /^\s*\[([^\]]+)\]\s*(;.*)?$/; +const KEY = /^(\s*)(;\s*)?([^=;\s][^=]*?)\s*=\s*(.*)$/; + +function value(rest) { + if (rest.startsWith('"')) { + let out = ""; + for (let i = 1; i < rest.length; i++) { + const c = rest[i]; + if (c === '"') return { v: out, end: i + 1 }; + if (c === "\\" && i + 1 < rest.length) out += rest[++i]; + else out += c; + } + return { v: out, end: rest.length }; + } + const semi = rest.search(/\s*;/); + const raw = semi < 0 ? rest : rest.slice(0, semi); + return { v: raw.trim(), end: raw.replace(/\s+$/, "").length }; +} + +function quote(v) { + return /[;"]|^\s|\s$/.test(v) ? '"' + v.replace(/[\\"]/g, c => "\\" + c) + '"' : v; +} + +// [{name, h, end}]: the header's line and the line after the section. +function ranges(lines) { + const out = []; + lines.forEach((l, i) => { + const m = HEADER.exec(l); + if (!m) return; + if (out.length) out[out.length - 1].end = i; + out.push({ name: m[1].trim(), h: i, end: lines.length }); + }); + return out; +} + +function sections(text) { return ranges(text.split("\n")).map(r => r.name); } + +// [{key, value}] for the active keys of a section, in file order. +function entries(text, section) { + const lines = text.split("\n"); + const r = ranges(lines).find(x => x.name === section); + if (!r) return []; + const out = []; + for (let i = r.h + 1; i < r.end; i++) { + const m = KEY.exec(lines[i]); + if (m && !m[2]) out.push({ key: m[3].trim(), value: value(m[4]).v }); + } + return out; +} + +function get(text, section, key) { + const e = entries(text, section).find(x => x.key === key); + return e ? e.value : undefined; +} + +// value null unsets: the key is commented out where it stands. +function set(text, section, key, val) { + const lines = text.split("\n"); + const r = ranges(lines).find(x => x.name === section); + if (!r) { + if (val === null) return text; + return text.replace(/\n*$/, "") + "\n\n[" + section + "]\n" + key + " = " + quote(val) + "\n"; + } + let commented = -1; + let last = r.h; + for (let i = r.h + 1; i < r.end; i++) { + const m = KEY.exec(lines[i]); + if (lines[i].trim() !== "") last = i; + if (!m || m[3].trim() !== key) continue; + const tail = m[4].slice(value(m[4]).end); + if (!m[2]) { + lines[i] = val === null ? m[1] + "; " + key + " = " + m[4] + : m[1] + key + " = " + quote(val) + tail; + return lines.join("\n"); + } + if (commented < 0) commented = i; + } + if (val === null) return text; + if (commented >= 0) { + const m = KEY.exec(lines[commented]); + lines[commented] = m[1] + key + " = " + quote(val) + m[4].slice(value(m[4]).end); + } else lines.splice(last + 1, 0, key + " = " + quote(val)); + return lines.join("\n"); +} + +// Deletes an active key's line outright, for the free-form sections +// ([tagcolors], [keys]) where a commented-out row would only pile up. +function remove(text, section, key) { + const lines = text.split("\n"); + const r = ranges(lines).find(x => x.name === section); + if (!r) return text; + for (let i = r.h + 1; i < r.end; i++) { + const m = KEY.exec(lines[i]); + if (m && !m[2] && m[3].trim() === key) { lines.splice(i, 1); return lines.join("\n"); } + } + return text; +} + +// pairs: [[key, value], ...] +function addSection(text, section, pairs) { + return text.replace(/\n*$/, "") + "\n\n[" + section + "]\n" + + pairs.map(p => p[0] + " = " + quote(p[1])).join("\n") + "\n"; +} + +// The header through the line before the next header, which takes the blank +// line that separated it from the next section with it. +function removeSection(text, section) { + const lines = text.split("\n"); + const r = ranges(lines).find(x => x.name === section); + if (!r) return text; + lines.splice(r.h, r.end - r.h); + return lines.join("\n"); +} diff --git a/desktop/modules/qtmaildir/MailAccountsModule.qml b/desktop/modules/qtmaildir/MailAccountsModule.qml new file mode 100644 index 0000000..f634c8d --- /dev/null +++ b/desktop/modules/qtmaildir/MailAccountsModule.qml @@ -0,0 +1,35 @@ +// 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 +import "../.." + +// qtmaildir's [account.<key>] sections. +Module { + name: "mail-accounts" + label: "Accounts" + // nf-fa-users, present in Inconsolata Nerd Font. + icon: "\uf0c0" + settings: true + group: "Mail" + 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") ?? ""), + key: "account:" + a })) + function refresh() { Qtm.refresh(); } + + page: Component { + Page { + title: "Accounts" + AccountsPage { width: parent.width } + } + } +} diff --git a/desktop/modules/qtmaildir/MailComposeModule.qml b/desktop/modules/qtmaildir/MailComposeModule.qml new file mode 100644 index 0000000..e34fc07 --- /dev/null +++ b/desktop/modules/qtmaildir/MailComposeModule.qml @@ -0,0 +1,36 @@ +// 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 +import "../.." + +// qtmaildir's [compose]. +Module { + name: "mail-compose" + label: "Compose" + // nf-fa-pencil, present in Inconsolata Nerd Font. + icon: "\uf040" + settings: true + group: "Mail" + 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"], + ["attachment_warn_bytes", "Warn attaching over"]] + .map(f => ({ text: f[1], key: "compose/" + f[0] })) + function refresh() { Qtm.refresh(); } + + page: Component { + Page { + title: "Compose" + ComposePage { width: parent.width } + } + } +} diff --git a/desktop/modules/qtmaildir/MailGeneralModule.qml b/desktop/modules/qtmaildir/MailGeneralModule.qml new file mode 100644 index 0000000..b531579 --- /dev/null +++ b/desktop/modules/qtmaildir/MailGeneralModule.qml @@ -0,0 +1,46 @@ +// 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 +import "../.." + +// qtmaildir's [general], [sync] and [completion]. +Module { + // Not "mail": that is the drawer's unread-mail page. + name: "mail-general" + label: "Mail" + // nf-fa-envelope, present in Inconsolata Nerd Font. + icon: "\uf0e0" + settings: true + group: "Mail" + keywords: ["qtmaildir", "startup", "language", "toolbar", "zoom", "date", "sync", "contacts", + "calendar", "notmuch", "mimetype", "forward"] + readonly property var fields: [ + ["general", "startup_query", "Startup query"], ["general", "startup_account", "Startup account"], + ["general", "language", "Language"], ["general", "toolbar_icon_size", "Toolbar icon size"], + ["general", "message_zoom", "Message zoom"], ["general", "date_format", "Date format"], + ["general", "forward_prefixes", "Forward prefixes"], ["general", "mark_read_delay_ms", "Mark read after"], + ["general", "completion_on_focus", "Complete on focus"], ["sync", "command", "Sync command"], + ["sync", "log", "Sync log"], ["general", "auto_sync_delay_ms", "Sync after tagging"], + ["general", "sync_on_exit", "On quit with changes"], ["general", "contacts_dir", "Contacts"], + ["general", "calendars_dir", "Calendars"], ["general", "default_calendar", "Default calendar"], + ["general", "calendar_sync_command", "Calendar sync"], ["general", "notmuch_config", "notmuch config"], + ["completion", "extra_mimetypes", "Extra mimetypes"]] + searchItems: fields.map(f => ({ text: f[2], key: f[0] + "/" + f[1] })) + function refresh() { Qtm.refresh(); } + + page: Component { + Page { + title: "Mail" + GeneralPage { width: parent.width } + } + } +} diff --git a/desktop/modules/qtmaildir/MailTagsKeysModule.qml b/desktop/modules/qtmaildir/MailTagsKeysModule.qml new file mode 100644 index 0000000..b4c903c --- /dev/null +++ b/desktop/modules/qtmaildir/MailTagsKeysModule.qml @@ -0,0 +1,34 @@ +// 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 +import "../.." + +// qtmaildir's [tagcolors] and [keys]. +Module { + name: "mail-tags" + label: "Tags & shortcuts" + // nf-fa-tags, present in Inconsolata Nerd Font. + icon: "\uf02c" + settings: true + group: "Mail" + 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 }))) + function refresh() { Qtm.refresh(); } + + page: Component { + Page { + title: "Tags & shortcuts" + TagsKeysPage { width: parent.width } + } + } +} diff --git a/desktop/modules/qtmaildir/Note.qml b/desktop/modules/qtmaildir/Note.qml new file mode 100644 index 0000000..56b8208 --- /dev/null +++ b/desktop/modules/qtmaildir/Note.qml @@ -0,0 +1,35 @@ +// 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 +import "../.." + +// The top of every Mail page: when changes apply, and any write error. +Column { + width: parent ? parent.width : 0 + spacing: 4 + Text { + width: parent.width + wrapMode: Text.Wrap + text: Qtm.running ? "qtmaildir is running: it reads qtmaildir.conf at startup, so restart it to apply changes." + : "qtmaildir reads qtmaildir.conf at startup; changes apply on its next launch." + color: Qtm.running ? Theme.yellow : Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Text { + width: parent.width + visible: Qtm.error !== "" + wrapMode: Text.Wrap + text: Qtm.error + color: Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } +} diff --git a/desktop/modules/qtmaildir/Qtm.qml b/desktop/modules/qtmaildir/Qtm.qml new file mode 100644 index 0000000..744bcb4 --- /dev/null +++ b/desktop/modules/qtmaildir/Qtm.qml @@ -0,0 +1,77 @@ +// 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. + +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick +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. +Singleton { + id: root + + readonly property string path: (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + + "/qtmaildir/qtmaildir.conf" + property string text: "" + 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)) + + function get(section, key) { return I.get(root.text, section, key); } + function entries(section) { return I.entries(root.text, 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)); } + + // 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; + root.error = ""; + root.text = t; + fv.setText(t); + } + + function refresh() { + fv.reload(); + pg.running = false; + pg.running = true; + } + + FileView { + id: fv + path: root.path + atomicWrites: true + watchChanges: true + printErrors: false + // Its own writes come back here too; reparsing the same text is a no-op. + onFileChanged: reload() + onLoaded: root.text = text() + onLoadFailed: root.text = "" + onSaveFailed: err => root.error = "Cannot write " + root.path + ": " + FileViewError.toString(err) + } + + Process { + id: pg + command: ["pgrep", "-x", "qtmaildir"] + onExited: code => root.running = code === 0 + } +} diff --git a/desktop/modules/qtmaildir/README.md b/desktop/modules/qtmaildir/README.md new file mode 100644 index 0000000..4bbcbfd --- /dev/null +++ b/desktop/modules/qtmaildir/README.md @@ -0,0 +1,38 @@ +# qtmaildir + +Four settings pages, under Mail, for qtmaildir's +`~/.config/qtmaildir/qtmaildir.conf`: + +- **Mail**: `[general]`, `[sync]` and `[completion]`: startup query and + account, language, toolbar size, message zoom, date format, forward + prefixes, mark-read and auto-sync delays, sync on quit, the sync command + and log, contacts, calendars and their sync command, the notmuch config. +- **Compose**: `[compose]`. +- **Accounts**: one card per `[account.<key>]` with every key qtmaildir + reads; Add appends a section with `trash = Trash` and `spam = Spam`, + Remove asks once and deletes the section. The key is the account's + identity and is not renamed here. +- **Tags & shortcuts**: `[tagcolors]` and `[keys]`, a row each, removable, + with an add row. + +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. + +`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 +activated, and a tag or shortcut row removed is deleted outright. Section +headers are only lines that are nothing but `[name]`, never "everything up +to the next `[`": values hold brackets (`[Gmail]/Bozze`), which is the trap +AGENTS.md records. Values follow QSettings' reading: an unquoted `;` starts a +comment, and a value holding `;` or `"` is written quoted. + +The shortcut actions offered are copied from qtmaildir's +`KeyMap::knownActions()`, a `ponytail:` ceiling in `TagsKeysPage.qml`: a +new action there needs adding here, and meanwhile can be typed, since the +field takes free text. + +`Qtm.qml` is the singleton the four pages share. Check: +`node desktop/modules/qtmaildir/test_ini.js`. diff --git a/desktop/modules/qtmaildir/Setting.qml b/desktop/modules/qtmaildir/Setting.qml new file mode 100644 index 0000000..59e9c1b --- /dev/null +++ b/desktop/modules/qtmaildir/Setting.qml @@ -0,0 +1,104 @@ +// 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 +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. +Column { + id: s + property string section + property string key + property string label + property string placeholder: "" + property string hint: "" + property string kind: "text" + property real min: -Infinity + property real max: Infinity + // What a search hit scrolls to. + objectName: section + "/" + key + width: parent ? parent.width : 0 + spacing: 2 + + readonly property string value: Qtm.get(s.section, s.key) ?? "" + property string problem: "" + + function commit(t) { + t = t.trim(); + if (t === "") { s.problem = ""; Qtm.set(s.section, s.key, null); return; } + if (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; + } + } + s.problem = ""; + Qtm.set(s.section, s.key, t); + } + + Item { + width: s.width + height: 34 + Text { + id: lbl + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 160 + elide: Text.ElideRight + text: s.label + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Rectangle { + anchors { left: lbl.right; leftMargin: 8; right: parent.right; top: parent.top; bottom: parent.bottom } + radius: 8 + color: Theme.surface + 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 - 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; + } + Text { + anchors.verticalCenter: parent.verticalCenter + width: parent.width + elide: Text.ElideRight + visible: parent.text === "" + text: s.placeholder + color: Theme.overlay + font: parent.font + } + } + } + } + + Text { + x: 168 + width: s.width - x + visible: s.problem !== "" || s.hint !== "" + wrapMode: Text.Wrap + text: s.problem !== "" ? "Must be " + s.problem + "." : s.hint + color: s.problem !== "" ? Theme.red : Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + } +} diff --git a/desktop/modules/qtmaildir/TagsKeysPage.qml b/desktop/modules/qtmaildir/TagsKeysPage.qml new file mode 100644 index 0000000..fcbd3a0 --- /dev/null +++ b/desktop/modules/qtmaildir/TagsKeysPage.qml @@ -0,0 +1,213 @@ +// 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 +import QtQuick.Controls as C +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. +Column { + id: page + spacing: 6 + Note {} + + // ponytail: copied from qtmaildir's KeyMap::knownActions() (src/keymap.cpp); + // a new action there needs adding here. The action field also takes free + // text, so a missing name is a typo away, not blocked. + readonly property var actions: ["next_thread", "prev_thread", "open_thread", "archive", "delete", + "restore", "cleanup_stranded", "cleanup_stranded_spam", "empty_trash", "purge", "empty_spam", + "spam", "not_spam", "toggle_unread", "mark_all_read", "edit_tags", "tag_rules", "calendar", + "flag", "compose", "reply", "reply_all", "reply_no_quote", "forward", "edit_draft", + "save_message", "focus_query", "complete_query", "save_query", "select_all", "clear_pane", + "clear_selection", "toggle_html", "load_remote", "message_details", "zoom_in", "zoom_out", + "zoom_reset", "undo", "sync", "quit"] + + property string confirming: "" + + // The stock ComboBox paints from the palette, light by default; these + // are the desktop's colours, so it reads as part of the page. + component Combo: C.ComboBox { + editable: true + model: page.actions + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + palette { + base: Theme.surface; window: Theme.base; button: Theme.surface + text: Theme.text; buttonText: Theme.text; windowText: Theme.text + highlight: Theme.accent; highlightedText: Theme.base + mid: Qt.alpha(Theme.text, 0.2); dark: Qt.alpha(Theme.text, 0.2); light: Theme.surface + } + } + + component Box: Rectangle { + property alias text: ti.text + property string placeholder: "" + signal accepted + height: 34 + radius: 8 + color: Theme.surface + 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 - 2 } + onAccepted: parent.accepted() + Text { + anchors.verticalCenter: parent.verticalCenter + visible: parent.text === "" + text: parent.parent.placeholder + color: Theme.overlay + font: parent.font + } + } + } + + // --- tag colours --- + + Heading { text: "Tag colours" } + Text { + width: page.width + wrapMode: Text.Wrap + text: "By exact tag first, then by top-level prefix: shopping colours shopping/amazon unless it has its own." + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Repeater { + model: Qtm.entries("tagcolors") + delegate: Item { + id: tagRow + required property var modelData + objectName: "tag:" + modelData.key + width: page.width + height: 34 + Rectangle { + id: tagSwatch + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 16; height: 16; radius: 4 + color: tagRow.modelData.value + } + Text { + id: tagName + anchors { left: tagSwatch.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + width: 200 + elide: Text.ElideRight + text: tagRow.modelData.key + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + } + Box { + anchors { left: tagName.right; leftMargin: 8; right: tagDel.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) + } + Button { + id: tagDel + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + danger: true + text: page.confirming === "tag:" + tagRow.modelData.key ? "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; + } + } + } + } + + Item { + width: page.width + height: 34 + readonly property bool ok: newTag.text.trim() !== "" && /^#[0-9a-fA-F]{6}$/.test(newColour.text) + && Qtm.get("tagcolors", newTag.text.trim()) === undefined + function add() { + if (!ok) return; + Qtm.set("tagcolors", newTag.text.trim(), newColour.text); + newTag.text = ""; newColour.text = ""; + } + Box { id: newTag; anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 226; placeholder: "tag" } + Box { id: newColour; anchors { left: newTag.right; leftMargin: 8; right: tagAdd.left; rightMargin: 8; verticalCenter: parent.verticalCenter } + placeholder: "#rrggbb"; onAccepted: parent.add() } + Button { id: tagAdd; anchors { right: parent.right; verticalCenter: parent.verticalCenter } + text: "Add"; enabled: parent.ok; onClicked: parent.add() } + } + + // --- keys --- + + Heading { text: "Shortcuts" } + Text { + width: page.width + wrapMode: Text.Wrap + text: "Overrides of qtmaildir's default bindings: a key sequence such as Ctrl+E, and the action it runs." + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Repeater { + model: Qtm.entries("keys") + delegate: Item { + id: keyRow + required property var modelData + objectName: "key:" + modelData.key + width: page.width + height: 34 + Text { + id: keySeq + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 226 + elide: Text.ElideRight + text: keyRow.modelData.key + 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]) + } + Button { + id: keyDel + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + danger: true + text: page.confirming === "key:" + keyRow.modelData.key ? "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; + } + } + } + } + + Item { + width: page.width + height: 34 + readonly property bool ok: newSeq.text.trim() !== "" && newAction.editText.trim() !== "" + && Qtm.get("keys", newSeq.text.trim()) === undefined + function add() { + if (!ok) return; + Qtm.set("keys", newSeq.text.trim(), newAction.editText.trim()); + newSeq.text = ""; + } + Box { id: newSeq; anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 226; placeholder: "Ctrl+E" } + Combo { + id: newAction + anchors { left: newSeq.right; leftMargin: 8; right: keyAdd.left; rightMargin: 8; verticalCenter: parent.verticalCenter } + } + Button { id: keyAdd; anchors { right: parent.right; verticalCenter: parent.verticalCenter } + text: "Add"; enabled: parent.ok; onClicked: parent.add() } + } +} diff --git a/desktop/modules/qtmaildir/test_ini.js b/desktop/modules/qtmaildir/test_ini.js new file mode 100644 index 0000000..2e7fb03 --- /dev/null +++ b/desktop/modules/qtmaildir/test_ini.js @@ -0,0 +1,93 @@ +// 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. + +// Run: node desktop/modules/qtmaildir/test_ini.js +// Ini.js is a QML library, so its .pragma line is stripped before node +// evaluates it. + +const fs = require("fs"); +const assert = require("assert"); + +const src = fs.readFileSync(__dirname + "/Ini.js", "utf8") + .replace(/^\.pragma library$/m, ""); +const I = new Function(src + "\nreturn { sections, entries, get, set, remove, addSection, removeSection };")(); + +const file = [ + "[general]", + "; startup_query = Unread", + "toolbar_icon_size = 32", + "date_format = dd/MM/yyyy hh:mm", + "", + "[account.gmail]", + "maildir = gmail ; relative to notmuch's mail root", + "drafts = [Gmail]/Bozze", + 'send_command = "/usr/bin/msmtp -a x ; y"', + "label = GM", + "", + "[tagcolors]", + "shopping/amazon = #ff9900", + "", +].join("\n"); + +// A value holding brackets is not a section header (AGENTS.md: three of +// five accounts once lost their label to exactly that). +assert.deepStrictEqual(I.sections(file), ["general", "account.gmail", "tagcolors"]); +assert.strictEqual(I.get(file, "account.gmail", "drafts"), "[Gmail]/Bozze"); +assert.strictEqual(I.get(file, "account.gmail", "label"), "GM"); +// An inline comment is not part of the value; a quoted ; is. +assert.strictEqual(I.get(file, "account.gmail", "maildir"), "gmail"); +assert.strictEqual(I.get(file, "account.gmail", "send_command"), "/usr/bin/msmtp -a x ; y"); +// A commented key is unset. +assert.strictEqual(I.get(file, "general", "startup_query"), undefined); +assert.deepStrictEqual(I.entries(file, "tagcolors"), [{ key: "shopping/amazon", value: "#ff9900" }]); + +// Replacing keeps the inline comment and every other line. +let t = I.set(file, "account.gmail", "maildir", "gm2"); +assert.strictEqual(t.split("\n")[6], "maildir = gm2 ; relative to notmuch's mail root"); +assert.strictEqual(t.split("\n").length, file.split("\n").length); +// Setting a commented key activates its line. +t = I.set(file, "general", "startup_query", "Inbox"); +assert.strictEqual(t.split("\n")[1], "startup_query = Inbox"); +// Unsetting comments a key out in place. +t = I.set(file, "general", "toolbar_icon_size", null); +assert.strictEqual(t.split("\n")[2], "; toolbar_icon_size = 32"); +assert.strictEqual(I.get(t, "general", "toolbar_icon_size"), undefined); +// A new key lands at the end of its section, before the blank line. +t = I.set(file, "general", "language", "it"); +assert.strictEqual(t.split("\n")[4], "language = it"); +assert.strictEqual(t.split("\n")[5], ""); +assert.strictEqual(I.get(t, "general", "language"), "it"); +// A value with a ; or a quote is quoted, and reads back the same. +t = I.set(file, "account.gmail", "send_command", 'a "b" ; c'); +assert.strictEqual(I.get(t, "account.gmail", "send_command"), 'a "b" ; c'); +// A missing section is appended. +t = I.set(file, "compose", "send_html", "false"); +assert.ok(t.endsWith("\n[compose]\nsend_html = false\n"), JSON.stringify(t)); +assert.strictEqual(I.get(t, "compose", "send_html"), "false"); +// The same key in another section is untouched. +t = I.set("[a]\nx = 1\n\n[b]\nx = 2\n", "b", "x", "3"); +assert.strictEqual(t, "[a]\nx = 1\n\n[b]\nx = 3\n"); + +// remove deletes the line, where set(null) would comment it out. +t = I.remove(file, "tagcolors", "shopping/amazon"); +assert.deepStrictEqual(I.entries(t, "tagcolors"), []); +assert.ok(!t.includes("amazon")); +assert.strictEqual(I.remove(file, "tagcolors", "nope"), file); + +// Whole sections, for accounts. +t = I.addSection(file, "account.new", [["maildir", "new"], ["trash", "Trash"]]); +assert.deepStrictEqual(I.sections(t), ["general", "account.gmail", "tagcolors", "account.new"]); +assert.strictEqual(I.get(t, "account.new", "trash"), "Trash"); +t = I.removeSection(file, "account.gmail"); +assert.deepStrictEqual(I.sections(t), ["general", "tagcolors"]); +assert.strictEqual(t, "[general]\n; startup_query = Unread\ntoolbar_icon_size = 32\ndate_format = dd/MM/yyyy hh:mm\n\n[tagcolors]\nshopping/amazon = #ff9900\n"); + +console.log("test_ini: ok"); |
