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:
Gabriel Brown
2026-08-18 00:42:13 -04:00
parent fe7c85e471
commit 2bc12e6022
21 changed files with 1619 additions and 4 deletions
@@ -322,6 +322,62 @@ Singleton {
detail: "Lower is warmer" 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 "<output>,<path>" 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 ──────────────────────────────────────────────────────── // ── Internal ────────────────────────────────────────────────────────
{ {
key: "lastPage", type: "string", def: "home", group: "internal", key: "lastPage", type: "string", def: "home", group: "internal",
@@ -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
}
}
@@ -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 { SettingsCard {
title: "Windows" title: "Windows"
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved." subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
@@ -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
}
}
@@ -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()
}
}
}
@@ -12,6 +12,12 @@ Item {
property bool divider: true property bool divider: true
property int controlWidth: 150 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 width: parent ? parent.width : 620
implicitHeight: Math.max(56, copy.implicitHeight + 20) implicitHeight: Math.max(56, copy.implicitHeight + 20)
@@ -85,4 +91,27 @@ Item {
visible: root.divider visible: root.divider
color: Theme.alpha(Theme.fg, 0.065) 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()
}
} }
@@ -99,6 +99,9 @@ Rectangle {
case "notifications": return notificationsPage; case "notifications": return notificationsPage;
case "screen-intelligence": return screenIntelligencePage; case "screen-intelligence": return screenIntelligencePage;
case "shortcuts": return shortcutsPage; case "shortcuts": return shortcutsPage;
case "accessibility": return accessibilityPage;
case "power": return powerPage;
case "datetime": return dateTimePage;
case "services": return servicesPage; case "services": return servicesPage;
case "about": return aboutPage; case "about": return aboutPage;
default: return homePage; default: return homePage;
@@ -136,6 +139,9 @@ Rectangle {
} }
Component { id: homePage; HomePage {} } Component { id: homePage; HomePage {} }
Component { id: accessibilityPage; AccessibilityPage {} }
Component { id: powerPage; PowerPage {} }
Component { id: dateTimePage; DateTimePage {} }
Component { id: appearancePage; AppearancePage {} } Component { id: appearancePage; AppearancePage {} }
Component { id: displaysPage; DisplaysPage {} } Component { id: displaysPage; DisplaysPage {} }
Component { id: connectivityPage; ConnectivityPage {} } Component { id: connectivityPage; ConnectivityPage {} }
@@ -1,5 +1,6 @@
import QtQuick import QtQuick
import qs.config import qs.config
import qs.services
Rectangle { Rectangle {
id: root id: root
@@ -8,6 +9,13 @@ Rectangle {
property string query: searchInput.text.trim().toLowerCase() property string query: searchInput.text.trim().toLowerCase()
signal pageRequested(string page) 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: [ readonly property var destinations: [
{ page: "home", label: "Home", icon: "\u{F02DC}" }, { page: "home", label: "Home", icon: "\u{F02DC}" },
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}" }, { page: "appearance", label: "Appearance", icon: "\u{F0E0D}" },
@@ -19,6 +27,9 @@ Rectangle {
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" }, { page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" }, { page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
{ page: "shortcuts", label: "Input & Shortcuts", icon: "\u{F030C}" }, { 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: "services", label: "Startup & Services", icon: "\u{F0493}" },
{ page: "about", label: "About Panama", icon: "\u{F02FD}" } { 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 { Column {
width: parent.width width: parent.width
spacing: 4 spacing: 4
visible: root.query === ""
Repeater { Repeater {
model: root.destinations.filter(item => root.query === "" || item.label.toLowerCase().includes(root.query)) model: root.destinations
Rectangle { Rectangle {
id: navItem id: navItem
@@ -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
}
}
@@ -27,3 +27,7 @@ ChoiceRow 1.0 ChoiceRow.qml
ActionRow 1.0 ActionRow.qml ActionRow 1.0 ActionRow.qml
TextRow 1.0 TextRow.qml TextRow 1.0 TextRow.qml
DesktopPreview 1.0 DesktopPreview.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
+142
View File
@@ -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" <<EOF
# Installed by panama-idle. Points hypridle at the configuration Panama
# generates from its settings store, so idle timings are adjustable from
# Panama Settings rather than by editing a file in the Panama repository.
[Service]
ExecStart=
ExecStart=/usr/bin/hypridle -c $generated
EOF
systemctl --user daemon-reload
}
case "${1:-apply}" in
apply)
generate
if [[ -f "$dropin" ]]; then
systemctl --user restart hypridle.service
fi
;;
install)
generate
install_dropin
systemctl --user restart hypridle.service
;;
remove)
rm -f "$dropin"
systemctl --user daemon-reload
systemctl --user restart hypridle.service
;;
status)
load
managed=false
[[ -f "$dropin" ]] && managed=true
printf '{"managed":%s,"active":"%s","blankMinutes":%s,"lockMinutes":%s,"suspendMinutes":%s,"lockOnSleep":%s,"generated":"%s"}\n' \
"$managed" \
"$(systemctl --user is-active hypridle.service 2>/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
@@ -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);
}
}
+133
View File
@@ -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;
}
}
+115
View File
@@ -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 { 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"; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
DesktopPreferences.set("lastPage", root.settingsPage); DesktopPreferences.set("lastPage", root.settingsPage);
root.settingsOpen = true; root.settingsOpen = true;
@@ -435,9 +435,15 @@ Singleton {
} }
function isGnomePanelAllowed(panel: string): bool { 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 [ return [
"wifi", "network", "bluetooth", "sound", "power", "printers", "applications", "background", "bluetooth", "color", "display",
"online-accounts", "users", "mouse", "keyboard", "sharing" "keyboard", "mouse", "multitasking", "network", "notifications",
"online-accounts", "power", "printers", "privacy", "search",
"sharing", "sound", "system", "universal-access", "wacom",
"wellbeing", "wifi", "wwan"
].indexOf(panel) >= 0; ].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, " ");
}
}
@@ -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)
});
}
}
}
+82
View File
@@ -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'
+104
View File
@@ -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'