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
@@ -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 {
title: "Windows"
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 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()
}
}
@@ -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 {} }
@@ -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
@@ -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
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