Add wallpaper, power, date, accessibility, and real search
Continues the settings expansion toward replacing GNOME Settings for everything Panama actually owns. Wallpaper. A thumbnail grid rather than a path field: the value of this setting is the picture, so typing a path to something you cannot see is the worst version of it. Two things about hyprpaper 0.8 shaped this. Its IPC is much smaller than older documentation suggests -- preload, listloaded, unload, and reload all answer "invalid hyprpaper request", so setting is a single call with no preload. And the "<empty>,<path>" form that used to mean every output is silently ignored, so a wallpaper set that way appears to succeed and never changes; outputs are walked explicitly instead. hyprpaper.conf lives in the repo through the ~/.config/hypr symlink and so cannot hold machine state, which is why the choice lives in the shared settings store and is re-applied at startup. Power & Lock. hypridle has no IPC for reconfiguration and its config is hyprlang rather than the shared JSON, so scripts/panama-idle generates a config from the settings store and restarts the daemon. The generated file lives under XDG_STATE_HOME for the same symlink reason, with a systemd drop-in pointing hypridle at it. Management is a real state and the page says which one you are in rather than showing sliders that quietly do nothing. Zero means never for all three timers, which a naive template would render as "immediately". Date & Time. Deliberately not stored in Panama's settings: the timezone and network time belong to the machine and are shared with sessions that never see this file. Storing a copy would create a second answer to a question the system already answers. Reads and writes timedatectl directly; a cancelled polkit prompt surfaces as an error rather than as a value that appears to have been accepted. Accessibility. Pointer size and text scale have to agree across three consumers with no shared configuration -- the compositor, GTK, and the shell -- so the store is the source of truth and the values are pushed outward to gsettings and hyprctl setcursor. Search now indexes the schema instead of the twelve page labels. "gaps", "wallpaper", and "screenshot" previously found nothing on an app that has all three, which is the clearest way a settings app feels smaller than it is. Shortcuts are indexed by what they do. A contract asserts every non-internal schema label is reachable, so a new setting cannot be added in an undiscoverable state. The GNOME delegation allow-list was widened to the panel names gnome-control-center actually reports; the previous list contained "users", which is not one of them and so opened nothing. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
pragma Singleton
|
||||
|
||||
// Pointer size and text scale.
|
||||
//
|
||||
// These are the two settings that must agree across three consumers that do not
|
||||
// share a configuration system: the compositor draws the cursor, GTK
|
||||
// applications read gsettings, and the shell renders its own text. Panama's
|
||||
// store is the source of truth, and this pushes the value out to the other two
|
||||
// so they cannot disagree.
|
||||
//
|
||||
// pointer size -> gsettings (GTK) + `hyprctl setcursor` (compositor)
|
||||
// text scale -> gsettings (GTK)
|
||||
//
|
||||
// The shell's own font size is not scaled here. Theme.qml's sizes are part of
|
||||
// the design rather than a user preference, and scaling them at runtime would
|
||||
// reflow every panel against layouts that were tuned at the design size. Text
|
||||
// scale therefore affects applications, which is where it matters, and the
|
||||
// page says so rather than implying it does more.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string cursorTheme: ""
|
||||
property string lastError: ""
|
||||
|
||||
readonly property bool busy: themeQuery.running || runner.running || root.pending.length > 0
|
||||
|
||||
readonly property int cursorSize: DesktopPreferences.get("cursorSize")
|
||||
readonly property real textScale: DesktopPreferences.get("textScale")
|
||||
|
||||
Process {
|
||||
id: themeQuery
|
||||
command: ["gsettings", "get", "org.gnome.desktop.interface", "cursor-theme"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
// gsettings quotes strings: 'oreo_blue_cursors'
|
||||
root.cursorTheme = this.text.trim().replace(/^'|'$/g, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A short queue, because applying one setting takes several commands and
|
||||
// Process runs one at a time.
|
||||
property var pending: []
|
||||
|
||||
Process {
|
||||
id: runner
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0)
|
||||
root.lastError = "That accessibility setting could not be applied.";
|
||||
root.drain();
|
||||
}
|
||||
}
|
||||
|
||||
function drain(): void {
|
||||
if (runner.running || root.pending.length === 0)
|
||||
return;
|
||||
const next = root.pending[0];
|
||||
root.pending = root.pending.slice(1);
|
||||
runner.exec(next);
|
||||
}
|
||||
|
||||
function enqueue(commands: var): void {
|
||||
root.pending = root.pending.concat(commands);
|
||||
root.drain();
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
themeQuery.running = true;
|
||||
settle.restart();
|
||||
}
|
||||
|
||||
// Push the stored values outward once at startup, so a value changed in a
|
||||
// previous session is in effect in this one even though gsettings and the
|
||||
// compositor do not read Panama's store.
|
||||
Timer {
|
||||
id: settle
|
||||
interval: 1200
|
||||
onTriggered: root.applyAll()
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: DesktopPreferences
|
||||
function onRevisionChanged(): void { coalesce.restart(); }
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: coalesce
|
||||
interval: 250
|
||||
onTriggered: root.applyAll()
|
||||
}
|
||||
|
||||
function applyAll(): void {
|
||||
root.lastError = "";
|
||||
const size = String(root.cursorSize);
|
||||
const commands = [
|
||||
["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size],
|
||||
["gsettings", "set", "org.gnome.desktop.interface", "text-scaling-factor", String(root.textScale)]
|
||||
];
|
||||
// setcursor needs a theme name; skip it rather than guess if gsettings
|
||||
// has not answered yet. The next change will catch up.
|
||||
if (root.cursorTheme !== "")
|
||||
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
|
||||
root.enqueue(commands);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
pragma Singleton
|
||||
|
||||
// System date, time, and timezone.
|
||||
//
|
||||
// Deliberately NOT backed by the Panama settings store. The timezone and the
|
||||
// network-time setting belong to the machine, not to this desktop: they are
|
||||
// shared with every other session and with services that never see Panama's
|
||||
// JSON. Storing a copy would create a second answer to a question the system
|
||||
// already answers, which is the exact failure this settings rewrite exists to
|
||||
// remove. So this reads and writes `timedatectl` directly and holds no state of
|
||||
// its own beyond what it last observed.
|
||||
//
|
||||
// Setting the timezone or toggling NTP needs privilege. timedatectl asks
|
||||
// polkit, which shows the usual authentication dialog; on refusal the command
|
||||
// fails and the error is surfaced rather than the UI pretending it worked.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string timezone: ""
|
||||
property bool ntpEnabled: false
|
||||
property bool ntpSynchronised: false
|
||||
property string localTime: ""
|
||||
property string universalTime: ""
|
||||
property string rtcTime: ""
|
||||
property string lastError: ""
|
||||
|
||||
property var zones: []
|
||||
|
||||
readonly property bool busy: statusQuery.running || zonesQuery.running || writeRun.running
|
||||
|
||||
// "America/New_York" -> "New York" for display, keeping the region as a
|
||||
// separate field so the list can be grouped and searched sensibly.
|
||||
function regionOf(zone: string): string {
|
||||
const slash = zone.indexOf("/");
|
||||
return slash < 0 ? zone : zone.slice(0, slash);
|
||||
}
|
||||
|
||||
function cityOf(zone: string): string {
|
||||
const slash = zone.indexOf("/");
|
||||
return (slash < 0 ? zone : zone.slice(slash + 1)).replace(/_/g, " ");
|
||||
}
|
||||
|
||||
Process {
|
||||
id: statusQuery
|
||||
command: ["timedatectl", "show",
|
||||
"-p", "Timezone", "-p", "NTP", "-p", "NTPSynchronized",
|
||||
"-p", "TimeUSec", "-p", "RTCTimeUSec"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.parseStatus(this.text)
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0)
|
||||
root.lastError = "Could not read the system clock settings.";
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: zonesQuery
|
||||
command: ["timedatectl", "list-timezones"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.zones = this.text.split("\n")
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: writeRun
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
// A polkit refusal and a bad value both land here. Neither should
|
||||
// leave the UI showing a value the system did not take, so the
|
||||
// status is re-read either way.
|
||||
root.lastError = exitCode === 0
|
||||
? ""
|
||||
: "The system rejected that change, or authentication was cancelled.";
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
root.refresh();
|
||||
zonesQuery.running = true;
|
||||
}
|
||||
|
||||
function parseStatus(text: string): void {
|
||||
for (const line of text.split("\n")) {
|
||||
const split = line.indexOf("=");
|
||||
if (split < 0)
|
||||
continue;
|
||||
const key = line.slice(0, split);
|
||||
const value = line.slice(split + 1);
|
||||
if (key === "Timezone")
|
||||
root.timezone = value;
|
||||
else if (key === "NTP")
|
||||
root.ntpEnabled = value === "yes";
|
||||
else if (key === "NTPSynchronized")
|
||||
root.ntpSynchronised = value === "yes";
|
||||
}
|
||||
root.lastError = "";
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (!statusQuery.running)
|
||||
statusQuery.running = true;
|
||||
}
|
||||
|
||||
// Only a timezone the system itself listed is ever passed on, so no
|
||||
// caller-supplied text reaches the command.
|
||||
function setTimezone(zone: string): bool {
|
||||
if (root.zones.indexOf(zone) < 0) {
|
||||
root.lastError = "That is not a timezone this system recognises.";
|
||||
return false;
|
||||
}
|
||||
if (writeRun.running)
|
||||
return false;
|
||||
writeRun.exec(["timedatectl", "set-timezone", zone]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function setNtp(enabled: bool): bool {
|
||||
if (writeRun.running)
|
||||
return false;
|
||||
writeRun.exec(["timedatectl", "set-ntp", enabled ? "true" : "false"]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
pragma Singleton
|
||||
|
||||
// Idle, lock, and sleep timings.
|
||||
//
|
||||
// hypridle has no IPC for reconfiguration, and its config is hyprlang rather
|
||||
// than the shared JSON, so this cannot work the way the compositor settings do.
|
||||
// Instead scripts/panama-idle regenerates a config from the settings store and
|
||||
// restarts the daemon.
|
||||
//
|
||||
// The generated file lives under XDG_STATE_HOME rather than ~/.config/hypr,
|
||||
// because that directory is a symlink into the Panama repository -- writing
|
||||
// there at runtime would put machine state into a tracked file. A systemd
|
||||
// drop-in points hypridle at the generated path with `-c`.
|
||||
//
|
||||
// "Managed" is therefore a real state with two sides: when the drop-in is
|
||||
// installed the timings below are in effect, and when it is not, hypridle is
|
||||
// running the repository's shipped hypridle.conf and these values are only a
|
||||
// stored intention. The Power page says which it is rather than showing
|
||||
// controls that quietly do nothing.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-idle"
|
||||
|
||||
property bool managed: false
|
||||
property string serviceState: "unknown"
|
||||
property string generatedPath: ""
|
||||
property string lastError: ""
|
||||
|
||||
readonly property bool busy: statusQuery.running || applyRun.running
|
||||
|
||||
// The values as stored. They only describe what is running when `managed`.
|
||||
readonly property int blankMinutes: DesktopPreferences.get("screenBlankMinutes")
|
||||
readonly property int lockMinutes: DesktopPreferences.get("lockMinutes")
|
||||
readonly property int suspendMinutes: DesktopPreferences.get("suspendMinutes")
|
||||
readonly property bool lockOnSleep: DesktopPreferences.get("lockOnSleep")
|
||||
|
||||
// Blanking after locking is legal but pointless, and blanking with lock off
|
||||
// is fine. Surfacing the one genuinely confusing combination beats silently
|
||||
// reordering the user's numbers.
|
||||
readonly property bool lockBeforeBlank: root.lockMinutes > 0
|
||||
&& root.blankMinutes > 0
|
||||
&& root.lockMinutes < root.blankMinutes
|
||||
|
||||
Process {
|
||||
id: statusQuery
|
||||
command: [root.helperPath, "status"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const state = JSON.parse(this.text);
|
||||
root.managed = state.managed === true;
|
||||
root.serviceState = String(state.active ?? "unknown");
|
||||
root.generatedPath = String(state.generated ?? "");
|
||||
root.lastError = "";
|
||||
} catch (error) {
|
||||
root.lastError = "Could not read the idle configuration.";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: applyRun
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.lastError = exitCode === 0 ? "" : "Could not update the idle configuration.";
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
|
||||
function refresh(): void {
|
||||
if (!statusQuery.running)
|
||||
statusQuery.running = true;
|
||||
}
|
||||
|
||||
// Regenerates the config from the current settings and restarts hypridle if
|
||||
// Panama is managing it. Safe to call when it is not: the file is written
|
||||
// and nothing is restarted.
|
||||
function apply(): void {
|
||||
if (applyRun.running)
|
||||
return;
|
||||
applyRun.exec([root.helperPath, "apply"]);
|
||||
}
|
||||
|
||||
function setManaged(enabled: bool): void {
|
||||
if (applyRun.running)
|
||||
return;
|
||||
applyRun.exec([root.helperPath, enabled ? "install" : "remove"]);
|
||||
}
|
||||
|
||||
// Regenerate whenever one of the four inputs changes. Coalesced, because a
|
||||
// slider drag settles through several commits and each one would otherwise
|
||||
// restart the daemon.
|
||||
Connections {
|
||||
target: DesktopPreferences
|
||||
function onRevisionChanged(): void {
|
||||
if (root.managed)
|
||||
regenerate.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: regenerate
|
||||
interval: 400
|
||||
onTriggered: root.apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
pragma Singleton
|
||||
|
||||
// Search across every setting, not just page names.
|
||||
//
|
||||
// The sidebar's field used to filter the twelve page labels, so "gaps",
|
||||
// "wallpaper", and "repeat delay" all found nothing — which is precisely the
|
||||
// thing that makes a settings app feel smaller than it is. This indexes the
|
||||
// schema itself, so any setting is reachable by typing what it does, and a new
|
||||
// schema entry becomes searchable with no change here.
|
||||
//
|
||||
// Shortcuts are indexed too: "screenshot" should find the key that takes one.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Which page shows the settings in a given schema group. A group with no
|
||||
// entry here still appears in results and routes to Home rather than being
|
||||
// dropped, so adding a group can never make a setting unreachable.
|
||||
readonly property var groupPages: ({
|
||||
"clock": "appearance",
|
||||
"vitals": "appearance",
|
||||
"windows": "appearance",
|
||||
"effects": "appearance",
|
||||
"wallpaper": "appearance",
|
||||
"dock": "desktop",
|
||||
"focus": "desktop",
|
||||
"display": "displays",
|
||||
"idle": "power",
|
||||
"accessibility": "accessibility",
|
||||
"input": "shortcuts",
|
||||
"weather": "appearance",
|
||||
"notifications": "notifications",
|
||||
"capture": "screen-intelligence"
|
||||
})
|
||||
|
||||
// Settings that are real but have no schema entry, because the system owns
|
||||
// them rather than Panama. Without these, searching "timezone" would fail
|
||||
// on a settings app that plainly has one.
|
||||
readonly property var extraEntries: [
|
||||
{ label: "Timezone", detail: "Set the system timezone", page: "datetime" },
|
||||
{ label: "Network time", detail: "Synchronise the clock with a time server", page: "datetime" },
|
||||
{ label: "Wi-Fi", detail: "Managed by GNOME Settings", page: "connectivity" },
|
||||
{ label: "Bluetooth", detail: "Managed by GNOME Settings", page: "connectivity" },
|
||||
{ label: "Printers", detail: "Managed by GNOME Settings", page: "connectivity" },
|
||||
{ label: "Default applications", detail: "Browser, mail, files", page: "services" },
|
||||
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" },
|
||||
{ label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" }
|
||||
]
|
||||
|
||||
function pageFor(group: string): string {
|
||||
return root.groupPages[group] ?? "home";
|
||||
}
|
||||
|
||||
// [{ label, detail, page, kind }] for a query. Empty query yields nothing:
|
||||
// the sidebar shows its normal navigation in that case.
|
||||
function search(query: string): var {
|
||||
const needle = String(query).trim().toLowerCase();
|
||||
if (needle === "")
|
||||
return [];
|
||||
|
||||
const results = [];
|
||||
const seen = {};
|
||||
|
||||
function add(label, detail, page, kind) {
|
||||
const dedupe = `${kind}:${label}:${page}`;
|
||||
if (seen[dedupe])
|
||||
return;
|
||||
seen[dedupe] = true;
|
||||
results.push({ label: label, detail: detail, page: page, kind: kind });
|
||||
}
|
||||
|
||||
for (const entry of PreferenceSchema.entries) {
|
||||
if (entry.internal)
|
||||
continue;
|
||||
const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group}`.toLowerCase();
|
||||
if (haystack.indexOf(needle) >= 0)
|
||||
add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting");
|
||||
}
|
||||
|
||||
for (const entry of root.extraEntries) {
|
||||
if (`${entry.label} ${entry.detail}`.toLowerCase().indexOf(needle) >= 0)
|
||||
add(entry.label, entry.detail, entry.page, "setting");
|
||||
}
|
||||
|
||||
for (const bind of Keybinds.binds) {
|
||||
if (bind.description.toLowerCase().indexOf(needle) >= 0)
|
||||
add(bind.description, bind.chord, "shortcuts", "shortcut");
|
||||
}
|
||||
|
||||
// Exact prefix matches first: typing "blur" should put "Blur" above
|
||||
// "Blur radius", and both above a setting that merely mentions blur in
|
||||
// its explanation.
|
||||
return results.sort((a, b) => {
|
||||
const al = a.label.toLowerCase();
|
||||
const bl = b.label.toLowerCase();
|
||||
const ap = al === needle ? 0 : (al.indexOf(needle) === 0 ? 1 : 2);
|
||||
const bp = bl === needle ? 0 : (bl.indexOf(needle) === 0 ? 1 : 2);
|
||||
return ap !== bp ? ap - bp : al.localeCompare(bl);
|
||||
}).slice(0, 40);
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ Singleton {
|
||||
}
|
||||
|
||||
function openSettings(page: string): void {
|
||||
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "services", "about"];
|
||||
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "accessibility", "power", "datetime", "services", "about"];
|
||||
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
||||
DesktopPreferences.set("lastPage", root.settingsPage);
|
||||
root.settingsOpen = true;
|
||||
|
||||
@@ -435,9 +435,15 @@ Singleton {
|
||||
}
|
||||
|
||||
function isGnomePanelAllowed(panel: string): bool {
|
||||
// Verified against `gnome-control-center --list` on this system. A name
|
||||
// that panel list does not contain opens nothing and reports an error,
|
||||
// so guessing one here would be a silently dead button.
|
||||
return [
|
||||
"wifi", "network", "bluetooth", "sound", "power", "printers",
|
||||
"online-accounts", "users", "mouse", "keyboard", "sharing"
|
||||
"applications", "background", "bluetooth", "color", "display",
|
||||
"keyboard", "mouse", "multitasking", "network", "notifications",
|
||||
"online-accounts", "power", "printers", "privacy", "search",
|
||||
"sharing", "sound", "system", "universal-access", "wacom",
|
||||
"wellbeing", "wifi", "wwan"
|
||||
].indexOf(panel) >= 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
pragma Singleton
|
||||
|
||||
// The desktop background.
|
||||
//
|
||||
// hyprpaper owns the actual painting; this owns choosing. Two things are worth
|
||||
// knowing about hyprpaper 0.8:
|
||||
//
|
||||
// * Its IPC is much smaller than the documentation for older versions
|
||||
// suggests. `wallpaper <output>,<path>` and `listactive` work; `preload`,
|
||||
// `listloaded`, `unload`, and `reload` all answer "invalid hyprpaper
|
||||
// request". So there is no preload step -- setting is a single call.
|
||||
// * hyprpaper.conf lives in the Panama repo via the ~/.config/hypr symlink,
|
||||
// so it cannot be rewritten at runtime without dirtying a tracked file.
|
||||
// The chosen wallpaper therefore lives in the shared settings store like
|
||||
// every other preference, and is re-applied when the shell starts.
|
||||
//
|
||||
// The argument is "<output>,<path>", so a path containing a comma would be
|
||||
// parsed as a different request. The schema's pattern rejects those, and the
|
||||
// value is passed as a single argv element rather than through a shell.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Absolute paths of candidate images, newest first.
|
||||
property var available: []
|
||||
property string active: ""
|
||||
property string lastError: ""
|
||||
property bool scanning: false
|
||||
|
||||
readonly property string configured: DesktopPreferences.get("wallpaperPath")
|
||||
|
||||
// Directories searched for wallpapers, in order. Screenshots are
|
||||
// deliberately excluded: a folder of 300 screenshots is not a wallpaper
|
||||
// picker, and including it made the grid useless on this machine.
|
||||
readonly property var searchRoots: [
|
||||
`${Quickshell.env("HOME")}/Pictures/Wallpapers`,
|
||||
`${Quickshell.env("HOME")}/Pictures/Backgrounds`,
|
||||
`${Quickshell.env("HOME")}/.local/share/backgrounds`,
|
||||
"/usr/share/backgrounds"
|
||||
]
|
||||
|
||||
Process {
|
||||
id: scan
|
||||
|
||||
// -print0 would be safer against odd filenames, but the schema already
|
||||
// rejects paths containing commas or newlines, and this list is only
|
||||
// ever offered as candidates -- the value that gets stored is validated
|
||||
// again on the way in.
|
||||
command: ["bash", "-lc",
|
||||
"find " + root.searchRoots.map(dir => `'${dir}'`).join(" ")
|
||||
+ " -maxdepth 2 -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\)"
|
||||
+ " -printf '%T@ %p\\n' 2>/dev/null | sort -rn | cut -d' ' -f2- | head -60"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const paths = this.text.split("\n").map(line => line.trim()).filter(line => line.length > 0);
|
||||
root.available = paths;
|
||||
root.scanning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: activeQuery
|
||||
command: ["hyprctl", "hyprpaper", "listactive"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
// "DP-2: /path/to/image.jpg", one line per output.
|
||||
const first = this.text.split("\n").find(line => line.indexOf(":") > 0);
|
||||
root.active = first ? first.slice(first.indexOf(":") + 1).trim() : "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hyprpaper requires an explicit output name: the "<empty>,<path>" form that
|
||||
// older versions accepted as "all outputs" is silently ignored by 0.8, so a
|
||||
// wallpaper set that way appears to succeed and never changes. Outputs are
|
||||
// therefore walked one at a time.
|
||||
Process {
|
||||
id: apply
|
||||
|
||||
property string requested: ""
|
||||
property var remaining: []
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0) {
|
||||
root.lastError = "hyprpaper could not load that image.";
|
||||
apply.remaining = [];
|
||||
return;
|
||||
}
|
||||
if (apply.remaining.length > 0) {
|
||||
const next = apply.remaining[0];
|
||||
apply.remaining = apply.remaining.slice(1);
|
||||
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${next},${apply.requested}`]);
|
||||
return;
|
||||
}
|
||||
root.lastError = "";
|
||||
DesktopPreferences.set("wallpaperPath", apply.requested);
|
||||
root.refreshActive();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
root.rescan();
|
||||
root.refreshActive();
|
||||
restore.restart();
|
||||
}
|
||||
|
||||
// hyprpaper is started by the compositor's autostart, so it may not be
|
||||
// listening yet when the shell comes up. Re-applying the stored choice
|
||||
// after a short delay makes the wallpaper survive a reboot without needing
|
||||
// hyprpaper.conf to know about it.
|
||||
Timer {
|
||||
id: restore
|
||||
interval: 1500
|
||||
onTriggered: {
|
||||
const stored = root.configured;
|
||||
if (stored !== "" && stored !== root.active)
|
||||
root.set(stored);
|
||||
}
|
||||
}
|
||||
|
||||
function rescan(): void {
|
||||
if (scan.running)
|
||||
return;
|
||||
root.scanning = true;
|
||||
scan.running = true;
|
||||
}
|
||||
|
||||
function refreshActive(): void {
|
||||
if (!activeQuery.running)
|
||||
activeQuery.running = true;
|
||||
}
|
||||
|
||||
// Applies to every connected output. Returns false when the path is not one
|
||||
// the schema will accept, so a caller can report the refusal.
|
||||
function set(path: string): bool {
|
||||
if (PreferenceSchema.coerce("wallpaperPath", path) === undefined) {
|
||||
root.lastError = "That file path cannot be used as a wallpaper.";
|
||||
return false;
|
||||
}
|
||||
if (apply.running)
|
||||
return false;
|
||||
|
||||
apply.requested = path;
|
||||
// "" clears the preference without touching what is on screen.
|
||||
if (path === "") {
|
||||
DesktopPreferences.set("wallpaperPath", "");
|
||||
return true;
|
||||
}
|
||||
|
||||
const outputs = Quickshell.screens.map(screen => screen.name).filter(name => !!name);
|
||||
if (outputs.length === 0) {
|
||||
root.lastError = "No display to set a wallpaper on.";
|
||||
return false;
|
||||
}
|
||||
|
||||
apply.remaining = outputs.slice(1);
|
||||
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${outputs[0]},${path}`]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The display name for a path: the file's own name, without extension,
|
||||
// with separators turned into spaces.
|
||||
function titleFor(path: string): string {
|
||||
const file = String(path).split("/").pop();
|
||||
return file.replace(/\.[^.]+$/, "").replace(/[_-]+/g, " ");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user