aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md3
-rw-r--r--README.md2
-rw-r--r--desktop/modules/deckit/DeckIt.qml198
-rw-r--r--desktop/modules/deckit/DeckItModule.qml57
-rw-r--r--desktop/modules/deckit/DeckItPage.qml282
-rw-r--r--desktop/modules/deckit/README.md36
-rw-r--r--desktop/modules/qtmaildir/ChoiceSetting.qml6
-rw-r--r--desktop/modules/sbbuilder/Store.qml4
-rw-r--r--desktop/modules/sbbuilder/Toml.js4
-rw-r--r--desktop/modules/sbbuilder/test_toml.js4
-rw-r--r--desktop/shell.qml2
11 files changed, 591 insertions, 7 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 375de73..6f637c6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,7 +16,8 @@ modules.
window for pages set once: wallpaper, transitions, theme, theme status,
icons, sunset, idle (the former appearance shell), notifications, autostart, shells,
the assistant's daemon and presets, qtmaildir, git,
- Hyprland's keyboard shortcuts, options and monitors, fonts, the Sonix boot logo
+ Hyprland's keyboard shortcuts, options and monitors, fonts, the Sonix boot logo,
+ deck-it (the tablet dashboard's autostart and config)
island/ top-centre pills on DP-1: clock, volume, Sonix player, tray, layout,
privacy, calendar, OBS
assistant/ the assistant canvas on SUPER+Home, a view over desktop-assistant
diff --git a/README.md b/README.md
index a915d94..15985ab 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ repos stay independent, this one has no build-time dependency on that one.
## Implementations
desktop/ the drawer: sound, mail, VMs; the settings window:
- wallpaper, theme, icons, sunset, idle, autostart, git
+ wallpaper, theme, icons, sunset, idle, autostart, git, deck-it
island/ top-centre pills: clock, volume, tray, layout, privacy
window-switcher/ open windows as live previews in a grid, on ALT+TAB
keybinds/ live keybind reminder, on SUPER+k
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");
diff --git a/desktop/shell.qml b/desktop/shell.qml
index 091484f..cf7084a 100644
--- a/desktop/shell.qml
+++ b/desktop/shell.qml
@@ -21,6 +21,7 @@ import "modules/ai"
import "modules/appearance"
import "modules/assistant"
import "modules/autostart"
+import "modules/deckit"
import "modules/qtmaildir"
import "modules/sbbuilder"
import "modules/shells"
@@ -108,6 +109,7 @@ ShellRoot {
OptionsModule {},
MonitorsModule {},
ObsScenesModule {},
+ DeckItModule {},
AssistantModule { id: asstSettings },
PresetsModule {},
MailGeneralModule {},