707 lines
31 KiB
QML
707 lines
31 KiB
QML
// Applications: what is installed, what opens your files, and what starts with
|
|
// your session.
|
|
//
|
|
// This page used to answer only the middle question. That made it the one place
|
|
// in Settings you could not do the obvious thing -- see what is on the machine
|
|
// and remove something -- so the list came first and everything else arranged
|
|
// itself around it.
|
|
//
|
|
// Two rules the page keeps to:
|
|
//
|
|
// It never pretends to own a system package. A Flatpak can be inspected and
|
|
// removed here because that is a per-user operation with per-user blast
|
|
// radius. `dnf remove` is not: removing the wrong package takes the desktop
|
|
// with it, and a settings pane should not put that one press away. The row
|
|
// says so and prints the command, which is more useful than a button that
|
|
// refuses.
|
|
//
|
|
// It never links somewhere with nothing on it. The "Elsewhere in Settings"
|
|
// chips appear per application, and only where that application already has a
|
|
// rule on the page being linked to.
|
|
|
|
import Quickshell
|
|
import QtQuick
|
|
import qs.config
|
|
import qs.services
|
|
import qs.modules.clipboard
|
|
|
|
SettingsPage {
|
|
id: root
|
|
|
|
objectName: "applications"
|
|
title: "Applications"
|
|
lede: "What is installed, what opens your files, and what starts with your session."
|
|
|
|
// Which installed application is unfolded. One at a time: the body is tall
|
|
// enough that two open rows would push the third off the screen.
|
|
property string expandedApp: ""
|
|
|
|
// Uninstalling deletes an application, so it never happens on a first
|
|
// press. Holds the Flatpak id that is one press away from being removed.
|
|
property string pendingUninstall: ""
|
|
|
|
// Which entry has been asked to be removed. Removal deletes a file, so
|
|
// it never happens on a first press.
|
|
property string confirmingAutostartRemoval: ""
|
|
|
|
property bool addingAutostart: false
|
|
property bool showingFileTypes: false
|
|
|
|
// Empty means "whichever category the catalog lists first", so the card is
|
|
// never blank before anything has been chosen.
|
|
property string catalogCategory: ""
|
|
|
|
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)));
|
|
}
|
|
|
|
// ── The installed list ───────────────────────────────────────────────────
|
|
|
|
// How many rows the card draws before it stops and says how many are left.
|
|
// A machine with 243 applications is a scroll, not a list; the search field
|
|
// is what actually finds one.
|
|
readonly property int installedLimit: 10
|
|
|
|
readonly property var installedApps: (AppLibrary.apps ?? []).filter(
|
|
app => AppLibrary.matches(app, installedSearch.text))
|
|
|
|
readonly property var expandedAppRecord:
|
|
(AppLibrary.apps ?? []).find(app => String(app.entryId ?? "") === root.expandedApp) ?? null
|
|
|
|
// Read for the open row only. The cache is read directly so this binding
|
|
// has a dependency on it -- that is what turns "Reading…" into the summary
|
|
// when the answer lands -- and permissionsFor() is what asks for an answer
|
|
// that is not there yet.
|
|
readonly property var expandedPermissions: {
|
|
const cache = AppLibrary.permissionCache;
|
|
const app = root.expandedAppRecord;
|
|
if (!app || String(app.kind ?? "") !== "flatpak")
|
|
return null;
|
|
const key = String(app.flatpakId ?? "");
|
|
const info = cache[key] ?? AppLibrary.permissionsFor(key);
|
|
return info && Array.isArray(info.summary) ? info : null;
|
|
}
|
|
|
|
// Flatseal is the tool that edits these; offering it when it is not
|
|
// installed would be a button that does nothing.
|
|
readonly property bool flatsealAvailable: root.applications.some(
|
|
entry => String(entry.id ?? "").replace(/\.desktop$/, "") === "com.github.tchx84.Flatseal")
|
|
|
|
function autostartId(app: var): string {
|
|
const id = String(app?.entryId ?? "");
|
|
return id.endsWith(".desktop") ? id : id + ".desktop";
|
|
}
|
|
|
|
function autostartEntry(desktopId: string): var {
|
|
return DefaultApps.autostartEntries.find(entry => String(entry.id) === desktopId) ?? null;
|
|
}
|
|
|
|
function autostartOn(app: var): bool {
|
|
const entry = root.autostartEntry(root.autostartId(app));
|
|
return entry !== null && entry.enabled !== false;
|
|
}
|
|
|
|
// Turning it on for an application that has never had an entry writes one;
|
|
// there is no separate "add" step from here, because from the row's point
|
|
// of view there is one switch and it means one thing.
|
|
function setAppAutostart(app: var, value: bool): void {
|
|
const desktopId = root.autostartId(app);
|
|
if (root.autostartEntry(desktopId) === null) {
|
|
if (value)
|
|
DefaultApps.addAutostart(desktopId);
|
|
return;
|
|
}
|
|
DefaultApps.setAutostart(desktopId, value);
|
|
}
|
|
|
|
// ── Where else this application already has a rule ───────────────────────
|
|
|
|
function appIdentifiers(app: var): var {
|
|
const out = [];
|
|
const entryId = String(app?.entryId ?? "").replace(/\.desktop$/, "");
|
|
const flatpakId = String(app?.flatpakId ?? "");
|
|
const name = String(app?.name ?? "");
|
|
for (const value of [entryId, flatpakId, name]) {
|
|
if (value !== "")
|
|
out.push(value.toLowerCase());
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function hasNotificationRule(app: var): bool {
|
|
const identifiers = root.appIdentifiers(app);
|
|
return (Notifs.applications ?? []).some(
|
|
known => identifiers.indexOf(String(known.id ?? "").toLowerCase()) >= 0);
|
|
}
|
|
|
|
// A stream, not a stored rule: the Sound page's per-application rows come
|
|
// from what is playing right now, so the chip is honest only while there is
|
|
// something there to change.
|
|
function hasSoundRule(app: var): bool {
|
|
const identifiers = root.appIdentifiers(app);
|
|
return (AudioDevices.applications ?? []).some(stream =>
|
|
identifiers.indexOf(String(stream.key ?? "").toLowerCase()) >= 0
|
|
|| identifiers.indexOf(String(stream.label ?? "").toLowerCase()) >= 0);
|
|
}
|
|
|
|
// The portal's permission store records answers under the application's own
|
|
// id, which is the same id this list carries -- so the match is exact and
|
|
// costs nothing.
|
|
function hasPrivacyRule(app: var): bool {
|
|
const identifiers = root.appIdentifiers(app);
|
|
return (Permissions.devices ?? []).some(device =>
|
|
(device.applications ?? []).some(
|
|
recorded => identifiers.indexOf(String(recorded.app ?? "").toLowerCase()) >= 0));
|
|
}
|
|
|
|
// ── The catalog ──────────────────────────────────────────────────────────
|
|
|
|
readonly property var catalogCategories: AppLibrary.categories ?? []
|
|
|
|
// A category arrives either as a plain name or as an object carrying its
|
|
// entries with it, so both are read rather than one being assumed.
|
|
function categoryId(category: var): string {
|
|
return String(category?.id ?? category?.name ?? category ?? "");
|
|
}
|
|
|
|
function categoryLabel(category: var): string {
|
|
const label = String(category?.label ?? category?.name ?? category?.id ?? category ?? "");
|
|
const count = Number(category?.count ?? (category?.entries ?? []).length ?? 0);
|
|
return count > 0 ? label + " · " + count : label;
|
|
}
|
|
|
|
readonly property string activeCategory: {
|
|
if (root.catalogCategory !== "")
|
|
return root.catalogCategory;
|
|
const categories = root.catalogCategories;
|
|
return categories.length > 0 ? root.categoryId(categories[0]) : "";
|
|
}
|
|
|
|
readonly property var catalogEntries: {
|
|
// Reading the category list is what makes this re-run once the catalog
|
|
// has been parsed; entriesFor() alone is a function call and would not.
|
|
const categories = root.catalogCategories;
|
|
if (categories.length === 0 || root.activeCategory === "")
|
|
return [];
|
|
return AppLibrary.entriesFor(root.activeCategory) ?? [];
|
|
}
|
|
|
|
// ── The launcher's own chord, read from the keymap ───────────────────────
|
|
|
|
// Hardcoding "Super+Space" survived two rebinds of the launcher and told
|
|
// the wrong story both times. The keymap is already loaded; this reads it.
|
|
readonly property var launcherChords: (Keybinds.binds ?? [])
|
|
.filter(bind => String(bind.description ?? "") === "Launcher")
|
|
.map(bind => String(bind.chord ?? ""))
|
|
.filter(chord => chord !== "")
|
|
|
|
// AppLibrary loads nothing on its own: `flatpak list` and the catalog cost
|
|
// a process each and only this page wants them, so a shell start where
|
|
// nobody opens Applications pays for neither.
|
|
Component.onCompleted: {
|
|
AppLibrary.refresh();
|
|
AppLibrary.refreshCatalog();
|
|
}
|
|
|
|
TextRow {
|
|
visible: DefaultApps.lastError !== ""
|
|
label: "Application settings need attention"
|
|
detail: DefaultApps.lastError
|
|
value: ""
|
|
divider: false
|
|
}
|
|
|
|
TextRow {
|
|
visible: String(AppLibrary.lastError ?? "") !== ""
|
|
label: "The application list needs attention"
|
|
detail: String(AppLibrary.lastError ?? "")
|
|
value: ""
|
|
divider: false
|
|
}
|
|
|
|
// ── What is installed ────────────────────────────────────────────────────
|
|
|
|
SettingsCard {
|
|
title: "Installed applications"
|
|
subtitle: !AppLibrary.flatpaksLoaded
|
|
? "Reading the applications installed on this machine…"
|
|
: (AppLibrary.apps ?? []).length + " installed · "
|
|
+ AppLibrary.flatpakCount + " through Flatpak"
|
|
|
|
SearchField {
|
|
id: installedSearch
|
|
|
|
width: parent.width
|
|
placeholder: "Search installed applications"
|
|
}
|
|
|
|
Item { width: 1; height: 8 }
|
|
|
|
Repeater {
|
|
model: root.installedApps.slice(0, root.installedLimit)
|
|
|
|
delegate: InstalledAppRow {
|
|
id: installedRow
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
readonly property bool open: root.expandedApp === String(installedRow.modelData.entryId ?? "")
|
|
|
|
width: parent.width
|
|
app: installedRow.modelData
|
|
expanded: installedRow.open
|
|
// A write in flight, not a read: the catalog refresh this page
|
|
// starts on open must not grey out every row for a second.
|
|
busy: AppLibrary.busyEntryId !== "" || DefaultApps.busy
|
|
divider: installedRow.index
|
|
< Math.min(root.installedApps.length, root.installedLimit) - 1
|
|
autostart: root.autostartOn(installedRow.modelData)
|
|
flatsealAvailable: root.flatsealAvailable
|
|
permissionsKnown: installedRow.open && root.expandedPermissions !== null
|
|
permissionSummary: installedRow.open && root.expandedPermissions
|
|
? root.expandedPermissions.summary
|
|
: []
|
|
hasNotificationRule: root.hasNotificationRule(installedRow.modelData)
|
|
hasSoundRule: root.hasSoundRule(installedRow.modelData)
|
|
hasPrivacyRule: root.hasPrivacyRule(installedRow.modelData)
|
|
confirmingUninstall: root.pendingUninstall !== ""
|
|
&& root.pendingUninstall === String(installedRow.modelData.flatpakId ?? "")
|
|
|
|
onActivated: {
|
|
root.pendingUninstall = "";
|
|
root.expandedApp = installedRow.open
|
|
? ""
|
|
: String(installedRow.modelData.entryId ?? "");
|
|
}
|
|
onAutostartToggled: value => root.setAppAutostart(installedRow.modelData, value)
|
|
onJumped: page => ShellState.openSettings(page)
|
|
onFlatsealRequested: Quickshell.execDetached(
|
|
["flatpak", "run", "com.github.tchx84.Flatseal"])
|
|
onUninstallArmed: root.pendingUninstall = String(installedRow.modelData.flatpakId ?? "")
|
|
onUninstallCancelled: root.pendingUninstall = ""
|
|
onUninstallConfirmed: {
|
|
const flatpakId = String(installedRow.modelData.flatpakId ?? "");
|
|
root.pendingUninstall = "";
|
|
root.expandedApp = "";
|
|
AppLibrary.uninstall(flatpakId);
|
|
}
|
|
}
|
|
}
|
|
|
|
TextRow {
|
|
visible: root.installedApps.length > root.installedLimit
|
|
label: (root.installedApps.length - root.installedLimit) + " more"
|
|
detail: "Search finds any of them by name or by application id."
|
|
value: ""
|
|
divider: false
|
|
}
|
|
|
|
TextRow {
|
|
visible: (AppLibrary.apps ?? []).length > 0 && root.installedApps.length === 0
|
|
label: "Nothing matches that"
|
|
detail: "Both the application's name and its id are searched."
|
|
value: ""
|
|
divider: false
|
|
}
|
|
}
|
|
|
|
// ── What could be installed ──────────────────────────────────────────────
|
|
|
|
SettingsCard {
|
|
title: "Browse the catalog"
|
|
subtitle: "Panama's curated applications — the same list `panama apps` offers, installed the same way."
|
|
|
|
Flow {
|
|
width: parent.width
|
|
spacing: 7
|
|
|
|
Repeater {
|
|
model: root.catalogCategories
|
|
|
|
delegate: SettingsChip {
|
|
required property var modelData
|
|
|
|
text: root.categoryLabel(modelData)
|
|
active: root.categoryId(modelData) === root.activeCategory
|
|
onClicked: root.catalogCategory = root.categoryId(modelData)
|
|
}
|
|
}
|
|
}
|
|
|
|
Item { width: 1; height: 10 }
|
|
|
|
Repeater {
|
|
model: root.catalogEntries
|
|
|
|
delegate: SettingRow {
|
|
id: catalogRow
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
readonly property bool installed: catalogRow.modelData.installed === true
|
|
readonly property bool flatpak: String(catalogRow.modelData.kind ?? "") === "flatpak"
|
|
// `id` is the catalog line verbatim, "flatpak:" prefix and all,
|
|
// because that is what install matches. `ref` is the same thing
|
|
// with the prefix taken off, which is what a person reads.
|
|
readonly property string ref: String(catalogRow.modelData.ref ?? catalogRow.modelData.id ?? "")
|
|
|
|
width: parent.width
|
|
label: String(catalogRow.modelData.label ?? "") !== ""
|
|
? String(catalogRow.modelData.label)
|
|
: catalogRow.ref
|
|
detail: catalogRow.flatpak
|
|
? catalogRow.ref + " · Flatpak, from Flathub"
|
|
: catalogRow.ref + " · system package, so installing asks for your password"
|
|
value: catalogRow.installed ? "Installed" : ""
|
|
controlWidth: 110
|
|
divider: catalogRow.index < root.catalogEntries.length - 1
|
|
|
|
SettingsButton {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: !catalogRow.installed
|
|
// Only the row being installed says so, rather than the
|
|
// whole card greying out around one press.
|
|
text: AppLibrary.busyEntryId === String(catalogRow.modelData.id ?? "")
|
|
? "Installing…"
|
|
: "Install"
|
|
tone: "accent"
|
|
enabled: !AppLibrary.busy
|
|
onClicked: AppLibrary.install(root.activeCategory,
|
|
String(catalogRow.modelData.id ?? ""))
|
|
}
|
|
}
|
|
}
|
|
|
|
TextRow {
|
|
visible: root.catalogCategories.length === 0
|
|
label: AppLibrary.catalogLoaded
|
|
? "The catalog is empty"
|
|
: "Reading the catalog…"
|
|
detail: "It is parsed from setup/packages/extras, the same files the command line uses."
|
|
value: ""
|
|
divider: false
|
|
}
|
|
}
|
|
|
|
// ── What opens what ──────────────────────────────────────────────────────
|
|
|
|
SettingsCard {
|
|
title: "Default applications"
|
|
subtitle: "Each role sets its whole family of types together, so one picture never opens somewhere different from the next."
|
|
|
|
Repeater {
|
|
model: root.roles
|
|
|
|
delegate: OptionPickerRow {
|
|
id: roleRow
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
readonly property var choices: root.choicesForRole(roleRow.modelData)
|
|
|
|
width: parent.width
|
|
label: roleRow.modelData.label
|
|
detail: roleRow.modelData.detail
|
|
options: roleRow.choices.map(entry => ({
|
|
value: root.desktopId(entry),
|
|
label: root.displayName(entry),
|
|
detail: String(entry.genericName || entry.comment || root.desktopId(entry))
|
|
}))
|
|
current: root.currentHandler(roleRow.modelData.key)
|
|
enabled: roleRow.choices.length > 0 && !DefaultApps.busy
|
|
value: DefaultApps.busy ? "Loading…" : (
|
|
roleRow.currentOption
|
|
? String(roleRow.currentOption.label)
|
|
: (root.currentHandler(roleRow.modelData.key) || "Not set")
|
|
)
|
|
onPicked: value => DefaultApps.setDefault(roleRow.modelData.key, String(value))
|
|
}
|
|
}
|
|
|
|
// The escape hatch from the paragraph above: one type, on its own.
|
|
ActionRow {
|
|
label: "One file type"
|
|
detail: root.showingFileTypes
|
|
? "Search the type database and give a single type its own application"
|
|
: "Override a single type when a role's family is too broad"
|
|
action: root.showingFileTypes ? "Close" : "Choose"
|
|
divider: root.showingFileTypes
|
|
onTriggered: {
|
|
root.showingFileTypes = !root.showingFileTypes;
|
|
if (!root.showingFileTypes)
|
|
DefaultApps.clearTypeSearch();
|
|
}
|
|
}
|
|
|
|
FileTypePicker {
|
|
visible: root.showingFileTypes
|
|
width: parent.width
|
|
matches: DefaultApps.typeMatches ?? []
|
|
busy: DefaultApps.searchingTypes
|
|
truncated: DefaultApps.typeSearchTruncated
|
|
onQueried: query => DefaultApps.searchTypes(query)
|
|
onChosen: (mime, desktopId) => DefaultApps.setType(mime, desktopId)
|
|
}
|
|
}
|
|
|
|
// ── What starts with the session ─────────────────────────────────────────
|
|
|
|
SettingsCard {
|
|
title: "Autostart"
|
|
subtitle: "Entries live in your user configuration. The switch in an application's own row above writes the same files."
|
|
|
|
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: ""
|
|
}
|
|
|
|
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
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The compositor's own autostart, described rather than offered. These
|
|
// are started from config/dot/hypr/autostart.lua, which is where they
|
|
// are changed -- a switch here would edit a file the compositor reads
|
|
// once at launch and give no sign that nothing had happened.
|
|
TextRow {
|
|
label: "Compositor autostart"
|
|
detail: "Started from the Hyprland configuration, and read-only here."
|
|
value: DefaultApps.luaAutostartEntries.length > 0
|
|
? DefaultApps.luaAutostartEntries.length
|
|
+ (DefaultApps.luaAutostartEntries.length === 1 ? " entry" : " entries")
|
|
: ""
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Searching from the launcher ──────────────────────────────────────────
|
|
|
|
// 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: root.launcherChords.length > 1
|
|
? root.launcherChords.join(", ") + " all open it, because Super+A and Super+R"
|
|
+ " were GNOME's app grid and run dialog"
|
|
: "Rebindable from the keyboard page, like every other launcher chord"
|
|
// The keymap is the source of truth; the literal is what shows
|
|
// while it is still being read.
|
|
value: root.launcherChords.length > 0 ? root.launcherChords[0] : "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: "Refresh"
|
|
|
|
ActionRow {
|
|
label: "Reload application settings"
|
|
detail: "Re-read desktop entries, defaults, user autostart files, and the installed list"
|
|
action: DefaultApps.busy || AppLibrary.busy ? "Refreshing…" : "Refresh"
|
|
enabled: !DefaultApps.busy && !AppLibrary.busy
|
|
divider: false
|
|
onTriggered: {
|
|
DefaultApps.refresh();
|
|
AppLibrary.refresh();
|
|
AppLibrary.refreshCatalog();
|
|
}
|
|
}
|
|
}
|
|
}
|