diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 1b4ae14..473ea84 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -322,6 +322,62 @@ Singleton { detail: "Lower is warmer" }, + // ── Desktop background ────────────────────────────────────────────── + // Applied through hyprpaper's IPC. Not an hl.config option, so it has + // no `hypr` block; services/Wallpaper.qml owns applying it. + { + key: "wallpaperPath", type: "string", def: "", group: "wallpaper", + // Reaches hyprpaper as the "," argument form, so a + // comma would split it into a different request. Absolute paths + // only, no commas, no newlines. + pattern: "^(|/[^,\\n]+)$", + label: "Wallpaper", + detail: "Shown on every output" + }, + + // ── Idle, lock, and sleep ─────────────────────────────────────────── + // Written into a generated hypridle config; see scripts/panama-idle. + // Zero means never for all three. + { + key: "screenBlankMinutes", type: "int", def: 5, min: 0, max: 120, step: 1, + unit: "min", group: "idle", + label: "Turn the screen off after", + detail: "Blanks the display; nothing is locked yet" + }, + { + key: "lockMinutes", type: "int", def: 10, min: 0, max: 240, step: 1, + unit: "min", group: "idle", + label: "Lock the screen after", + detail: "Counted from when the session went idle, not from blanking" + }, + { + key: "suspendMinutes", type: "int", def: 0, min: 0, max: 480, step: 5, + unit: "min", group: "idle", + label: "Suspend after", + detail: "This is a desktop, so Panama ships with automatic suspend off" + }, + { + key: "lockOnSleep", type: "bool", def: true, group: "idle", + label: "Lock before sleeping", + detail: "Requires your password when the machine wakes" + }, + + // ── Accessibility ─────────────────────────────────────────────────── + // Backed by gsettings so GTK applications agree with the shell, and + // pushed to the compositor as well where it has its own notion. + { + key: "cursorSize", type: "int", def: 24, min: 16, max: 64, step: 4, + unit: "px", group: "accessibility", + label: "Pointer size", + detail: "Applies to the compositor and to applications" + }, + { + key: "textScale", type: "real", def: 1.0, min: 0.75, max: 2.0, step: 0.05, + group: "accessibility", + label: "Text size", + detail: "Scales interface text everywhere; 1.00 is the design size" + }, + // ── Internal ──────────────────────────────────────────────────────── { key: "lastPage", type: "string", def: "home", group: "internal", diff --git a/config/dot/quickshell/modules/settings/AccessibilityPage.qml b/config/dot/quickshell/modules/settings/AccessibilityPage.qml new file mode 100644 index 0000000..62665c2 --- /dev/null +++ b/config/dot/quickshell/modules/settings/AccessibilityPage.qml @@ -0,0 +1,65 @@ +// Accessibility. +// +// Pointer size and text scale have to agree across three consumers that share +// no configuration system -- the compositor, GTK applications, and the shell. +// Panama's store is the source of truth and services/Accessibility.qml pushes +// the value to the other two. + +import QtQuick +import qs.config +import qs.services + +SettingsPage { + id: root + + title: "Accessibility" + lede: "Make the desktop easier to see and easier to hit." + + SettingsCard { + title: "Pointer" + subtitle: "Applied to the compositor and to applications at the same time." + + SliderRow { setting: "cursorSize" } + SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false } + } + + SettingsCard { + title: "Text" + subtitle: "Scales text in applications. Panama's own panels are drawn at their design size, so the shell is unaffected." + + SliderRow { setting: "textScale"; divider: false } + } + + SettingsCard { + title: "Motion" + subtitle: "Panama never animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing." + + ToggleRow { setting: "animationsEnabled"; divider: false } + } + + SettingsCard { + title: "Contrast" + subtitle: "Unfocused windows can be faded to make the focused one obvious, or left at full strength if that is harder to read." + + SliderRow { setting: "inactiveOpacity"; divider: false } + } + + SettingsCard { + title: "System accessibility" + subtitle: "Screen reader, zoom, and on-screen keyboard are provided by GNOME's accessibility stack." + + ActionRow { + label: "GNOME accessibility settings" + detail: "Opens in GNOME Settings" + action: "Open" + divider: false + onTriggered: SystemSettings.openGnomePanel("universal-access") + } + } + + SettingsCard { + visible: Accessibility.lastError !== "" + title: "Could not apply" + subtitle: Accessibility.lastError + } +} diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml index 475f1c1..1488f2f 100644 --- a/config/dot/quickshell/modules/settings/AppearancePage.qml +++ b/config/dot/quickshell/modules/settings/AppearancePage.qml @@ -42,6 +42,28 @@ SettingsPage { } } + SettingsCard { + title: "Background" + subtitle: Wallpaper.lastError !== "" + ? Wallpaper.lastError + : "Applied to every display. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds." + + WallpaperPicker { + width: parent.width + } + + ActionRow { + label: "Look for new images" + detail: Wallpaper.scanning + ? "Scanning…" + : Wallpaper.available.length + " image" + (Wallpaper.available.length === 1 ? "" : "s") + " found" + action: "Rescan" + divider: false + enabled: !Wallpaper.scanning + onTriggered: Wallpaper.rescan() + } + } + SettingsCard { title: "Windows" subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved." diff --git a/config/dot/quickshell/modules/settings/DateTimePage.qml b/config/dot/quickshell/modules/settings/DateTimePage.qml new file mode 100644 index 0000000..17906ee --- /dev/null +++ b/config/dot/quickshell/modules/settings/DateTimePage.qml @@ -0,0 +1,116 @@ +// Date & Time. +// +// These belong to the machine rather than to Panama, so nothing here is stored +// in Panama's settings file -- it would be a second answer to a question the +// system already answers. Timezone and network time are read from and written +// to timedatectl directly; the clock's presentation lives on Appearance, +// because that genuinely is a Panama preference. +// +// Changing the timezone or network time needs privilege. timedatectl asks +// polkit, and a cancelled dialog surfaces as an error rather than as a value +// that appears to have been accepted. + +import QtQuick +import Quickshell +import qs.config +import qs.services +import qs.modules.clipboard + +SettingsPage { + id: root + + title: "Date & Time" + lede: "Timezone and network time, shared with the whole machine." + + SettingsCard { + title: "Clock" + + TextRow { + label: "Current time" + detail: DateTime.timezone === "" ? "Reading the system clock" : DateTime.timezone + value: Qt.formatDateTime(clock.date, Settings.use24Hour ? "ddd d MMM HH:mm" : "ddd d MMM h:mm AP") + } + SettingRow { + label: "Set automatically" + detail: DateTime.ntpEnabled + ? (DateTime.ntpSynchronised ? "Synchronised with a time server" : "Waiting to synchronise") + : "The clock is set by hand" + controlWidth: 48 + divider: false + + SettingsToggle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: DateTime.ntpEnabled + enabled: !DateTime.busy + onToggled: value => DateTime.setNtp(value) + } + } + } + + SettingsCard { + title: "Timezone" + subtitle: "Currently " + (DateTime.timezone === "" ? "unknown" : DateTime.timezone) + + ". Type to narrow the list." + + SearchField { + id: zoneSearch + width: parent.width + placeholder: "Search timezones" + } + + Repeater { + model: root.matchingZones + + TextRow { + required property var modelData + required property int index + + label: DateTime.cityOf(modelData) + detail: DateTime.regionOf(modelData) + value: modelData === DateTime.timezone ? "Current" : "" + controlWidth: 90 + divider: index < root.matchingZones.length - 1 + activatable: true + onActivated: DateTime.setTimezone(modelData) + } + } + + TextRow { + visible: root.matchingZones.length === 0 + label: "No timezone matches that" + detail: "Try a city or a region, such as \"Denver\" or \"Europe\"" + divider: false + } + } + + SettingsCard { + visible: DateTime.lastError !== "" + title: "The system did not accept that" + subtitle: DateTime.lastError + } + + // Bounded so a blank search does not try to lay out six hundred rows. The + // current zone is always included, so the card never looks empty when the + // field is untouched. + readonly property var matchingZones: { + const needle = zoneSearch.text.trim().toLowerCase(); + const all = DateTime.zones; + if (needle === "") { + // The zone in effect goes first. Listing a region alphabetically + // means the current setting is usually off the bottom of the card, + // which makes the list look like it does not know what is set. + const current = DateTime.timezone; + const nearby = all + .filter(zone => zone !== current && DateTime.regionOf(zone) === DateTime.regionOf(current)) + .slice(0, 11); + return current === "" ? nearby : [current].concat(nearby); + } + return all.filter(zone => zone.toLowerCase().replace(/_/g, " ").indexOf(needle) >= 0).slice(0, 40); + } + + SystemClock { + id: clock + precision: SystemClock.Minutes + } +} diff --git a/config/dot/quickshell/modules/settings/PowerPage.qml b/config/dot/quickshell/modules/settings/PowerPage.qml new file mode 100644 index 0000000..a4727b1 --- /dev/null +++ b/config/dot/quickshell/modules/settings/PowerPage.qml @@ -0,0 +1,83 @@ +// Power & Lock. +// +// hypridle has no IPC for reconfiguration, so these values reach it by +// regenerating its config and restarting the daemon (services/IdleLock.qml). +// That only happens when Panama manages the daemon, and the card below says +// plainly which state you are in rather than presenting sliders that silently +// do nothing. + +import QtQuick +import Quickshell +import qs.config +import qs.services + +SettingsPage { + id: root + + title: "Power & Lock" + lede: "When the screen turns off, when the session locks, and whether it ever sleeps." + + SettingsCard { + title: "Idle behaviour" + subtitle: IdleLock.managed + ? "Panama is managing hypridle. Changes take effect immediately." + : "hypridle is running Panama's shipped configuration. Turn on management below to make these adjustable." + + SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" } + SliderRow { setting: "lockMinutes"; zeroLabel: "Never" } + SliderRow { setting: "suspendMinutes"; zeroLabel: "Never" } + ToggleRow { setting: "lockOnSleep"; divider: false } + } + + // Only shown when the numbers are actually contradictory, rather than as a + // permanent warning nobody reads. + SettingsCard { + visible: IdleLock.lockBeforeBlank + title: "Lock happens before the screen turns off" + subtitle: "The session will lock at " + IdleLock.lockMinutes + + " minutes and the display will not blank until " + IdleLock.blankMinutes + + ". That works, but the screen stays lit on the lock screen for the difference." + } + + SettingsCard { + title: "Management" + subtitle: "Panama generates hypridle's configuration into your state directory and points the service at it with a systemd drop-in. ~/.config/hypr is a symlink into the Panama repository, so the shipped configuration cannot be rewritten in place." + + SettingRow { + label: "Let Panama manage idle timings" + detail: IdleLock.serviceState === "active" + ? "hypridle is running" + : "hypridle is " + IdleLock.serviceState + controlWidth: 48 + + SettingsToggle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: IdleLock.managed + enabled: !IdleLock.busy + onToggled: value => IdleLock.setManaged(value) + } + } + + ActionRow { + label: "Lock the screen now" + detail: "Same as the Super+L shortcut" + action: "Lock" + divider: false + onTriggered: Quickshell.execDetached(["loginctl", "lock-session"]) + } + } + + SettingsCard { + visible: IdleLock.lastError !== "" + title: "Idle configuration problem" + subtitle: IdleLock.lastError + + ActionRow { + label: "Read the idle configuration again" + action: "Retry" + divider: false + onTriggered: IdleLock.refresh() + } + } +} diff --git a/config/dot/quickshell/modules/settings/SettingRow.qml b/config/dot/quickshell/modules/settings/SettingRow.qml index b5d4222..4e072c6 100644 --- a/config/dot/quickshell/modules/settings/SettingRow.qml +++ b/config/dot/quickshell/modules/settings/SettingRow.qml @@ -12,6 +12,12 @@ Item { property bool divider: true property int controlWidth: 150 + // Rows are inert by default. A row that represents a choice -- picking a + // timezone from a list, say -- opts into being clickable across its whole + // width, which is a much larger target than the trailing control alone. + property bool activatable: false + signal activated + width: parent ? parent.width : 620 implicitHeight: Math.max(56, copy.implicitHeight + 20) @@ -85,4 +91,27 @@ Item { visible: root.divider color: Theme.alpha(Theme.fg, 0.065) } + + // Declared in this component's own body, so it is a child of the row rather + // than of the trailing slot that `trailingData` routes external children to. + Rectangle { + anchors.fill: parent + anchors.bottomMargin: 1 + radius: 8 + z: -1 + visible: root.activatable && rowHover.hovered + color: Theme.alpha(Theme.fg, 0.05) + border.width: 0 + } + + HoverHandler { + id: rowHover + enabled: root.activatable + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + enabled: root.activatable + onTapped: root.activated() + } } diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index 191d28b..81e54a6 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -99,6 +99,9 @@ Rectangle { case "notifications": return notificationsPage; case "screen-intelligence": return screenIntelligencePage; case "shortcuts": return shortcutsPage; + case "accessibility": return accessibilityPage; + case "power": return powerPage; + case "datetime": return dateTimePage; case "services": return servicesPage; case "about": return aboutPage; default: return homePage; @@ -136,6 +139,9 @@ Rectangle { } Component { id: homePage; HomePage {} } + Component { id: accessibilityPage; AccessibilityPage {} } + Component { id: powerPage; PowerPage {} } + Component { id: dateTimePage; DateTimePage {} } Component { id: appearancePage; AppearancePage {} } Component { id: displaysPage; DisplaysPage {} } Component { id: connectivityPage; ConnectivityPage {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index 6d3dc6e..0f1d099 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -1,5 +1,6 @@ import QtQuick import qs.config +import qs.services Rectangle { id: root @@ -8,6 +9,13 @@ Rectangle { property string query: searchInput.text.trim().toLowerCase() signal pageRequested(string page) + readonly property var results: SettingsSearch.search(root.query) + + function pageLabel(page: string): string { + const found = root.destinations.find(item => item.page === page); + return found ? found.label : "Settings"; + } + readonly property var destinations: [ { page: "home", label: "Home", icon: "\u{F02DC}" }, { page: "appearance", label: "Appearance", icon: "\u{F0E0D}" }, @@ -19,6 +27,9 @@ Rectangle { { page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" }, { page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" }, { page: "shortcuts", label: "Input & Shortcuts", icon: "\u{F030C}" }, + { page: "accessibility", label: "Accessibility", icon: "\u{F0208}" }, + { page: "power", label: "Power & Lock", icon: "\u{F0425}" }, + { page: "datetime", label: "Date & Time", icon: "\u{F0954}" }, { page: "services", label: "Startup & Services", icon: "\u{F0493}" }, { page: "about", label: "About Panama", icon: "\u{F02FD}" } ] @@ -92,12 +103,91 @@ Rectangle { } } + // ── Search results ────────────────────────────────────────────────── + // Typing searches the settings themselves, not the twelve page names. + // "gaps", "wallpaper", and "screenshot" all used to find nothing, which + // made the app feel far smaller than it is. + Column { + width: parent.width + spacing: 3 + visible: root.query !== "" + + Text { + width: parent.width + leftPadding: 4 + bottomPadding: 4 + text: root.results.length === 0 + ? "Nothing matches" + : root.results.length + (root.results.length === 1 ? " result" : " results") + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + Repeater { + model: root.results + + Rectangle { + id: hit + + required property var modelData + + width: parent.width + height: 44 + radius: 10 + color: hitMouse.containsMouse ? Theme.alpha(Theme.fg, 0.08) : "transparent" + border.width: 0 + + Column { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 12 + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + Text { + width: parent.width + text: hit.modelData.label + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + elide: Text.ElideRight + } + + Text { + width: parent.width + text: hit.modelData.kind === "shortcut" + ? hit.modelData.detail + : root.pageLabel(hit.modelData.page) + color: hit.modelData.kind === "shortcut" ? Theme.accent : Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + } + + MouseArea { + id: hitMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.pageRequested(hit.modelData.page); + searchInput.text = ""; + } + } + } + } + } + Column { width: parent.width spacing: 4 + visible: root.query === "" Repeater { - model: root.destinations.filter(item => root.query === "" || item.label.toLowerCase().includes(root.query)) + model: root.destinations Rectangle { id: navItem diff --git a/config/dot/quickshell/modules/settings/WallpaperPicker.qml b/config/dot/quickshell/modules/settings/WallpaperPicker.qml new file mode 100644 index 0000000..42c8e57 --- /dev/null +++ b/config/dot/quickshell/modules/settings/WallpaperPicker.qml @@ -0,0 +1,151 @@ +// The wallpaper grid. +// +// A thumbnail grid rather than a file path field: choosing a background is the +// one setting where the value IS the picture, and typing a path to something +// you cannot see is the worst possible version of it. +// +// Images are loaded asynchronously and at a fraction of their real size -- +// several of the candidates here are 8-12 MB, and decoding them at full +// resolution to draw a 150px tile would cost more memory than the rest of the +// shell put together. + +import QtQuick +import QtQuick.Effects +import qs.config +import qs.services + +Item { + id: root + + implicitHeight: grid.implicitHeight + + readonly property int columns: Math.max(2, Math.floor(width / 190)) + readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160 + + Grid { + id: grid + + width: parent.width + columns: root.columns + spacing: 10 + + Repeater { + model: Wallpaper.available + + Rectangle { + id: tile + + required property var modelData + + readonly property bool current: Wallpaper.active === tile.modelData + + width: root.cellWidth + height: Math.round(root.cellWidth * 9 / 16) + radius: Theme.cardRadius + color: Theme.alpha(Theme.bgDark, 0.6) + clip: true + border.width: 0 + + Image { + id: thumbnail + + anchors.fill: parent + source: "file://" + tile.modelData + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: false + // Decode to roughly the size actually drawn. Without this a + // grid of 12MB photographs decodes at full resolution. + sourceSize.width: 400 + sourceSize.height: 240 + + // Several of these are 8-12MB originals, so a tile can sit + // empty for a second or two. Fading in on ready makes that + // read as loading rather than as a broken image, and the + // fade runs once per tile rather than continuously. + opacity: status === Image.Ready ? 1 : 0 + Behavior on opacity { + NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutQuad } + } + } + + Text { + anchors.centerIn: parent + visible: thumbnail.status === Image.Loading + text: "Loading…" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + Text { + anchors.centerIn: parent + width: parent.width - 20 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + visible: thumbnail.status === Image.Error + text: "Could not read this image" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + // The prism marks the active wallpaper, the same way it marks + // the focused window and the selected sidebar entry. + Rectangle { + anchors.fill: parent + radius: parent.radius + visible: tile.current + color: "transparent" + border.width: 2 + border.color: Theme.accent + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 26 + visible: tile.current || hover.hovered + border.width: 0 + + gradient: Gradient { + GradientStop { position: 0.0; color: Theme.alpha(Theme.bgDark, 0.0) } + GradientStop { position: 1.0; color: Theme.alpha(Theme.bgDark, 0.88) } + } + + Text { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 7 + text: tile.current ? "Current wallpaper" : Wallpaper.titleFor(tile.modelData) + color: tile.current ? Theme.accent : Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: tile.current ? Font.DemiBold : Font.Normal + elide: Text.ElideRight + } + } + + HoverHandler { id: hover } + + TapHandler { + onTapped: Wallpaper.set(tile.modelData) + } + } + } + } + + Text { + anchors.centerIn: parent + visible: Wallpaper.available.length === 0 && !Wallpaper.scanning + width: parent.width - 40 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + text: "No images found. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds." + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index f9a1b42..4cdb5c6 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -27,3 +27,7 @@ ChoiceRow 1.0 ChoiceRow.qml ActionRow 1.0 ActionRow.qml TextRow 1.0 TextRow.qml DesktopPreview 1.0 DesktopPreview.qml +PowerPage 1.0 PowerPage.qml +DateTimePage 1.0 DateTimePage.qml +AccessibilityPage 1.0 AccessibilityPage.qml +WallpaperPicker 1.0 WallpaperPicker.qml diff --git a/config/dot/quickshell/scripts/panama-idle b/config/dot/quickshell/scripts/panama-idle new file mode 100755 index 0000000..f5cb8db --- /dev/null +++ b/config/dot/quickshell/scripts/panama-idle @@ -0,0 +1,142 @@ +#!/usr/bin/env bash + +# Generates hypridle's configuration from Panama's shared settings. +# +# Why this exists rather than editing hypridle.conf directly: ~/.config/hypr is +# a symlink into the Panama repository, so writing hypridle.conf at runtime +# would dirty a tracked file with machine state. The generated config therefore +# lives under XDG_STATE_HOME, and a systemd drop-in points hypridle at it with +# `-c`. The repository's hypridle.conf remains the shipped default and is what +# runs if this has never been set up. +# +# panama-idle apply regenerate and restart hypridle +# panama-idle status report as JSON what is in effect +# panama-idle install write the systemd drop-in (idempotent) +# panama-idle remove remove the drop-in and fall back to the shipped config +# +# All values are read from the settings store and clamped here as well as in the +# schema, because this script is also reachable from a shell. + +set -euo pipefail + +settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json" +state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/panama" +generated="$state_dir/hypridle.conf" +dropin_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/hypridle.service.d" +dropin="$dropin_dir/panama.conf" + +read_setting() { + local key="$1" fallback="$2" + [[ -r "$settings" ]] || { printf '%s' "$fallback"; return; } + jq -r --arg k "$key" --arg d "$fallback" \ + 'if has($k) and (.[$k] != null) then (.[$k] | tostring) else $d end' \ + "$settings" 2>/dev/null || printf '%s' "$fallback" +} + +clamp_int() { + local value="$1" low="$2" high="$3" fallback="$4" + [[ "$value" =~ ^-?[0-9]+$ ]] || { printf '%s' "$fallback"; return; } + (( value < low )) && value="$low" + (( value > high )) && value="$high" + printf '%s' "$value" +} + +load() { + blank_min="$(clamp_int "$(read_setting screenBlankMinutes 5)" 0 120 5)" + lock_min="$(clamp_int "$(read_setting lockMinutes 10)" 0 240 10)" + suspend_min="$(clamp_int "$(read_setting suspendMinutes 0)" 0 480 0)" + lock_on_sleep="$(read_setting lockOnSleep true)" + [[ "$lock_on_sleep" == "true" || "$lock_on_sleep" == "false" ]] || lock_on_sleep=true +} + +generate() { + load + mkdir -p "$state_dir" + + { + printf '# Generated by panama-idle from %s\n' "$settings" + printf '# Do not edit: it is rewritten whenever the idle settings change.\n' + printf '# The shipped defaults live in the Panama repo at config/dot/hypr/hypridle.conf.\n\n' + + printf 'general {\n' + printf ' lock_cmd = pidof hyprlock || hyprlock\n' + if [[ "$lock_on_sleep" == "true" ]]; then + printf ' before_sleep_cmd = loginctl lock-session\n' + fi + printf " after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = \"on\" })'\n" + printf ' inhibit_sleep = 2\n' + printf '}\n' + + if (( blank_min > 0 )); then + printf '\n# %s minutes -> screen off.\n' "$blank_min" + printf 'listener {\n' + printf ' timeout = %s\n' "$(( blank_min * 60 ))" + printf " on-timeout = hyprctl dispatch 'hl.dsp.dpms({ action = \"off\" })'\n" + printf " on-resume = hyprctl dispatch 'hl.dsp.dpms({ action = \"on\" })'\n" + printf '}\n' + fi + + if (( lock_min > 0 )); then + printf '\n# %s minutes -> lock.\n' "$lock_min" + printf 'listener {\n' + printf ' timeout = %s\n' "$(( lock_min * 60 ))" + printf ' on-timeout = loginctl lock-session\n' + printf '}\n' + fi + + if (( suspend_min > 0 )); then + printf '\n# %s minutes -> suspend.\n' "$suspend_min" + printf 'listener {\n' + printf ' timeout = %s\n' "$(( suspend_min * 60 ))" + printf ' on-timeout = systemctl suspend\n' + printf '}\n' + fi + } >"$generated.tmp" + + mv "$generated.tmp" "$generated" +} + +install_dropin() { + mkdir -p "$dropin_dir" + cat >"$dropin" </dev/null || printf unknown)" \ + "$blank_min" "$lock_min" "$suspend_min" "$lock_on_sleep" "$generated" + ;; + *) + printf 'usage: panama-idle [apply|install|remove|status]\n' >&2 + exit 2 + ;; +esac diff --git a/config/dot/quickshell/services/Accessibility.qml b/config/dot/quickshell/services/Accessibility.qml new file mode 100644 index 0000000..de6d284 --- /dev/null +++ b/config/dot/quickshell/services/Accessibility.qml @@ -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); + } +} diff --git a/config/dot/quickshell/services/DateTime.qml b/config/dot/quickshell/services/DateTime.qml new file mode 100644 index 0000000..e27a3fd --- /dev/null +++ b/config/dot/quickshell/services/DateTime.qml @@ -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; + } +} diff --git a/config/dot/quickshell/services/IdleLock.qml b/config/dot/quickshell/services/IdleLock.qml new file mode 100644 index 0000000..f761d92 --- /dev/null +++ b/config/dot/quickshell/services/IdleLock.qml @@ -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() + } +} diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml new file mode 100644 index 0000000..eaa30a9 --- /dev/null +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -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); + } +} diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index 46bc462..578b7f4 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -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; diff --git a/config/dot/quickshell/services/SystemSettings.qml b/config/dot/quickshell/services/SystemSettings.qml index 79a4338..4e3e711 100644 --- a/config/dot/quickshell/services/SystemSettings.qml +++ b/config/dot/quickshell/services/SystemSettings.qml @@ -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; } diff --git a/config/dot/quickshell/services/Wallpaper.qml b/config/dot/quickshell/services/Wallpaper.qml new file mode 100644 index 0000000..d57688d --- /dev/null +++ b/config/dot/quickshell/services/Wallpaper.qml @@ -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 ,` 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 ",", 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 "," 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, " "); + } +} diff --git a/config/dot/quickshell/settings-search-harness.qml b/config/dot/quickshell/settings-search-harness.qml new file mode 100644 index 0000000..12af5c5 --- /dev/null +++ b/config/dot/quickshell/settings-search-harness.qml @@ -0,0 +1,21 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.services + +ShellRoot { + IpcHandler { + target: "settings-search-test" + + function find(query: string): string { + const hits = SettingsSearch.search(query); + return JSON.stringify({ + count: hits.length, + top: hits.length > 0 ? hits[0].label : "", + topPage: hits.length > 0 ? hits[0].page : "", + labels: hits.slice(0, 6).map(hit => hit.label) + }); + } + } +} diff --git a/tests/hypr/idle-config-contract.sh b/tests/hypr/idle-config-contract.sh new file mode 100755 index 0000000..5dc9473 --- /dev/null +++ b/tests/hypr/idle-config-contract.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +# scripts/panama-idle generates hypridle's configuration from Panama's settings. +# +# This is the one generator that can cost the user their automatic screen lock, +# so the properties that matter are: zero means never (rather than "immediately", +# which a naive template would produce), values are clamped even when the +# settings file has been hand-edited, and a missing or corrupt file still yields +# a working configuration rather than an empty one. + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-idle" +work="$(mktemp -d /tmp/panama-idle-contract.XXXXXX)" + +fail() { + printf 'idle config contract: %s\n' "$1" >&2 + exit 1 +} + +cleanup() { rm -rf "$work"; } +trap cleanup EXIT + +generated="$work/state/panama/hypridle.conf" + +run_with() { + # Isolated config AND state, so this never touches the real generated file + # and never restarts the user's hypridle: `apply` only restarts when the + # systemd drop-in exists, and it cannot exist under this temporary root. + mkdir -p "$work/config/panama" + printf '%s' "$1" >"$work/config/panama/settings.json" + XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" "$helper" apply +} + +# ── Shipped defaults ───────────────────────────────────────────────────────── +run_with '{}' +grep -q 'timeout = 300' "$generated" || fail 'default screen blank is not 5 minutes' +grep -q 'timeout = 600' "$generated" || fail 'default lock is not 10 minutes' +grep -q 'before_sleep_cmd' "$generated" || fail 'lock before sleep missing by default' +grep -q 'systemctl suspend' "$generated" && fail 'automatic suspend is on by default' + +# ── Zero means never, not immediately ──────────────────────────────────────── +run_with '{"screenBlankMinutes":0,"lockMinutes":0,"suspendMinutes":0}' +grep -q 'timeout = 0' "$generated" && fail 'a zero timeout was written as an immediate trigger' +# `dpms` also appears in general.after_sleep_cmd, which is correct and must +# stay; what must not exist is any listener at all. +grep -qE '^listener' "$generated" && fail 'a listener was written when everything is set to never' +grep -q 'after_sleep_cmd' "$generated" || fail 'the general block was lost when all timers are off' + +# ── Values are honoured ────────────────────────────────────────────────────── +run_with '{"screenBlankMinutes":2,"lockMinutes":7,"suspendMinutes":45}' +grep -q 'timeout = 120' "$generated" || fail '2 minute blank not honoured' +grep -q 'timeout = 420' "$generated" || fail '7 minute lock not honoured' +grep -q 'timeout = 2700' "$generated" || fail '45 minute suspend not honoured' +grep -q 'systemctl suspend' "$generated" || fail 'suspend listener missing when set' + +# ── lockOnSleep off removes the pre-sleep lock ─────────────────────────────── +run_with '{"lockOnSleep":false}' +grep -q 'before_sleep_cmd' "$generated" && fail 'pre-sleep lock present when disabled' + +# ── Hand-edited nonsense is clamped, not passed through ────────────────────── +run_with '{"screenBlankMinutes":99999,"lockMinutes":-40,"suspendMinutes":"soon"}' +grep -q 'timeout = 7200' "$generated" || fail 'an above-range blank was not clamped to the maximum' +grep -q 'timeout = 0' "$generated" && fail 'a negative lock produced an immediate trigger' +grep -q 'systemctl suspend' "$generated" && fail 'a non-numeric suspend produced a listener' + +# ── A corrupt or absent settings file still yields a working config ────────── +run_with '{ not json at all' +grep -q 'timeout = 300' "$generated" || fail 'a corrupt settings file did not fall back to defaults' +rm -f "$work/config/panama/settings.json" +XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" "$helper" apply +grep -q 'timeout = 300' "$generated" || fail 'an absent settings file did not fall back to defaults' + +# ── status reports what it generated ───────────────────────────────────────── +status="$(XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" "$helper" status)" +jq -e '.managed == false and .blankMinutes == 5 and .lockMinutes == 10' <<<"$status" >/dev/null \ + || fail "status did not report the generated values: $status" + +trap - EXIT +cleanup +printf 'idle config contract: PASS\n' diff --git a/tests/quickshell/settings-search-contract.sh b/tests/quickshell/settings-search-contract.sh new file mode 100755 index 0000000..63c19f4 --- /dev/null +++ b/tests/quickshell/settings-search-contract.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash + +# The sidebar search indexes the schema, not the page names. +# +# Before this, the field filtered twelve page labels, so "gaps", "wallpaper", +# and "screenshot" all returned nothing on an app that has all three. That is +# the single clearest way a settings app feels smaller than it is, and it +# regresses invisibly: nothing breaks, results just quietly stop appearing. +# +# This pins that every schema entry is reachable by its own label, that +# shortcuts are searchable by what they do, and that results route to a page +# that actually exists. + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +harness="$repo_dir/config/dot/quickshell/settings-search-harness.qml" + +fail() { + printf 'settings search contract: %s\n' "$1" >&2 + exit 1 +} + +qs_for_harness() { + qs -p "$harness" "$@" +} + +cleanup() { + qs_for_harness kill >/dev/null 2>&1 || true +} +trap cleanup EXIT + +qs_for_harness --daemonize >/dev/null +for _ in $(seq 1 40); do + qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-search-test$' && break + sleep 0.1 +done +qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-search-test$' || fail 'test IPC target did not start' + +find_top() { + qs_for_harness ipc call settings-search-test find "$1" +} + +# ── An empty query is navigation, not a search ─────────────────────────────── +[[ "$(find_top '' | jq -r .count)" == "0" ]] || fail 'an empty query returned results' + +# ── Real settings are findable by what they are ────────────────────────────── +while IFS='|' read -r query expect_label expect_page; do + result="$(find_top "$query")" + got_label="$(jq -r .top <<<"$result")" + got_page="$(jq -r .topPage <<<"$result")" + [[ "$got_label" == "$expect_label" ]] \ + || fail "searching '$query' put '$got_label' first, expected '$expect_label'" + [[ "$got_page" == "$expect_page" ]] \ + || fail "searching '$query' routes to '$got_page', expected '$expect_page'" +done <<'CASES' +wallpaper|Wallpaper|appearance +blur|Blur|appearance +timezone|Timezone|datetime +repeat delay|Repeat delay|shortcuts +CASES + +# ── Shortcuts are searchable by what they do ───────────────────────────────── +[[ "$(find_top screenshot | jq -r .topPage)" == "shortcuts" ]] \ + || fail 'searching a shortcut description did not route to the shortcuts page' + +# ── Every non-internal schema entry is reachable by its own label ──────────── +# A setting that cannot be found by typing its name is a setting the user +# cannot discover, which is the failure this whole page structure exists to fix. +schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" +missing=0 +while IFS= read -r label; do + [[ -n "$label" ]] || continue + count="$(find_top "$label" | jq -r .count)" + if [[ "$count" == "0" ]]; then + printf 'settings search contract: no result for schema label "%s"\n' "$label" >&2 + missing=$((missing + 1)) + fi +done < <(python3 - "$schema" <<'EXTRACT' +import re, sys +# Entries marked `internal` are shell state the user never edits, so they are +# deliberately excluded from search. Extract only the user-facing labels. +text = open(sys.argv[1]).read() +for block in re.findall(r"\{\s*\n\s+key:.*?\n\s{8}\}", text, re.S): + if "internal: true" in block: + continue + match = re.search(r'label: "([^"]+)"', block) + if match: + print(match.group(1)) +EXTRACT +) +[[ "$missing" -eq 0 ]] || fail "$missing schema label(s) are not findable by search" + +# ── Results only ever route to pages the shell can open ────────────────────── +allowed="$(grep -oE 'const allowed = \[[^]]*\]' "$repo_dir/config/dot/quickshell/services/ShellState.qml" \ + | grep -oE '"[a-z-]+"' | tr -d '"' | sort -u)" +for query in wallpaper blur timezone screenshot pointer lock volume gaps; do + page="$(find_top "$query" | jq -r .topPage)" + grep -qx "$page" <<<"$allowed" || fail "search routed '$query' to unknown page '$page'" +done + +trap - EXIT +cleanup +printf 'settings search contract: PASS\n'