From 66049c965139d821747ea76960b1e91316f8110a Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 17 Oct 2026 09:54:18 +0200 Subject: 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 --- desktop/modules/autostart/AutostartPage.qml | 444 ++++++++++++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 desktop/modules/autostart/AutostartPage.qml (limited to 'desktop/modules/autostart/AutostartPage.qml') 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. +// +// 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() + } + } +} -- cgit v1.2.3