// 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") // autostart.lua, from the module. required property string file 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: page.file // 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 { activeFocusOnTab: true 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] ?? "?" // What a search hit scrolls to (AutostartModule's searchItems key). objectName: modelData.cmd 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() } } }