aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/autostart/AutostartPage.qml
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules/autostart/AutostartPage.qml')
-rw-r--r--desktop/modules/autostart/AutostartPage.qml444
1 files changed, 444 insertions, 0 deletions
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()
+ }
+ }
+}