diff options
Diffstat (limited to 'desktop/modules')
| -rw-r--r-- | desktop/modules/deckit/DeckIt.qml | 198 | ||||
| -rw-r--r-- | desktop/modules/deckit/DeckItModule.qml | 57 | ||||
| -rw-r--r-- | desktop/modules/deckit/DeckItPage.qml | 282 | ||||
| -rw-r--r-- | desktop/modules/deckit/README.md | 36 | ||||
| -rw-r--r-- | desktop/modules/qtmaildir/ChoiceSetting.qml | 6 | ||||
| -rw-r--r-- | desktop/modules/sbbuilder/Store.qml | 4 | ||||
| -rw-r--r-- | desktop/modules/sbbuilder/Toml.js | 4 | ||||
| -rw-r--r-- | desktop/modules/sbbuilder/test_toml.js | 4 |
8 files changed, 586 insertions, 5 deletions
diff --git a/desktop/modules/deckit/DeckIt.qml b/desktop/modules/deckit/DeckIt.qml new file mode 100644 index 0000000..8d24917 --- /dev/null +++ b/desktop/modules/deckit/DeckIt.qml @@ -0,0 +1,198 @@ +// 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 "../sbbuilder" +import "../autostart/Autostart.js" as A + +// deck-it's user config and its autostart line. The config is a sparse +// override: deck-it merges it over the shipped config.toml key by key, so a +// key left unset here keeps deck-it's default, which `deck-it defaults` +// reports. The autostart switch edits hypr-theme's autostart.lua at once, +// as the Autostart page does; config edits go through the SaveBar. +Singleton { + id: root + + readonly property string config: (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + readonly property string path: root.config + "/deck-it/config.toml" + readonly property string autostartFile: root.config + "/hypr/sections/autostart.lua" + // Wi-Fi, waiting for the tablet; it defers to a USB start. + readonly property string autostartCmd: "deck-it wifi --wait" + + readonly property Store store: Store {} + // Empty until `deck-it defaults` answered; then the page can show them. + property bool ready: false + property string defaultsError: "" + + // The autostart.lua entries that run deck-it, and whether one is enabled. + property var entries: [] + readonly property bool autostart: root.entries.some(e => e.enabled) + property string autostartError: "" + + function reload() { fv.reload(); asv.reload(); if (!root.ready) defs.running = true; } + + function save() { + const d = root.store; + if (d.text !== d.base) { d.error = "changed on disk: Discard to load it, then redo"; return; } + if (d.draft === "") return; // FileView.setText("") writes nothing (AGENTS.md) + d.error = ""; + d.text = d.draft; + d.base = d.draft; + fv.setText(d.draft); + } + + // `deck-it status`, polled while the page is open: {server, display, + // waiting, tablet, extend}; null until the first answer. + property var status: null + property int watchers: 0 + // The last Restart's outcome; restartAt is 0 when none is pending. + property string note: "" + property bool noteBad: false + property real restartAt: 0 + readonly property int restartTimeout: 30000 + + // One line for the page. + readonly property string summary: { + const s = root.status; + if (!s) return "Checking…"; + if (s.server && s.tablet) return "Running, tablet connected (" + s.tablet + ")" + + (s.extend ? (s.display ? ", display streaming" : ", display not streaming") : ""); + if (s.server) return "Running, but no tablet is tunnelled"; + if (s.waiting) return "Waiting for the tablet on Wi-Fi"; + return "Not running"; + } + readonly property bool healthy: !!root.status && root.status.server && root.status.tablet !== "" + && (!root.status.extend || root.status.display) + + // Stops it and starts it again the way login does, so a saved config applies. + function restart() { + Quickshell.execDetached(["sh", "-c", "deck-it stop; exec deck-it wifi --wait"]); + root.note = "Restarting…"; + root.noteBad = false; + root.restartAt = Date.now(); + // Let stop run first, or the first poll still sees the old instance. + poll.restart(); + } + + function checkRestart() { + if (root.restartAt === 0) return; + if (root.healthy) { + root.note = "Restarted at " + Qt.formatTime(new Date(), "HH:mm:ss"); + root.noteBad = false; + root.restartAt = 0; + } else if (Date.now() - root.restartAt > root.restartTimeout) { + root.note = "Not back after " + root.restartTimeout / 1000 + "s: " + root.summary; + root.noteBad = true; + root.restartAt = 0; + } + } + + Timer { + id: poll + interval: 2000 + repeat: true + triggeredOnStart: true + running: root.watchers > 0 + onTriggered: if (!stat.running) stat.running = true + } + Process { + id: stat + command: ["deck-it", "status"] + stdout: StdioCollector { id: statOut } + onExited: { + try { root.status = JSON.parse(statOut.text); } catch (e) { root.status = null; } + root.checkRestart(); + } + } + + function mine(text) { + return A.parse(text).filter(e => A.program(e.cmd, "").replace(/.*\//, "") === "deck-it"); + } + + // On: re-enable a commented-out line, else add one. Off: comment out + // every enabled one. Each edit returns null if its line moved meanwhile. + function setAutostart(on) { + let t = asv.text(); + const list = root.mine(t); + if (on) { + const off = list.find(e => !e.enabled); + t = off ? A.toggle(t, off) : A.add(t, root.autostartCmd); + } else { + // Toggling keeps every line's index, so the parsed entries stay valid. + for (const e of list.filter(e => e.enabled)) if (t !== null) t = A.toggle(t, e); + } + if (t === null) { + root.autostartError = "autostart.lua changed underneath or has no closing end), reloaded. Try again."; + asv.reload(); + return; + } + root.autostartError = ""; + asv.setText(t); + root.entries = root.mine(t); + } + + FileView { + id: fv + path: root.path + atomicWrites: true + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.store.loaded(text()) + onLoadFailed: root.store.loaded("") + onSaveFailed: err => root.store.error = "cannot write: " + FileViewError.toString(err) + } + + FileView { + id: asv + path: root.autostartFile + // Hyprland reloads its config on every change: never a half-written file. + atomicWrites: true + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.entries = root.mine(text()) + onLoadFailed: err => root.autostartError = "cannot read autostart.lua: " + FileViewError.toString(err) + onSaveFailed: err => root.autostartError = "cannot write autostart.lua: " + FileViewError.toString(err) + } + + // The shipped values, as {section: {key: value}} with top-level keys + // under "", the shape Store's placeholders read. + Process { + id: defs + command: ["deck-it", "defaults"] + stdout: StdioCollector { id: defsOut } + stderr: StdioCollector { id: defsErr } + onExited: code => { + let d = null; + try { d = JSON.parse(defsOut.text); } catch (e) {} + if (!d) { + root.defaultsError = "deck-it defaults failed: " + (defsErr.text.trim() || `exit ${code}`) + + ". Is deck-it on PATH?"; + return; + } + const out = { "": {} }; + for (const k in d) { + if (d[k] !== null && typeof d[k] === "object" && !Array.isArray(d[k])) out[k] = d[k]; + else out[""][k] = d[k]; + } + root.store.defaults = out; + root.defaultsError = ""; + root.ready = true; + } + } + + Component.onCompleted: defs.running = true +} diff --git a/desktop/modules/deckit/DeckItModule.qml b/desktop/modules/deckit/DeckItModule.qml new file mode 100644 index 0000000..5f572ff --- /dev/null +++ b/desktop/modules/deckit/DeckItModule.qml @@ -0,0 +1,57 @@ +// 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 Quickshell +import QtQuick +import "../.." +import "../qtmaildir" + +// deck-it, the tablet dashboard: whether it starts at login, and its config. +// See README.md. +Module { + id: mod + name: "deckit" + label: "deck-it" + // nf-md-tablet, present in Inconsolata Nerd Font. + icon: "" + settings: true + group: "Desktop" + dirty: DeckIt.store.dirty + keywords: ["deck-it", "tablet", "android", "dashboard", "stream deck", "adb", "wifi", "avnc", + "wayvnc", "second monitor", "launcher", "autostart"] + readonly property var fields: [ + ["autostart", "", "Start at login"], ["wifi", "address", "Wi-Fi address"], + ["display", "enabled", "Second display"], ["display", "resolution", "Resolution"], + ["display", "position", "Position"], ["display", "scale", "Scale"], + ["display", "refresh", "Refresh"], ["display", "name", "Output name"], + ["ports", "command_center", "Dashboard port"], ["ports", "display", "Display port"], + ["launch", "apps", "Launchers"], ["windows", "workspaces", "Workspaces"], + ["obs", "host", "OBS host"], ["obs", "port", "OBS port"], ["obs", "password", "OBS password"], + ["llm", "url", "LLM dashboard URL"], ["sbbuilder", "host", "Build VM host"], + ["hardware", "net", "Network interface"], ["hardware", "battery", "Battery"], + ["hardware", "cpu_sensor", "CPU sensor"], ["hardware", "gpu", "GPU"], + ["hardware", "gpu_mem_total", "GPU memory"], ["icons", "theme", "Fallback icon theme"]] + searchItems: fields.map(f => ({ text: f[2], key: f[0] + "/" + f[1] })) + function refresh() { DeckIt.reload(); } + + page: Component { + Page { + title: "deck-it" + actions: Component { + SaveBar { + dirty: DeckIt.store.dirty; valid: DeckIt.store.valid; error: DeckIt.store.error + onSave: DeckIt.save(); onDiscard: DeckIt.store.discard() + } + } + DeckItPage { width: parent.width } + } + } +} diff --git a/desktop/modules/deckit/DeckItPage.qml b/desktop/modules/deckit/DeckItPage.qml new file mode 100644 index 0000000..685d62f --- /dev/null +++ b/desktop/modules/deckit/DeckItPage.qml @@ -0,0 +1,282 @@ +// 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 Quickshell +import QtQuick +import "../.." +import "../qtmaildir" + +// The deck-it settings page. Every field edits the draft of +// ~/.config/deck-it/config.toml; an emptied field unsets the key, so deck-it's +// shipped default (the placeholder) applies. +Column { + id: col + spacing: 6 + readonly property var st: DeckIt.store + function def(section, key) { return col.st.fallback(section, key); } + + // The launcher list, in button order. + readonly property var apps: col.st.value("launch", "apps") ?? [] + property string query: "" + // Installed apps not yet listed, for the picker. Terminal apps are left + // out, as the Autostart page does: gtk-launch would need a terminal for them. + readonly property var candidates: { + const q = col.query.trim().toLowerCase(); + return DesktopEntries.applications.values + .filter(a => !a.noDisplay && !a.runInTerminal && a.execString && !col.apps.includes(a.id) + && (q === "" || a.name.toLowerCase().includes(q) || a.id.toLowerCase().includes(q))) + .sort((a, b) => a.name.localeCompare(b.name)) + .slice(0, 8); + } + function setApps(list) { col.st.put("launch", "apps", list); } + + component Small: Text { + width: col.width + wrapMode: Text.Wrap + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Small { text: DeckIt.path + ", only the keys that differ from deck-it's defaults. Saved changes apply when deck-it restarts." } + Small { visible: DeckIt.defaultsError !== ""; color: Theme.red; text: DeckIt.defaultsError } + Component.onCompleted: DeckIt.watchers++ + Component.onDestruction: DeckIt.watchers-- + + Item { + width: col.width + height: 34 + Rectangle { + id: dot + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 10; height: 10; radius: 5 + color: DeckIt.healthy ? Theme.green : DeckIt.status && (DeckIt.status.server || DeckIt.status.waiting) + ? Theme.yellow : Theme.red + } + Text { + anchors { left: dot.right; leftMargin: 10; right: restartBtn.left; rightMargin: 8; verticalCenter: parent.verticalCenter } + elide: Text.ElideRight + text: DeckIt.summary + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Button { + id: restartBtn + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + text: DeckIt.restartAt ? "Restarting…" : "Restart deck-it" + enabled: !DeckIt.restartAt + onClicked: DeckIt.restart() + } + } + Small { visible: DeckIt.note !== ""; color: DeckIt.noteBad ? Theme.red : Theme.green; text: DeckIt.note } + + Heading { text: "Start" } + Item { + objectName: "autostart/" + width: col.width + height: 34 + Text { + id: asLabel + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 160 + text: "Start at login" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Switch { + id: asSwitch + anchors { left: asLabel.right; leftMargin: 8; verticalCenter: parent.verticalCenter } + checked: DeckIt.autostart + onToggled: on => DeckIt.setAutostart(on) + } + Text { + anchors { left: asSwitch.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: DeckIt.autostartCmd + ", in autostart.lua; applies at next login" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + } + Small { visible: DeckIt.autostartError !== ""; color: Theme.red; text: DeckIt.autostartError } + Setting { store: col.st; section: "wifi"; key: "address"; label: "Wi-Fi address" + placeholder: "ip:port" + hint: "From the tablet's Wireless debugging screen. The port changes whenever it is switched on; empty tries the last address, then mDNS." } + + Heading { text: "Display" } + BoolSetting { store: col.st; section: "display"; key: "enabled"; label: "Second display" + fallback: col.def("display", "enabled") ?? true + hint: "a headless monitor streamed to AVNC; off runs the dashboard alone" } + Setting { store: col.st; section: "display"; key: "resolution"; label: "Resolution" + placeholder: col.st.placeholder("display", "resolution"); hint: "WIDTHxHEIGHT, or auto to ask the tablet." } + Setting { store: col.st; section: "display"; key: "position"; label: "Position" + placeholder: col.st.placeholder("display", "position") + hint: "auto-below centres it under the first monitor; any Hyprland position works." } + Setting { store: col.st; section: "display"; key: "scale"; label: "Scale"; kind: "float"; min: 0.25; max: 4 + placeholder: col.st.placeholder("display", "scale") } + Setting { store: col.st; section: "display"; key: "refresh"; label: "Refresh"; kind: "int"; min: 1; max: 240 + placeholder: col.st.placeholder("display", "refresh") } + Setting { store: col.st; section: "display"; key: "name"; label: "Output name" + placeholder: col.st.placeholder("display", "name") } + + Heading { objectName: "launch/apps"; text: "Launchers" } + Small { text: "Buttons on the Windows tab, in this order. Each runs its app through gtk-launch." } + Repeater { + model: col.apps + delegate: Item { + id: row + required property string modelData + required property int index + readonly property var entry: DesktopEntries.byId(row.modelData) + width: col.width + height: 34 + Image { + id: appIcon + anchors { left: parent.left; leftMargin: 8; verticalCenter: parent.verticalCenter } + width: 20; height: 20 + sourceSize: Qt.size(20, 20) + source: row.entry ? Quickshell.iconPath(row.entry.icon, true) : "" + } + Text { + anchors { left: appIcon.right; leftMargin: 10; right: up.left; rightMargin: 8; verticalCenter: parent.verticalCenter } + elide: Text.ElideRight + text: row.entry ? row.entry.name + " " + row.modelData : row.modelData + " not installed" + color: row.entry ? Theme.text : Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Button { + id: up + anchors { right: del.left; rightMargin: 6; verticalCenter: parent.verticalCenter } + text: "Up" + enabled: row.index > 0 + onClicked: { + const a = col.apps.slice(); + a.splice(row.index - 1, 0, a.splice(row.index, 1)[0]); + col.setApps(a); + } + } + Button { + id: del + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + text: "Remove" + danger: true + onClicked: col.setApps(col.apps.filter(a => a !== row.modelData)) + } + } + } + Rectangle { + width: col.width + height: 34 + radius: 8 + color: Theme.surface + TextInput { + id: search + activeFocusOnTab: true + 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 } + onTextEdited: col.query = text + Text { + anchors.verticalCenter: parent.verticalCenter + visible: parent.text === "" + text: "add an app: search installed apps" + color: Theme.subtext + font: parent.font + } + } + } + Repeater { + model: col.candidates + delegate: Rectangle { + id: app + required property var modelData + width: col.width + height: 32 + radius: 8 + color: appArea.containsMouse ? Qt.alpha(Theme.text, 0.06) : "transparent" + Image { + id: candIcon + anchors { left: parent.left; leftMargin: 8; verticalCenter: parent.verticalCenter } + width: 20; height: 20 + sourceSize: Qt.size(20, 20) + source: Quickshell.iconPath(app.modelData.icon, true) + } + Text { + anchors { left: candIcon.right; leftMargin: 10; right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter } + elide: Text.ElideRight + text: app.modelData.name + " " + app.modelData.id + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + MouseArea { + id: appArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + col.setApps(col.apps.concat([app.modelData.id])); + col.query = ""; + search.text = ""; + } + } + } + } + + Heading { text: "Modules" } + Small { text: "The dashboard's panels and tabs." } + Repeater { + model: Object.keys(col.def("modules") ?? {}) + BoolSetting { + required property string modelData + store: col.st; section: "modules"; key: modelData; label: modelData + fallback: col.def("modules", modelData) ?? true + } + } + Setting { store: col.st; section: "windows"; key: "workspaces"; label: "Workspaces" + placeholder: col.st.placeholder("windows", "workspaces") + hint: "Numbers on the Windows tab, separated by spaces, shown even when empty." } + + Heading { text: "Services" } + Setting { store: col.st; section: "obs"; key: "host"; label: "OBS host" + placeholder: col.st.placeholder("obs", "host") } + Setting { store: col.st; section: "obs"; key: "port"; label: "OBS port"; kind: "int"; min: 1; max: 65535 + placeholder: col.st.placeholder("obs", "port") } + Setting { store: col.st; section: "obs"; key: "password"; label: "OBS password" + hint: "obs-websocket's; empty reads the one OBS stores in its own config." } + Setting { store: col.st; section: "llm"; key: "url"; label: "LLM dashboard URL" + placeholder: col.st.placeholder("llm", "url") } + Setting { store: col.st; section: "sbbuilder"; key: "host"; label: "Build VM host" + placeholder: col.st.placeholder("sbbuilder", "host"); hint: "ssh host alias; runs sbb-remote jobs there." } + + Heading { text: "Hardware" } + Small { text: "Each is auto, or a fixed value such as wlan0, BAT1 or coretemp." } + Setting { store: col.st; section: "hardware"; key: "net"; label: "Network interface" + placeholder: col.st.placeholder("hardware", "net") } + Setting { store: col.st; section: "hardware"; key: "battery"; label: "Battery" + placeholder: col.st.placeholder("hardware", "battery") } + Setting { store: col.st; section: "hardware"; key: "cpu_sensor"; label: "CPU sensor" + placeholder: col.st.placeholder("hardware", "cpu_sensor") } + Setting { store: col.st; section: "hardware"; key: "gpu"; label: "GPU" + placeholder: col.st.placeholder("hardware", "gpu"); hint: "auto (Intel xe) or none." } + Setting { store: col.st; section: "hardware"; key: "gpu_mem_total"; label: "GPU memory" + placeholder: col.st.placeholder("hardware", "gpu_mem_total"); hint: "The xe driver reports no VRAM size: the card's total, like 12G." } + + Heading { text: "Advanced" } + Setting { store: col.st; section: "ports"; key: "command_center"; label: "Dashboard port"; kind: "int"; min: 1024; max: 65535 + placeholder: col.st.placeholder("ports", "command_center"); hint: "On the tablet's localhost, through adb reverse." } + Setting { store: col.st; section: "ports"; key: "display"; label: "Display port"; kind: "int"; min: 1024; max: 65535 + placeholder: col.st.placeholder("ports", "display") } + ChoiceSetting { store: col.st; section: ""; key: "hyprland"; label: "Hyprland config" + fallbackLabel: "Default (" + (col.def("", "hyprland") ?? "auto") + ")" + options: [{ value: "auto", label: "auto" }, { value: "lua", label: "lua" }, { value: "classic", label: "classic" }] } + Setting { store: col.st; section: "icons"; key: "theme"; label: "Fallback icon theme" + placeholder: col.st.placeholder("icons", "theme"); hint: "Used only when keystone sets no icon theme." } +} diff --git a/desktop/modules/deckit/README.md b/desktop/modules/deckit/README.md new file mode 100644 index 0000000..59c85fd --- /dev/null +++ b/desktop/modules/deckit/README.md @@ -0,0 +1,36 @@ +# deckit + +One settings page, under Desktop, for deck-it (the tablet dashboard, its own +repo): whether it starts at login, and its `~/.config/deck-it/config.toml`. + +The config file is a sparse override. deck-it merges it over its shipped +`config.toml` key by key, so this page writes only what differs, and an +emptied field unsets the key and brings the default back. The defaults come +from `deck-it defaults` (JSON), run once when the page first loads, so they +are never copied here; deck-it must be on PATH (`~/bin/deck-it`, a link to +the repo's launcher). The draft rules are the settings window's, through +sbbuilder's `Store` and `Toml.js`, which gained plain decimals for the display +scale and lists of numbers for the workspaces. + +Start at login is not part of the draft: like the Autostart page, it edits +hypr-theme's `autostart.lua` at once, through `Autostart.js`. On re-enables a +commented-out `deck-it` line or adds `deck-it wifi --wait` before the closing +`end)`; off comments out every enabled one. Any `hl.exec_cmd` line whose +program is `deck-it` counts, so a hand-written `deck-it watch-usb` shows as on. + +Launchers are desktop ids (`[launch] apps`), added from the installed apps +the way the Autostart page picks one, minus terminal apps. Up and Remove edit +the list; the order is the button order on the tablet. + +deck-it reads its config once at start, so a save applies on Restart +deck-it, which runs `deck-it stop` and then the login command. + +While the page is open it polls `deck-it status` (JSON: server, display, +waiting, tablet, extend) every 2s and shows one line with a dot: green when +the server runs with a tablet tunnelled (and the display streams, if enabled), +yellow while starting or waiting for the tablet, red when stopped. After a +Restart it reports "Restarted" once that state is green, or what it saw if +it is not back within 30s; a `wifi --wait` with no tablet in reach ends there. + +Check: `node desktop/modules/sbbuilder/test_toml.js`; the deck-it side is +tested in its repo. diff --git a/desktop/modules/qtmaildir/ChoiceSetting.qml b/desktop/modules/qtmaildir/ChoiceSetting.qml index 01de9da..0a365d0 100644 --- a/desktop/modules/qtmaildir/ChoiceSetting.qml +++ b/desktop/modules/qtmaildir/ChoiceSetting.qml @@ -22,10 +22,12 @@ Column { // [{value, label}] property var options: [] property string fallbackLabel: "Default" + // The draft it edits; qtmaildir's by default, deck-it passes its own. + property var store: Qtm objectName: section + "/" + key width: parent ? parent.width : 0 - readonly property string value: Qtm.get(s.section, s.key) ?? "" + readonly property string value: s.store.get(s.section, s.key) ?? "" Item { width: s.width @@ -66,7 +68,7 @@ Column { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: Qtm.set(s.section, s.key, chip.modelData.value === "" ? null : chip.modelData.value) + onClicked: s.store.set(s.section, s.key, chip.modelData.value === "" ? null : chip.modelData.value) } } } diff --git a/desktop/modules/sbbuilder/Store.qml b/desktop/modules/sbbuilder/Store.qml index 7036f36..e6e9b09 100644 --- a/desktop/modules/sbbuilder/Store.qml +++ b/desktop/modules/sbbuilder/Store.qml @@ -74,7 +74,9 @@ QtObject { if (section === "manifests" && t === "") return st.markInvalid(section + "/" + key, true); if (t === null || t === "") return st.put(section, key, null); const d = st.fallback(section, key); - st.put(section, key, Array.isArray(d) ? t.split(/\s+/).filter(x => x !== "") + const words = t.split(/\s+/).filter(x => x !== ""); + // A list of numbers stays one, deck-it's workspaces for one. + st.put(section, key, Array.isArray(d) ? (typeof d[0] === "number" ? words.map(Number) : words) : typeof d === "number" ? Number(t) : typeof d === "boolean" ? t === "true" : t); } diff --git a/desktop/modules/sbbuilder/Toml.js b/desktop/modules/sbbuilder/Toml.js index dddb6f7..eb29c88 100644 --- a/desktop/modules/sbbuilder/Toml.js +++ b/desktop/modules/sbbuilder/Toml.js @@ -17,7 +17,7 @@ // set again is activated. Top-level keys are section "". // // ponytail: the TOML these files use, one key per line: strings, integers, -// booleans, one-line arrays. A value with anything but a comment after it +// plain decimals, booleans, one-line arrays. A value with anything but a comment after it // (multi-line strings and arrays included) is refused: entries() skips it and // set() throws rather than write a duplicate key; such a file is edited by // hand. Also not recognised: CRLF line endings, `[ spaced ]` and `[[array]]` @@ -67,7 +67,7 @@ function val(s, i) { } return { v: out, i: i + 1 }; } - const m = /^(true|false|[+-]?[0-9][0-9_]*)(?=[\s#,\]]|$)/.exec(s.slice(i)); + const m = /^(true|false|[+-]?[0-9][0-9_]*(?:\.[0-9][0-9_]*)?)(?=[\s#,\]]|$)/.exec(s.slice(i)); if (!m) return null; const v = m[1] === "true" ? true : m[1] === "false" ? false : Number(m[1].replace(/_/g, "")); return { v: v, i: i + m[1].length }; diff --git a/desktop/modules/sbbuilder/test_toml.js b/desktop/modules/sbbuilder/test_toml.js index c86741d..7e62b4e 100644 --- a/desktop/modules/sbbuilder/test_toml.js +++ b/desktop/modules/sbbuilder/test_toml.js @@ -102,4 +102,8 @@ assert.deepStrictEqual(T.sections(file), ["manifests", "refresh"]); assert.deepStrictEqual(T.sections("a = 1\n[manifests]\n"), ["manifests"]); assert.deepStrictEqual(T.sections("a = 1\n"), []); +// Decimals: read, and rewritten in place (deck-it's display scale). +assert.strictEqual(T.get("[display]\nscale = 1.5 # hi\n", "display", "scale"), 1.5); +assert.strictEqual(T.set("[display]\nscale = 1.5 # hi\n", "display", "scale", 2), "[display]\nscale = 2 # hi\n"); + console.log("test_toml: ok"); |
