aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/deckit/DeckIt.qml
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-10 10:06:13 +0100
committerDanilo M. <danix@danix.xyz>2026-11-10 10:06:13 +0100
commit58e37da20ed56eef1863f259e17a819105ac33f2 (patch)
tree17e39338164390043b30e10aded0aecb2e6e3e03 /desktop/modules/deckit/DeckIt.qml
parentd10f2cc00c1091f63e335df1d6913c7abddf06e5 (diff)
downloadquickshell-58e37da20ed56eef1863f259e17a819105ac33f2.tar.gz
quickshell-58e37da20ed56eef1863f259e17a819105ac33f2.zip
feat(deckit): settings page for deck-it, the tablet dashboard
A page under Desktop for deck-it's ~/.config/deck-it/config.toml and its login line. deck-it now merges that file over its shipped config key by key, so the page writes only what differs and takes its placeholders from `deck-it defaults` rather than copying them. - Start at login edits hypr-theme's autostart.lua at once, as the Autostart page does, through Autostart.js: it re-enables a commented deck-it line or adds `deck-it wifi --wait`; off comments them out. - Every config key, with launchers picked from the installed apps (desktop ids, reorderable) and module switches built from the defaults. - A status line polls `deck-it status` every 2s while the page is open; Restart runs `deck-it stop` and the login command, then reports "Restarted" once the server runs with a tablet tunnelled, or what it saw after 30s. Shared helpers it needed: Toml.js reads plain decimals (the display scale), Store keeps a list of numbers numeric (the workspaces), and ChoiceSetting takes a store as Setting and BoolSetting already do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/modules/deckit/DeckIt.qml')
-rw-r--r--desktop/modules/deckit/DeckIt.qml198
1 files changed, 198 insertions, 0 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
+}