The audit's second tier: values that were measurements of the author's desktop, shipped to every machine as if they were defaults. Settings greeted every human as Gabriel; it now greets whoever accountsservice says is signed in, and nobody when it says nothing. The weather shipped his home coordinates and confidently reported his forecast anywhere on earth; it now ships unset, fetches nothing until a location is chosen, and the location row says so. The GTK bookmarks carried seven /home/gib paths and his file server into every file dialog; they are now generated per machine from a template and gitignored -- Nautilus edits the instance freely, the way settings.ini already worked one file over. Web search routed through his personal bang redirector; the engine is now the webSearchUrl preference with a DuckDuckGo default, read by both the script command and the suggestions extension, which the launcher-search contract already pins to one another. The GPU vitals path defaulted to his card1 and lost the readout on any machine enumerated differently; a machine with exactly one GPU now adopts it. And the Containers and Snapshots pages hide once a scan proves their backing stack absent, instead of rendering permanently empty on machines that never had podman or snapper. Lesser residue swept in the same pass: the DP-2 hyprpaper block one machine needed, the author's username-typo expansions (moved to his personal seed in user/, where personal content belongs), a capture fallback into /home/gib, and a parity table asserting one machine's hardware as fact. Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
367 lines
16 KiB
QML
367 lines
16 KiB
QML
// Applications and session startup.
|
|
|
|
import Quickshell
|
|
import QtQuick
|
|
import qs.services
|
|
|
|
SettingsPage {
|
|
id: root
|
|
|
|
objectName: "applications"
|
|
title: "Applications"
|
|
lede: "Choose what opens your files and links, and what starts with your session."
|
|
|
|
property string expandedRole: ""
|
|
property bool addingAutostart: false
|
|
|
|
// Which entry has been asked to be removed. Removal deletes a file, so
|
|
// it never happens on a first press.
|
|
property string confirmingAutostartRemoval: ""
|
|
readonly property var applications: DesktopEntries.applications.values
|
|
// Each role governs a whole family of types, not one representative: setting
|
|
// "Images" writes PNG, JPEG, WebP and the rest together, so a file manager
|
|
// can never open one image in a viewer and its neighbour in an editor.
|
|
// The detail line names the family the way someone would describe it.
|
|
readonly property var roles: [
|
|
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
|
|
{ key: "mail", label: "Mail", detail: "Email links", categorySets: [["email"]], terms: ["mail client", "email client"] },
|
|
{ key: "files", label: "Files", detail: "Folders and file locations", categorySets: [["filemanager"]], terms: ["file manager"] },
|
|
{ key: "terminal", label: "Terminal", detail: "Terminal links and command-line handoffs", categorySets: [["terminalemulator"]], terms: ["terminal emulator", "terminal"] },
|
|
{ key: "images", label: "Images", detail: "PNG, JPEG, GIF, WebP, SVG and other pictures", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] },
|
|
{ key: "music", label: "Music", detail: "MP3, FLAC, Ogg and other audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] },
|
|
{ key: "video", label: "Video", detail: "MP4, MKV, WebM and other video", categorySets: [["video"]], terms: ["video player", "movie player"] },
|
|
{ key: "documents", label: "Documents", detail: "PDF and EPUB documents", categorySets: [["office", "viewer"]], terms: ["document viewer", "pdf viewer", "ebook", "e-book"] },
|
|
{ key: "text", label: "Text", detail: "Plain text, Markdown, and source files", categorySets: [["texteditor"]], terms: ["text editor", "code editor"] },
|
|
{ key: "archives", label: "Archives", detail: "Zip, tar, and other archives", categorySets: [["archiving"], ["filemanager"]], terms: ["archive manager", "file roller", "file manager"] }
|
|
]
|
|
|
|
function desktopId(entry: var): string {
|
|
const entryId = String(entry?.id ?? "");
|
|
return entryId.endsWith(".desktop") ? entryId : entryId + ".desktop";
|
|
}
|
|
|
|
function displayName(entry: var): string {
|
|
return String(entry?.name || entry?.genericName || root.desktopId(entry));
|
|
}
|
|
|
|
function currentHandler(role: string): string {
|
|
return String(DefaultApps.handlers[role] ?? "");
|
|
}
|
|
|
|
function currentEntry(role: string): var {
|
|
const handler = root.currentHandler(role);
|
|
return root.applications.find(entry => root.desktopId(entry) === handler) ?? null;
|
|
}
|
|
|
|
function matchesRole(entry: var, role: var): bool {
|
|
// DesktopEntries hands back a QML list, not a JavaScript array, so
|
|
// Array.isArray is false for it. The old code took that as "this is a
|
|
// string", stringified the list into "Network,WebBrowser" and then split
|
|
// on ";" only -- producing the single token "network,webbrowser", which
|
|
// matches no category at all.
|
|
//
|
|
// Nothing failed loudly. Browsers still appeared because their generic
|
|
// name contains "web browser", so the terms fallback carried the role
|
|
// by itself. Archives matched NOTHING, which meant that row could only
|
|
// ever offer the application it already had.
|
|
//
|
|
// Joining first and splitting on both separators handles the list form
|
|
// and a plain string equally.
|
|
const raw = entry.categories;
|
|
const joined = Array.isArray(raw) ? raw.join(";") : String(raw ?? "");
|
|
const categories = [];
|
|
for (const value of joined.split(/[;,]/)) {
|
|
const category = value.trim().toLowerCase();
|
|
if (category !== "")
|
|
categories.push(category);
|
|
}
|
|
const metadata = [entry.name, entry.genericName]
|
|
.map(value => String(value ?? "").toLowerCase())
|
|
.join(" ");
|
|
return role.categorySets.some(set => set.every(category => categories.includes(category)))
|
|
|| role.terms.some(term => metadata.includes(term));
|
|
}
|
|
|
|
function choicesForRole(role: var): var {
|
|
const choices = root.applications.filter(entry => root.matchesRole(entry, role));
|
|
const currentEntry = root.currentEntry(role.key);
|
|
if (currentEntry && !choices.some(entry => root.desktopId(entry) === root.desktopId(currentEntry)))
|
|
choices.push(currentEntry);
|
|
return choices.sort((left, right) => root.displayName(left).localeCompare(root.displayName(right)));
|
|
}
|
|
|
|
TextRow {
|
|
visible: DefaultApps.lastError !== ""
|
|
label: "Application settings need attention"
|
|
detail: DefaultApps.lastError
|
|
value: ""
|
|
divider: false
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Default applications"
|
|
subtitle: "Open a row to choose from applications that advertise the matching role."
|
|
|
|
Repeater {
|
|
model: root.roles
|
|
|
|
delegate: Column {
|
|
id: roleBlock
|
|
|
|
required property var modelData
|
|
required property int index
|
|
readonly property var choices: root.choicesForRole(roleBlock.modelData)
|
|
readonly property var selectedEntry: root.currentEntry(roleBlock.modelData.key)
|
|
|
|
width: parent.width
|
|
|
|
SettingRow {
|
|
id: roleRow
|
|
|
|
readonly property bool open: root.expandedRole === roleBlock.modelData.key
|
|
|
|
label: roleBlock.modelData.label
|
|
detail: roleBlock.modelData.detail
|
|
activatable: roleBlock.choices.length > 0 && !DefaultApps.busy
|
|
controlWidth: 210
|
|
|
|
// Drawn rather than left to SettingRow's plain value text, so
|
|
// the row carries the same chevron a PickerRow does. These
|
|
// open a chooser but looked completely inert without it.
|
|
Row {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 9
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: DefaultApps.busy ? "Loading…" : (
|
|
roleBlock.selectedEntry
|
|
? root.displayName(roleBlock.selectedEntry)
|
|
: (root.currentHandler(roleBlock.modelData.key) || "Not set")
|
|
)
|
|
color: Theme.fgDim
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: roleBlock.choices.length > 0
|
|
text: roleRow.open ? "\u25B4" : "\u25BE"
|
|
color: Theme.fgMuted
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
}
|
|
|
|
divider: root.expandedRole !== roleBlock.modelData.key && roleBlock.index < root.roles.length - 1
|
|
onActivated: {
|
|
root.expandedRole = root.expandedRole === roleBlock.modelData.key
|
|
? ""
|
|
: roleBlock.modelData.key;
|
|
}
|
|
}
|
|
|
|
Column {
|
|
width: parent.width
|
|
visible: root.expandedRole === roleBlock.modelData.key
|
|
|
|
Repeater {
|
|
model: roleBlock.choices
|
|
|
|
delegate: SettingRow {
|
|
id: candidateRow
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
readonly property string candidateId: root.desktopId(candidateRow.modelData)
|
|
readonly property bool selected: candidateRow.candidateId === root.currentHandler(roleBlock.modelData.key)
|
|
|
|
label: root.displayName(candidateRow.modelData)
|
|
detail: String(candidateRow.modelData.genericName || candidateRow.modelData.comment || candidateRow.candidateId)
|
|
value: candidateRow.selected ? "Current" : ""
|
|
activatable: !candidateRow.selected && !DefaultApps.busy
|
|
divider: candidateRow.index < roleBlock.choices.length - 1 || roleBlock.index < root.roles.length - 1
|
|
onActivated: {
|
|
DefaultApps.setDefault(roleBlock.modelData.key, candidateRow.candidateId);
|
|
root.expandedRole = "";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// GNOME's Search panel, answered honestly.
|
|
//
|
|
// It configures which applications provide results in gnome-shell's
|
|
// overview and which folders are indexed. gnome-shell does not run here,
|
|
// so those settings would do nothing. Under Panama searching is the
|
|
// launcher's job, and Vicinae carries its own preferences -- reimplementing
|
|
// them here would give two places to change one thing.
|
|
SettingsCard {
|
|
title: "Search"
|
|
subtitle: "Applications, files, the calculator, clipboard history, emoji, and open windows are all searched from the launcher."
|
|
|
|
TextEntryRow { setting: "webSearchUrl"; placeholder: "https://duckduckgo.com/?q=" }
|
|
|
|
TextRow {
|
|
label: "Launcher"
|
|
detail: SystemSettings.vicinaeActive
|
|
? "Running as a user service"
|
|
: "Not running — Super+Shift+R opens the fallback launcher"
|
|
value: "Vicinae"
|
|
}
|
|
|
|
TextRow {
|
|
label: "Open search"
|
|
detail: "Three keys open it, because Super+A and Super+R were GNOME's app grid and run dialog"
|
|
value: "Super+Space"
|
|
}
|
|
|
|
ActionRow {
|
|
label: "Change these shortcuts"
|
|
detail: "Every launcher chord is rebindable, including clipboard history and emoji"
|
|
action: "Open keyboard"
|
|
divider: false
|
|
onTriggered: ShellState.openSettings("shortcuts")
|
|
}
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "User autostart"
|
|
subtitle: "Choose what starts with your session. Entries live in your user configuration, not the compositor."
|
|
|
|
ActionRow {
|
|
label: "Add an application"
|
|
detail: root.addingAutostart
|
|
? "Search the applications installed on this machine"
|
|
: "Start another installed application when you sign in"
|
|
action: root.addingAutostart ? "Close" : "Choose"
|
|
divider: !root.addingAutostart || DefaultApps.autostartEntries.length > 0
|
|
enabled: !DefaultApps.busy
|
|
onTriggered: root.addingAutostart = !root.addingAutostart
|
|
}
|
|
|
|
AutostartAppPicker {
|
|
visible: root.addingAutostart
|
|
width: parent.width
|
|
existing: DefaultApps.autostartEntries.map(entry => entry.id)
|
|
onPicked: id => {
|
|
DefaultApps.addAutostart(id);
|
|
root.addingAutostart = false;
|
|
}
|
|
}
|
|
|
|
TextRow {
|
|
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
|
|
label: "No user autostart entries"
|
|
detail: "Applications can add entries to ~/.config/autostart."
|
|
value: ""
|
|
divider: false
|
|
}
|
|
|
|
Repeater {
|
|
model: DefaultApps.autostartEntries
|
|
|
|
delegate: SettingRow {
|
|
id: autostartRow
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
readonly property bool confirming:
|
|
root.confirmingAutostartRemoval === String(autostartRow.modelData.id)
|
|
|
|
label: autostartRow.modelData.name
|
|
detail: autostartRow.confirming
|
|
? "Removing deletes this entry. Turning it off instead is reversible."
|
|
: autostartRow.modelData.id
|
|
divider: autostartRow.index < DefaultApps.autostartEntries.length - 1
|
|
controlWidth: 210
|
|
|
|
// A switch, not the words "Enabled"/"Disabled". The row always
|
|
// toggled on click, but read as static text, so a control that
|
|
// worked looked like a status nobody could change.
|
|
Row {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 9
|
|
|
|
SettingsButton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: autostartRow.confirming
|
|
text: "Remove it"
|
|
tone: "danger"
|
|
enabled: !DefaultApps.busy
|
|
onClicked: {
|
|
root.confirmingAutostartRemoval = "";
|
|
DefaultApps.removeAutostart(String(autostartRow.modelData.id));
|
|
}
|
|
}
|
|
|
|
SettingsButton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: autostartRow.confirming ? "Keep" : "Remove…"
|
|
enabled: !DefaultApps.busy
|
|
onClicked: root.confirmingAutostartRemoval =
|
|
autostartRow.confirming ? "" : String(autostartRow.modelData.id)
|
|
}
|
|
|
|
SettingsToggle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
checked: autostartRow.modelData.enabled
|
|
onToggled: value => DefaultApps.setAutostart(autostartRow.modelData.id, value)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Compositor autostart"
|
|
subtitle: "These are started from the Hyprland configuration. They are read-only here."
|
|
|
|
TextRow {
|
|
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
|
|
label: "No compositor entries found"
|
|
detail: "No hl.exec_cmd entries were found in config/dot/hypr/autostart.lua."
|
|
value: ""
|
|
divider: false
|
|
}
|
|
|
|
Repeater {
|
|
model: DefaultApps.luaAutostartEntries
|
|
|
|
delegate: TextRow {
|
|
id: luaRow
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
label: luaRow.modelData.name
|
|
detail: luaRow.modelData.command
|
|
value: "Hyprland"
|
|
divider: luaRow.index < DefaultApps.luaAutostartEntries.length - 1
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Refresh"
|
|
|
|
ActionRow {
|
|
label: "Reload application settings"
|
|
detail: "Re-read desktop entries, defaults, and user autostart files"
|
|
action: DefaultApps.busy ? "Refreshing…" : "Refresh"
|
|
enabled: !DefaultApps.busy
|
|
divider: false
|
|
onTriggered: DefaultApps.refresh()
|
|
}
|
|
}
|
|
}
|