aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-17 09:54:18 +0200
committerDanilo M. <danix@danix.xyz>2026-10-17 09:54:18 +0200
commit66049c965139d821747ea76960b1e91316f8110a (patch)
tree45ac7a387ea02c1cc8d26e104f6e69a96d3880ed
parent4513a506fc575f880db80f05bb3a9cdf7dc100e0 (diff)
downloadquickshell-66049c965139d821747ea76960b1e91316f8110a.tar.gz
quickshell-66049c965139d821747ea76960b1e91316f8110a.zip
desktop: a settings window, and autostart as its first page
The drawer's tiles were turning into a full settings app. The drawer stays the quick area, live state and things toggled often; pages set once and left alone go to a settings window, a normal Hyprland toplevel listing them down the left. A module joins with settings: true, which also drops its tile. A gear tile and ipc call settings open|toggle reach it. Autostart edits hypr-theme's autostart.lua directly, line by line: an hl.exec_cmd line is an entry, behind -- it is disabled, everything else passes through. Edits refuse when their line moved since the parse, and writes are atomic because Hyprland reloads on every change. A wrong entry only shows at the next login, so adding is guided rather than a free text field: pick an installed app or type a command whose program must resolve on PATH before Add enables, then a workspace and silent, composed into a [workspace X silent] rule. Try runs the line through hyprctl dispatch so the rule applies as at login. Existing rows get no Try: they ran at login, and a second start stacks an instance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--AGENTS.md3
-rw-r--r--desktop/Module.qml6
-rw-r--r--desktop/README.md16
-rw-r--r--desktop/Settings.qml126
-rw-r--r--desktop/modules/autostart/Autostart.js129
-rw-r--r--desktop/modules/autostart/AutostartModule.qml30
-rw-r--r--desktop/modules/autostart/AutostartPage.qml444
-rw-r--r--desktop/modules/autostart/README.md39
-rw-r--r--desktop/modules/autostart/test_autostart.js87
-rw-r--r--desktop/shell.qml24
10 files changed, 902 insertions, 2 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 7569df5..c54d5bc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,7 +12,8 @@ any of them runs alone, and running one does not require the others. `desktop/`
modules.
desktop/ the drawer (SUPER alone): user details, sound, mail, VMs, status,
- performance, clipboard, updates, appearance
+ performance, clipboard, updates, appearance; and the
+ settings window for pages set once, such as autostart
island/ top-centre pills on DP-1: clock, volume, tray, layout, privacy,
calendar
assistant/ the assistant canvas on SUPER+Home, a view over desktop-assistant
diff --git a/desktop/Module.qml b/desktop/Module.qml
index 50b4235..64fb69f 100644
--- a/desktop/Module.qml
+++ b/desktop/Module.qml
@@ -38,9 +38,13 @@ QtObject {
// Whether the tile should render in its active accent. Default false.
property bool active: false
+ // Whether the page lives in the settings window rather than the drawer:
+ // things set once and left alone. Such a module has no tile by default.
+ property bool settings: false
+
// Whether the drawer's grid shows a tile. False for a module reached
// some other way: the user page opens from the drawer's header row.
- property bool tiled: true
+ property bool tiled: !settings
// The page back() returns to from this module's page. Empty is the grid.
// The git page sets "user", the page it is opened from.
diff --git a/desktop/README.md b/desktop/README.md
index 092baae..3fd1cac 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -24,6 +24,19 @@ declared argument to be present and a defaulted parameter registers as a type
the IPC layer rejects. The page-less grid entry is therefore the zero-argument
`toggle`. `ipc call drawer close` closes it.
+## The settings window
+
+The drawer is the quick area: live state, and things toggled often. Pages
+that are set once and left alone live in a settings window instead, a normal
+Hyprland window rather than a layer surface, with those pages listed down its
+left. A module joins it with `settings: true`, which also drops its tile. The
+gear tile opens it, and so does IPC:
+
+ qs -p ./desktop ipc call settings open autostart
+ qs -p ./desktop ipc call settings toggle
+
+`open` takes a page name; an unknown or empty one opens the first page.
+
## The modules
modules/sound/ output and input volume, the OSD, the player
@@ -38,6 +51,7 @@ the IPC layer rejects. The page-less grid entry is therefore the zero-argument
modules/user/ the greeting row and the user's details page
modules/clipboard/ cliphist history: search, copy, delete, clear all
modules/git/ the global git config, opened from the user page
+ modules/autostart/ Hyprland's autostart.lua, in the settings window
Sound, mail, vm, network, bluetooth, kdeconnect and ai each carry their own README.
Appearance is a tile onto the separate appearance shell, whose README covers the
@@ -54,6 +68,8 @@ drawer needs to show it:
- `tileContent`: a `Component` for the short state line under the label, or
null for a tile that says nothing beyond its name.
- `page`: a `Component` for the full-height page behind the tile, or null.
+- `settings`: the page lives in the settings window, not the drawer, and the
+ module has no tile.
- `activate()`: what a tile with no page does when clicked.
- `alwaysActive`: declares whether the module's service is meant to run while
the drawer is closed. The drawer does not read it, the module implements its
diff --git a/desktop/Settings.qml b/desktop/Settings.qml
new file mode 100644
index 0000000..a397105
--- /dev/null
+++ b/desktop/Settings.qml
@@ -0,0 +1,126 @@
+// 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
+
+// The settings window: a normal toplevel, not a layer surface, so Hyprland
+// tiles or floats it like any app. The drawer is the quick area; things set
+// once and left alone live here. It hosts the pages of the modules that
+// declare `settings: true`, from the same module list as the drawer, with a
+// list of them down the left.
+Scope {
+ id: root
+
+ property list<QtObject> modules
+ property bool open: false
+ property string page: ""
+
+ readonly property var pages: {
+ const out = [];
+ for (let i = 0; i < root.modules.length; i++)
+ if (root.modules[i].settings) out.push(root.modules[i]);
+ return out;
+ }
+ readonly property QtObject current: root.pages.find(m => m.name === root.page) ?? root.pages[0] ?? null
+
+ // An unknown or empty name opens the first page.
+ function show(name) { root.page = name; root.open = true; }
+ function close() { root.open = false; }
+
+ LazyLoader {
+ active: root.open
+
+ FloatingWindow {
+ title: "Settings"
+ implicitWidth: 900
+ implicitHeight: 640
+ color: Theme.base
+ onClosed: root.close()
+
+ Item {
+ anchors.fill: parent
+ focus: true
+ Keys.onEscapePressed: root.close()
+
+ ListView {
+ id: side
+ anchors { top: parent.top; left: parent.left; bottom: parent.bottom; margins: 12 }
+ width: 200
+ spacing: 4
+ model: root.pages
+
+ delegate: Rectangle {
+ id: entry
+ required property QtObject modelData
+ readonly property bool selected: root.current === modelData
+ width: side.width
+ height: 36
+ radius: 8
+ color: selected ? Qt.alpha(Theme.accent, 0.22)
+ : area.containsMouse ? Qt.alpha(Theme.text, 0.06) : "transparent"
+
+ Row {
+ anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ spacing: 10
+ Text {
+ text: entry.modelData.icon
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize }
+ color: Theme.text
+ }
+ Text {
+ text: entry.modelData.label
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.text
+ }
+ }
+
+ MouseArea {
+ id: area
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.page = entry.modelData.name
+ }
+ }
+ }
+
+ Rectangle {
+ id: rule
+ anchors { top: parent.top; bottom: parent.bottom; left: side.right; leftMargin: 12 }
+ width: 1
+ color: Qt.alpha(Theme.text, 0.12)
+ }
+
+ Loader {
+ id: pageLoader
+ anchors { top: parent.top; right: parent.right; bottom: parent.bottom; left: rule.right; margins: 16 }
+ sourceComponent: root.current?.page ?? null
+
+ // The Page's back arrow and dismiss close the window;
+ // go() moves to another settings page by name.
+ onLoaded: {
+ if (item && item.back) item.back.connect(root.close);
+ if (item && item.dismiss) item.dismiss.connect(root.close);
+ if (item && item.go) item.go.connect(name => root.page = name);
+ }
+
+ Binding {
+ target: pageLoader.item
+ property: "icon"
+ value: root.current ? root.current.icon : ""
+ when: pageLoader.item !== null
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/desktop/modules/autostart/Autostart.js b/desktop/modules/autostart/Autostart.js
new file mode 100644
index 0000000..81510d7
--- /dev/null
+++ b/desktop/modules/autostart/Autostart.js
@@ -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.
+
+.pragma library
+
+// Pure helpers for the autostart page, tested by test_autostart.js under node.
+//
+// autostart.lua is walked line by line. A line that is exactly
+// `hl.exec_cmd("...")` is an entry, the same behind `-- ` is a disabled entry,
+// and every other line passes through untouched. Each edit returns the new
+// file text, or null when the line it targets is no longer what was parsed
+// (the file changed underneath), so a stale page never edits the wrong line.
+
+const RE = /^(\s*)(-- ?)?hl\.exec_cmd\("((?:[^"\\]|\\.)*)"\)\s*$/;
+
+// ponytail: only \" and \\ are decoded, the two escapes the file uses; a
+// \n would show as n. Fine while commands are typed on one line.
+function unquote(s) { return s.replace(/\\(.)/g, "$1"); }
+function quote(s) { return s.replace(/[\\"]/g, c => "\\" + c); }
+
+// A command's leading `[rules]`, as hl.exec_cmd takes them, and the rest.
+function split(cmd) {
+ const m = /^\[([^\]]*)\]\s*(.*)$/.exec(cmd);
+ return m ? { rule: m[1].trim(), body: m[2] } : { rule: "", body: cmd };
+}
+
+// {ws, silent} for no rule or a lone workspace rule; null for any other rule,
+// which the page shows as is and does not offer to edit.
+function workspace(rule) {
+ if (rule === "") return { ws: "", silent: false };
+ const m = /^workspace\s+(\S+)(\s+silent)?$/.exec(rule);
+ return m ? { ws: m[1], silent: !!m[2] } : null;
+}
+
+function compose(ws, silent, body) {
+ return ws ? "[workspace " + ws + (silent ? " silent" : "") + "] " + body : body;
+}
+
+// Shell words, with quotes and backslashes resolved. ponytail: no $(...),
+// globs or operators beyond splitting at them; enough to find a program.
+function words(s) {
+ const out = [];
+ let cur = null, q = "";
+ for (let i = 0; i < s.length; i++) {
+ const c = s[i];
+ if (q) {
+ if (c === q) q = "";
+ else if (c === "\\" && q === '"' && i + 1 < s.length) cur += s[++i];
+ else cur += c;
+ } else if (c === "'" || c === '"') { q = c; cur = cur ?? ""; }
+ else if (c === "\\" && i + 1 < s.length) cur = (cur ?? "") + s[++i];
+ else if (/\s/.test(c) || ";&|<>".includes(c)) {
+ if (cur !== null) out.push(cur);
+ cur = null;
+ if (!/\s/.test(c)) out.push(c);
+ } else cur = (cur ?? "") + c;
+ }
+ if (cur !== null) out.push(cur);
+ return out;
+}
+
+// The program a command line runs, for a PATH check: past a `[rules]`
+// prefix, VAR=value assignments, env and exec, and into `sh -c '...'`.
+// `~/` is expanded against home. Empty if there is none.
+function program(cmd, home) {
+ const w = words(split(cmd).body);
+ let i = 0;
+ while (i < w.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w[i]) || w[i] === "env" || w[i] === "exec")) i++;
+ const p = w[i] ?? "";
+ if (/^(ba|z|da)?sh$/.test(p.replace(/.*\//, "")) && w[i + 1] === "-c" && w[i + 2] !== undefined)
+ return program(w[i + 2], home);
+ return p.startsWith("~/") ? home + p.slice(1) : p;
+}
+
+// A .desktop Exec line as a command: the field codes (%U, %f, ...) dropped,
+// %% kept as a literal %.
+function fromExec(exec) {
+ return exec.replace(/%%/g, "\0").replace(/\s*%[a-zA-Z]/g, "").replace(/\0/g, "%").trim();
+}
+
+function line(indent, cmd, enabled) {
+ return indent + (enabled ? "" : "-- ") + 'hl.exec_cmd("' + quote(cmd) + '")';
+}
+
+// [{index, raw, indent, cmd, enabled}] in file order.
+function parse(text) {
+ const out = [];
+ text.split("\n").forEach((raw, index) => {
+ const m = RE.exec(raw);
+ if (m) out.push({ index, raw, indent: m[1], cmd: unquote(m[3]), enabled: !m[2] });
+ });
+ return out;
+}
+
+function edit(text, e, fn) {
+ const a = text.split("\n");
+ if (a[e.index] !== e.raw) return null;
+ fn(a);
+ return a.join("\n");
+}
+
+function toggle(text, e) {
+ return edit(text, e, a => a[e.index] = line(e.indent, e.cmd, !e.enabled));
+}
+
+function remove(text, e) {
+ return edit(text, e, a => a.splice(e.index, 1));
+}
+
+// A new enabled entry before the hook's closing `end)`, indented like the
+// last entry. Null if there is no `end)` to put it before.
+function add(text, cmd) {
+ const a = text.split("\n");
+ let end = -1;
+ for (let i = a.length - 1; i >= 0; i--)
+ if (/^end\)\s*$/.test(a[i])) { end = i; break; }
+ if (end < 0) return null;
+ const last = parse(text).pop();
+ a.splice(end, 0, line(last ? last.indent : " ", cmd, true));
+ return a.join("\n");
+}
diff --git a/desktop/modules/autostart/AutostartModule.qml b/desktop/modules/autostart/AutostartModule.qml
new file mode 100644
index 0000000..bba8aff
--- /dev/null
+++ b/desktop/modules/autostart/AutostartModule.qml
@@ -0,0 +1,30 @@
+// 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 "../.."
+
+// What Hyprland launches at login, from hypr-theme's autostart.lua. Set once
+// and left alone, so it lives in the settings window, not on a tile.
+Module {
+ name: "autostart"
+ label: "Autostart"
+ // nf-fa-rocket, present in Inconsolata Nerd Font.
+ icon: ""
+ settings: true
+
+ page: Component {
+ Page {
+ title: "Autostart"
+ AutostartPage { width: parent.width }
+ }
+ }
+}
diff --git a/desktop/modules/autostart/AutostartPage.qml b/desktop/modules/autostart/AutostartPage.qml
new file mode 100644
index 0000000..1289a93
--- /dev/null
+++ b/desktop/modules/autostart/AutostartPage.qml
@@ -0,0 +1,444 @@
+// 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 Quickshell.Io
+import Quickshell.Hyprland
+import QtQuick
+import "../.."
+import "Autostart.js" as A
+
+// One row per hl.exec_cmd line in autostart.lua: a switch that comments it in
+// and out, the command, its workspace rule, a mark when its program is not on
+// PATH, and Remove, which asks once. No Run now on a row: those ran at login,
+// and a second start stacks a second instance (a second qs, AGENTS.md). Below, the add form: pick an
+// installed app or type a command, whose program must resolve before Add
+// enables, then a workspace and silent. Every edit is made on the file text
+// as last read, and refused if its line has moved.
+Column {
+ id: page
+
+ spacing: 6
+
+ readonly property string home: Quickshell.env("HOME")
+ property var entries: []
+ // Entry index -> the program's resolved path, "" when it is not found.
+ property var found: ({})
+ property string error: ""
+ // The line index whose Remove has been clicked once.
+ property int confirming: -1
+
+ // The add form.
+ property bool picking: true
+ property string query: ""
+ property string pickedName: ""
+ property string newCmd: ""
+ // "" nothing to check, "?" checking, "-" not found, else the path.
+ property string newFound: ""
+ property string newWs: ""
+ property bool newSilent: true
+
+ FileView {
+ id: fv
+ path: (Quickshell.env("XDG_CONFIG_HOME") || page.home + "/.config")
+ + "/hypr/sections/autostart.lua"
+ // Hyprland reloads its config on every change, so a half-written file
+ // would reach it.
+ atomicWrites: true
+ watchChanges: true
+ onFileChanged: reload()
+ onLoaded: page.setEntries(A.parse(text()))
+ onLoadFailed: err => page.error = "Cannot read " + path + ": " + FileViewError.toString(err)
+ onSaveFailed: err => page.error = "Cannot write " + path + ": " + FileViewError.toString(err)
+ }
+
+ function setEntries(list) {
+ page.entries = list;
+ page.confirming = -1;
+ page.check(list.map(e => A.program(e.cmd, page.home)), paths => {
+ const f = {};
+ list.forEach((e, i) => f[e.index] = paths[i]);
+ page.found = f;
+ });
+ }
+
+ // Each program's path as `command -v` finds it on this shell's PATH,
+ // which is the one Hyprland passes to what it launches. "" if not found.
+ function check(progs, cb) {
+ if (progs.length === 0) { cb([]); return; }
+ op.createObject(page, {
+ command: ["sh", "-c", 'for p; do command -v -- "$p" || echo; done', "sh"].concat(progs),
+ cb: out => cb(out.split("\n").slice(0, progs.length)),
+ }).running = true;
+ }
+ // One Process per call, so checks never share one (a running Process
+ // ignores a second start, AGENTS.md).
+ Component {
+ id: op
+ Process {
+ id: proc
+ property var cb: null
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try { proc.cb(text); }
+ finally { proc.destroy(); }
+ }
+ }
+ }
+ }
+
+ // Through Hyprland, so a [workspace ...] rule applies as it will at login.
+ function run(cmd) {
+ Quickshell.execDetached(["hyprctl", "dispatch", 'hl.dsp.exec_cmd("' + A.quote(cmd) + '")']);
+ }
+
+ // t is the new file text from Autostart.js, or null when the line an
+ // edit targeted is no longer the one parsed.
+ function apply(t) {
+ page.confirming = -1;
+ if (t === null) {
+ page.error = "autostart.lua changed underneath, reloaded. Try again.";
+ fv.reload();
+ return;
+ }
+ page.error = "";
+ fv.setText(t);
+ page.setEntries(A.parse(t));
+ }
+
+ readonly property string composed: A.compose(page.newWs, page.newSilent, page.newCmd.trim())
+ readonly property bool addable: page.newCmd.trim() !== "" && page.newFound !== "" && page.newFound !== "?" && page.newFound !== "-"
+
+ function add() {
+ if (!page.addable) return;
+ const t = A.add(fv.text(), page.composed);
+ if (t === null) { page.error = "No closing end) in autostart.lua to add before."; return; }
+ page.apply(t);
+ page.newCmd = "";
+ page.pickedName = "";
+ page.query = "";
+ }
+
+ onNewCmdChanged: { page.newFound = page.newCmd.trim() === "" ? "" : "?"; debounce.restart(); }
+ Timer {
+ id: debounce
+ interval: 250
+ onTriggered: {
+ const cmd = page.newCmd;
+ if (cmd.trim() === "") return;
+ page.check([A.program(cmd, page.home)], p => {
+ if (cmd === page.newCmd) page.newFound = p[0] || "-";
+ });
+ }
+ }
+
+ // The workspaces offered: the scratchpad, the eight numbered ones, and
+ // any other name Hyprland has open or an entry already uses.
+ readonly property var workspaces: {
+ const names = ["special", "1", "2", "3", "4", "5", "6", "7", "8"];
+ const more = Hyprland.workspaces.values.map(w => w.name)
+ .concat(page.entries.map(e => (A.workspace(A.split(e.cmd).rule) ?? { ws: "" }).ws));
+ for (const n of more) if (n && !names.includes(n)) names.push(n);
+ return names;
+ }
+
+ // Installed apps for the picker: the launcher's .desktop entries, minus
+ // hidden ones and terminal apps, which would need a terminal wrapped
+ // around them. ponytail: terminal apps go through "Command" instead.
+ readonly property var apps: {
+ const q = page.query.trim().toLowerCase();
+ return DesktopEntries.applications.values
+ .filter(a => !a.noDisplay && !a.runInTerminal && a.execString
+ && (q === "" || a.name.toLowerCase().includes(q)
+ || (a.genericName ?? "").toLowerCase().includes(q)))
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .slice(0, 8);
+ }
+
+ component Small: Text {
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ component Field: Rectangle {
+ id: field
+ property string text: ""
+ property string placeholder: ""
+ signal edited(string text)
+ 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 }
+ text: field.text
+ onTextEdited: field.edited(text)
+ onAccepted: field.accepted()
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: parent.text === ""
+ text: field.placeholder
+ color: Theme.subtext
+ font: parent.font
+ }
+ }
+ }
+
+ // A selectable pill: the picker/command switch and the workspace choices.
+ component Chip: Rectangle {
+ id: chip
+ property string text: ""
+ property bool selected: false
+ signal clicked
+ implicitWidth: chipLabel.implicitWidth + 20
+ implicitHeight: 28
+ radius: 14
+ color: selected ? Qt.alpha(Theme.accent, 0.85)
+ : chipArea.containsMouse ? Qt.alpha(Theme.accent, 0.22) : Qt.alpha(Theme.surface, 0.8)
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.15)
+ Text {
+ id: chipLabel
+ anchors.centerIn: parent
+ text: chip.text
+ color: chip.selected ? Theme.base : Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ MouseArea {
+ id: chipArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: chip.clicked()
+ }
+ }
+
+ Small {
+ width: parent.width
+ text: "Changes apply at next login. Try starts a new entry in this session."
+ wrapMode: Text.Wrap
+ }
+
+ Text {
+ width: parent.width
+ visible: page.error !== ""
+ text: page.error
+ wrapMode: Text.Wrap
+ color: Theme.red
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ Repeater {
+ model: page.entries
+
+ // Items with anchors, not Rows: a Row sizes to its children and fixed
+ // widths in one overflow the page (AGENTS.md).
+ delegate: Item {
+ id: row
+ required property var modelData
+ readonly property var parts: A.split(modelData.cmd)
+ readonly property var where: A.workspace(parts.rule)
+ readonly property string path: page.found[modelData.index] ?? "?"
+ width: page.width
+ height: 44
+
+ Switch {
+ id: sw
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ checked: row.modelData.enabled
+ onToggled: page.apply(A.toggle(fv.text(), row.modelData))
+ }
+
+ Text {
+ id: cmdText
+ anchors { left: sw.right; leftMargin: 10; right: del.left; rightMargin: 8; top: parent.top; topMargin: 4 }
+ elide: Text.ElideMiddle
+ text: row.parts.body
+ color: row.modelData.enabled ? Theme.text : Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+
+ Small {
+ anchors { left: cmdText.left; right: cmdText.right; top: cmdText.bottom; topMargin: 2 }
+ elide: Text.ElideRight
+ color: row.path === "" ? Theme.red : Theme.subtext
+ text: [row.path === "" ? "not found: " + A.program(row.modelData.cmd, page.home) : "",
+ row.where === null ? "[" + row.parts.rule + "]"
+ : row.where.ws ? "workspace " + row.where.ws + (row.where.silent ? ", silent" : "") : ""]
+ .filter(s => s).join(" · ")
+ }
+
+ Button {
+ id: del
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: page.confirming === row.modelData.index ? "Confirm" : "Remove"
+ danger: true
+ onClicked: {
+ if (page.confirming === row.modelData.index)
+ page.apply(A.remove(fv.text(), row.modelData));
+ else
+ page.confirming = row.modelData.index;
+ }
+ }
+ }
+ }
+
+ // --- add ---
+
+ Text {
+ topPadding: 14
+ text: "Add"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
+ color: Theme.text
+ }
+
+ Flow {
+ width: parent.width
+ spacing: 6
+ Chip { text: "Installed app"; selected: page.picking; onClicked: { page.picking = true; page.newCmd = ""; } }
+ Chip { text: "Command"; selected: !page.picking; onClicked: { page.picking = false; page.newCmd = ""; page.pickedName = ""; } }
+ }
+
+ // The picker: search, then click an app to take its Exec line.
+ Field {
+ visible: page.picking
+ width: parent.width
+ placeholder: "search apps"
+ text: page.query
+ onEdited: t => page.query = t
+ }
+
+ Repeater {
+ model: page.picking ? page.apps : []
+
+ delegate: Rectangle {
+ id: app
+ required property var modelData
+ readonly property bool selected: page.pickedName === modelData.name
+ width: page.width
+ height: 32
+ radius: 8
+ color: selected ? Qt.alpha(Theme.accent, 0.22)
+ : appArea.containsMouse ? Qt.alpha(Theme.text, 0.06) : "transparent"
+
+ Image {
+ id: appIcon
+ 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: appIcon.right; leftMargin: 10; right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ elide: Text.ElideRight
+ text: app.modelData.name + " " + A.fromExec(app.modelData.execString)
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ MouseArea {
+ id: appArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ page.pickedName = app.modelData.name;
+ page.newCmd = A.fromExec(app.modelData.execString);
+ }
+ }
+ }
+ }
+
+ // The command, typed, or picked and then adjustable.
+ Field {
+ visible: !page.picking || page.pickedName !== ""
+ width: parent.width
+ placeholder: "command, e.g. nm-applet --indicator"
+ text: page.newCmd
+ onEdited: t => page.newCmd = t
+ onAccepted: page.add()
+ }
+
+ Small {
+ visible: page.newFound !== ""
+ width: parent.width
+ elide: Text.ElideMiddle
+ color: page.newFound === "-" ? Theme.red : page.newFound === "?" ? Theme.subtext : Theme.green
+ text: page.newFound === "?" ? "checking…"
+ : page.newFound === "-" ? "not found on PATH: " + A.program(page.newCmd, page.home)
+ : "found: " + page.newFound
+ }
+
+ Small { topPadding: 6; text: "Workspace" }
+
+ Flow {
+ width: parent.width
+ spacing: 6
+ Chip { text: "Current"; selected: page.newWs === ""; onClicked: page.newWs = "" }
+ Repeater {
+ model: page.workspaces
+ Chip {
+ required property string modelData
+ text: modelData === "special" ? "scratchpad" : modelData.replace(/^special:/, "")
+ selected: page.newWs === modelData
+ onClicked: page.newWs = modelData
+ }
+ }
+ }
+
+ // Silent: the window goes to its workspace without switching to it.
+ Item {
+ width: parent.width
+ height: 32
+ opacity: page.newWs !== "" ? 1 : 0.45
+ Switch {
+ id: silent
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ enabled: page.newWs !== ""
+ checked: page.newSilent
+ onToggled: page.newSilent = checked
+ }
+ Small {
+ anchors { left: silent.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: "Silent: open there without switching to it"
+ }
+ }
+
+ Item {
+ width: parent.width
+ height: 34
+
+ Small {
+ anchors { left: parent.left; right: tryBtn.left; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ elide: Text.ElideMiddle
+ text: page.newCmd.trim() ? page.composed : ""
+ }
+ Button {
+ id: tryBtn
+ anchors { right: addBtn.left; rightMargin: 6; verticalCenter: parent.verticalCenter }
+ text: "Try"
+ enabled: page.addable
+ onClicked: page.run(page.composed)
+ }
+ Button {
+ id: addBtn
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: "Add"
+ enabled: page.addable
+ onClicked: page.add()
+ }
+ }
+}
diff --git a/desktop/modules/autostart/README.md b/desktop/modules/autostart/README.md
new file mode 100644
index 0000000..eea0c53
--- /dev/null
+++ b/desktop/modules/autostart/README.md
@@ -0,0 +1,39 @@
+# autostart
+
+What Hyprland launches at login: hypr-theme's `sections/autostart.lua`, which
+this page owns. It lives in the settings window, not the drawer, because it is
+set once and left alone.
+
+Adding is guided, because a wrong entry only shows at the next login. Pick
+an installed app (the launcher's `.desktop` entries, Exec line minus its
+`%U`-style field codes; terminal apps are left out and go through Command), or
+type a command. Either way the program is looked up on PATH as it changes, past
+`VAR=value`, `env`, `exec` and into `sh -c '...'`, and Add stays disabled until
+it resolves. A workspace (the scratchpad, 1 to 8, or any name in use) and
+silent become a `[workspace <name> silent]` rule. Try starts the composed line
+through `hyprctl dispatch hl.dsp.exec_cmd(...)`, so the rule applies as at
+login. Existing rows show their rule and a red mark when their program is no
+longer found. They have no Try: they already ran at login, and a second start
+stacks a second instance. Arguments are not checked, only the program.
+
+The file is walked line by line. A line that is exactly `hl.exec_cmd("...")`
+is an entry, the same line behind `-- ` is a disabled entry, and every other
+line passes through untouched. The switch comments a line in and out, Remove
+asks once and deletes it, and the field at the bottom adds an enabled line
+before the hook's closing `end)`. The file therefore carries no comments,
+since deleting an entry would orphan the one above it; the reason behind each
+line lives in the launched project's own docs.
+
+Every edit is made on the text as last read and refused if its line is no
+longer what was parsed, so an edit in an editor meanwhile is never
+overwritten blind: the page reloads and asks to try again. Writes are atomic,
+because Hyprland reloads its config on every change and must never see a
+half-written file.
+
+`hyprland.start` fires once per session, so a change applies at next login.
+
+Not handled: a command spread over several lines or written as a `[[...]]`
+long string is not an entry and is left alone, and only the `\"` and `\\`
+escapes are decoded.
+
+Check: `node desktop/modules/autostart/test_autostart.js`.
diff --git a/desktop/modules/autostart/test_autostart.js b/desktop/modules/autostart/test_autostart.js
new file mode 100644
index 0000000..00ba6b2
--- /dev/null
+++ b/desktop/modules/autostart/test_autostart.js
@@ -0,0 +1,87 @@
+// 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/autostart/test_autostart.js
+// Autostart.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 + "/Autostart.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const A = new Function(src + "\nreturn { parse, toggle, remove, add, split, workspace, compose, program, fromExec };")();
+
+const file = [
+ 'hl.on("hyprland.start", function()',
+ ' hl.exec_cmd("playerctld daemon")',
+ ' -- hl.exec_cmd("[workspace special silent] kitty btop")',
+ ' hl.exec_cmd("app >> \\"$XDG_RUNTIME_DIR/x.log\\" 2>&1")',
+ ' hl.on("other", function() end)',
+ 'end)',
+ '',
+].join("\n");
+
+const e = A.parse(file);
+assert.deepStrictEqual(e.map(x => [x.index, x.cmd, x.enabled]), [
+ [1, "playerctld daemon", true],
+ [2, "[workspace special silent] kitty btop", false],
+ [3, 'app >> "$XDG_RUNTIME_DIR/x.log" 2>&1', true],
+]);
+
+// Toggle both ways; untouched lines survive byte for byte.
+const off = A.toggle(file, e[0]);
+assert.strictEqual(off.split("\n")[1], ' -- hl.exec_cmd("playerctld daemon")');
+assert.strictEqual(A.toggle(off, A.parse(off)[0]), file);
+assert.strictEqual(A.toggle(file, e[1]).split("\n")[2],
+ ' hl.exec_cmd("[workspace special silent] kitty btop")');
+// Quotes round-trip through unquote and quote.
+const q = A.toggle(file, e[2]);
+assert.strictEqual(A.toggle(q, A.parse(q)[2]), file);
+
+assert.strictEqual(A.remove(file, e[1]).split("\n").length, file.split("\n").length - 1);
+assert.ok(!A.remove(file, e[1]).includes("btop"));
+
+// Added before the hook's own `end)`, not the nested one, quotes escaped.
+const added = A.add(file, 'say "hi"').split("\n");
+assert.strictEqual(added[5], ' hl.exec_cmd("say \\"hi\\"")');
+assert.strictEqual(added[6], "end)");
+assert.strictEqual(A.add("nothing here", "x"), null);
+
+// A stale entry, its line changed since parse, refuses rather than edits.
+assert.strictEqual(A.toggle(off, e[0]), null);
+assert.strictEqual(A.remove(A.remove(file, e[0]), e[1]), null);
+
+// The rule prefix, and the workspace form the page edits.
+assert.deepStrictEqual(A.split("[workspace special:dash silent] conky -c x"),
+ { rule: "workspace special:dash silent", body: "conky -c x" });
+assert.deepStrictEqual(A.split("conky"), { rule: "", body: "conky" });
+assert.deepStrictEqual(A.workspace("workspace special:dash silent"), { ws: "special:dash", silent: true });
+assert.deepStrictEqual(A.workspace("workspace 3"), { ws: "3", silent: false });
+assert.deepStrictEqual(A.workspace(""), { ws: "", silent: false });
+assert.strictEqual(A.workspace("float; size 50% 50%"), null);
+assert.strictEqual(A.compose("special", true, "btop"), "[workspace special silent] btop");
+assert.strictEqual(A.compose("", true, "btop"), "btop");
+
+// The program behind each live shape of command.
+const H = "/home/you";
+assert.strictEqual(A.program("playerctld daemon", H), "playerctld");
+assert.strictEqual(A.program("[workspace special silent] kitty --class k btop", H), "kitty");
+assert.strictEqual(A.program("~/bin/cal-notif daemon >> \"$X/c.log\" 2>&1", H), "/home/you/bin/cal-notif");
+assert.strictEqual(A.program("bash -c 'eval $(gnome-keyring-daemon --start) && dbus-update'", H), "eval");
+assert.strictEqual(A.program("bash -c 'echo \"$D\" > /run/x'", H), "echo");
+assert.strictEqual(A.program("FOO=1 env BAR=2 exec /usr/libexec/agent", H), "/usr/libexec/agent");
+assert.strictEqual(A.program("", H), "");
+
+assert.strictEqual(A.fromExec("firefox %u"), "firefox");
+assert.strictEqual(A.fromExec("app --name=%%x %F --flag"), "app --name=%x --flag");
+
+console.log("test_autostart: ok");
diff --git a/desktop/shell.qml b/desktop/shell.qml
index f7a1713..bceb568 100644
--- a/desktop/shell.qml
+++ b/desktop/shell.qml
@@ -19,6 +19,7 @@ import Quickshell.Wayland
import QtQuick
import "modules/ai"
import "modules/appearance"
+import "modules/autostart"
import "modules/bluetooth"
import "modules/clipboard"
import "modules/git"
@@ -74,9 +75,24 @@ ShellRoot {
UserModule {},
GitModule {},
NotesModule {},
+ // The settings window's entry point, a tile like the others.
+ Module {
+ name: "settings"
+ // nf-fa-gear, present in Inconsolata Nerd Font.
+ icon: "\uf013"
+ function activate() { settings.show(""); }
+ },
+ AutostartModule {},
]
}
+ // Pages set once and left alone, the modules marked `settings`, in a
+ // window of their own. See Settings.qml.
+ Settings {
+ id: settings
+ modules: drawer.modules
+ }
+
// The island on DP-1. It reads the sound module's service rather than
// binding PipeWire a second time.
Island {
@@ -105,6 +121,14 @@ ShellRoot {
function close() { drawer.close(); }
}
+ // qs -p <this dir> ipc call settings open autostart
+ // qs -p <this dir> ipc call settings toggle
+ IpcHandler {
+ target: "settings"
+ function open(page: string) { settings.show(page); }
+ function toggle() { if (settings.open) settings.close(); else settings.show(""); }
+ }
+
// qs -p <this dir> ipc call assistant toggle -> SUPER+Home
IpcHandler {
target: "assistant"