diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-17 09:54:18 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-17 09:54:18 +0200 |
| commit | 66049c965139d821747ea76960b1e91316f8110a (patch) | |
| tree | 45ac7a387ea02c1cc8d26e104f6e69a96d3880ed | |
| parent | 4513a506fc575f880db80f05bb3a9cdf7dc100e0 (diff) | |
| download | quickshell-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.md | 3 | ||||
| -rw-r--r-- | desktop/Module.qml | 6 | ||||
| -rw-r--r-- | desktop/README.md | 16 | ||||
| -rw-r--r-- | desktop/Settings.qml | 126 | ||||
| -rw-r--r-- | desktop/modules/autostart/Autostart.js | 129 | ||||
| -rw-r--r-- | desktop/modules/autostart/AutostartModule.qml | 30 | ||||
| -rw-r--r-- | desktop/modules/autostart/AutostartPage.qml | 444 | ||||
| -rw-r--r-- | desktop/modules/autostart/README.md | 39 | ||||
| -rw-r--r-- | desktop/modules/autostart/test_autostart.js | 87 | ||||
| -rw-r--r-- | desktop/shell.qml | 24 |
10 files changed, 902 insertions, 2 deletions
@@ -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" |
