Author SHA1 Message Date
Gabriel Brown 83391e0453 Make settings restore crash-safe 2026-08-18 02:22:23 -04:00
Gabriel Brown 172b099a04 Complete settings snapshot restoration 2026-08-18 02:22:23 -04:00
Gabriel Brown cae72b5179 Make Settings sidebar content scrollable 2026-08-18 01:54:00 -04:00
Gabriel Brown b3b8e0d66d Prevent shortcut reset collisions 2026-08-18 01:47:51 -04:00
Gabriel Brown 787d2b121a Use the durable Home reset boundary 2026-08-18 01:39:34 -04:00
Gabriel Brown 1b323c2fa5 Document Settings completion plan 2026-08-18 01:36:30 -04:00
Gabriel Brown 1ca571458c Complete Panama settings controls 2026-08-18 01:36:07 -04:00
Gabriel Brown 768801dbe4 Fix application role filtering 2026-08-18 01:28:29 -04:00
Gabriel Brown 375ecfcd95 Add application and autostart settings 2026-08-18 01:28:29 -04:00
Gabriel Brown ce95b34d19 Harden Home preferences reset contract 2026-08-18 01:28:29 -04:00
Gabriel Brown 9d430a3079 Finish Home and Phone settings cohesion 2026-08-18 01:28:29 -04:00
Gabriel Brown bac68d2bfb Document the settings vocabulary and its traps
Records how to add a setting -- one schema entry -- and the failure
modes found building the pages. Every one of them fails silently rather
than loudly, which is why they are worth writing down: a wrong readAs
makes successful writes look rejected, hyprpaper accepts an all-outputs
wallpaper request and ignores it, and a copy of the Quickshell config
shares the live shell's ID so an "isolated" harness can kill the running
desktop.

Also documents the two generated configurations and why they are
generated: ~/.config/hypr is a symlink into this repository, so writing
there at runtime would commit machine state to a tracked file.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 01:15:53 -04:00
Gabriel Brown 634a9ebe07 Let shortcuts be rebound from Settings
Every bind in keybinds.lua now goes through a small wrapper that
substitutes the chord from a stored override. Only the chord is taken
from settings; the action is always the Lua value written in that file,
so an override can move a shortcut but can never make one do something
else. That is the property that makes reading them from a file the user
can edit safe, and it is why the alternative -- storing dispatchers --
was not considered.

Overrides are keyed by the shipped chord rather than the description.
Keying by description moved every bind that shared one: rebinding
SUPER+C also moved the XF86Calculator hardware key onto the same chord,
silently costing it. Chords are unique; descriptions are not.

Applying needs hyprctl reload rather than a live hl.bind. Hyprland
reports Lua-defined binds with dispatcher "__lua" and a bytecode offset,
so the action cannot be reconstructed from outside to re-bind it; reload
re-runs the config, which re-reads the settings file.

The capture control ignores modifier-only presses, because every chord
passes through them and holding Super would otherwise be captured the
moment the modifier went down. It refuses a bare letter, which would
swallow ordinary typing, and refuses a key with no keysym name rather
than storing something that would fail to bind. Rebinding onto a chord
already in use is refused rather than shadowing the existing shortcut.

The refactor was verified by snapshotting all 113 binds before and
after: the keymap is byte-identical, and identical again after applying
an override and resetting it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 01:13:36 -04:00
Gabriel Brown 150f3cdb09 Make the Dock editable and add settings snapshots
The Dock's pinned applications were a sixteen-entry literal in
Settings.qml, so changing what sits in the Dock meant editing QML. They
are now an ordered list in the shared store, with move up, move down,
unpin, and a filtered picker for adding installed applications. Keeping
them in the shared store rather than a file of their own means they are
covered by Restore defaults like everything else.

This needed a "json" schema type for values the schema stores and resets
but does not validate field by field. It exists so structured settings
can live in the one file rather than growing a fourth preference store;
the owning service validates the contents.

Snapshots make the settings app safe to experiment with. The whole
configuration is one file, so a backup is a copy and a restore is an
overwrite, and restoring snapshots what it replaces so it is itself
undoable. A snapshot is validated as JSON before it can be restored over
a working configuration, and a name that is not a plain snapshot
filename from the backup directory is refused.

Snapshot names carry milliseconds. At one-second resolution a save
followed promptly by a restore produced the same filename twice, and the
restore's own safety snapshot overwrote the file it was about to read --
found by the contract, which restores immediately after saving.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 00:59:08 -04:00
Gabriel Brown 6377bfb8fd Route the Applications page and add its settings
Wires the schema entries and routing the codex agent needs for default
applications, weather, vitals refresh, notification timing, and capture,
so its pages can be written against keys that already exist.

Capture directories and encoder arguments are enums rather than free
text. Both are handed to a recorder process, and a settings page has no
reason to expose an arbitrary string there.

ApplicationsPage.qml is a placeholder so the page id can be routed,
registered, and searchable before the real page lands. It is owned by
the other agent and expected to be replaced wholesale.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 00:51:10 -04:00
Gabriel Brown 2bc12e6022 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
2026-08-18 00:42:13 -04:00
Gabriel Brown fe7c85e471 Build the settings vocabulary and generate the keymap
Stage 3 and 4 of docs/superpowers/plans/2026-08-17-panama-cohesion.md.

Add SettingsPage plus ToggleRow, SliderRow, ChoiceRow, ActionRow, and
TextRow. A row names a schema key and needs nothing else: label, detail,
range, and unit come from PreferenceSchema, and writes go through
SystemSettings.commitPreference, which routes compositor-backed keys
through apply-and-verify and local keys straight to the store. The page
scaffold that was copy-pasted eleven times is now one component.

Rebuild Appearance around a live preview of the real desktop, scaled by
the ratio between the preview and the actual monitor so a 10px gap on a
4500px display looks as small as it is. Rebuild Desktop & Dock and Input
& Shortcuts on the shared rows, replacing the read-only text that stood
in for controls that were merely expensive to add.

Generate the shortcut list from hyprctl binds. The page held a
hand-typed nineteen entries against a real keymap of a hundred and
thirteen; it could not show the rest and went stale whenever a bind
changed. Every bind now carries its own description -- backfilled for
the twenty-nine that lacked one -- and keybinds-contract.sh fails if any
bind lacks one, since undescribed binds are dropped from the page.

Make Restore defaults span every store Panama owns. Resetting only the
schema store left the Home accessory arrangement customised while
claiming to restore defaults, which is worse than no reset because it is
silent. Done through HomePreferences' existing public aliases rather
than a new API.

Four defects found while building:

cursor:inactive_timeout is answered by getoption as float, not int. A
wrong readAs does not fail loudly; it makes every write to that key look
rejected, and the user saw an error for a change that worked.
schema-hypr-shape-contract.sh now checks all 23 mapped options against
the running compositor.

The Settings window is tiled, so implicitWidth is only a hint and rows
must survive roughly 400px. SliderRow stacks its control under the label
below 520px.

Binding an anchor to undefined to switch layouts does not reliably
release it. Both row layouts are positioned explicitly.

Concurrent compositor writes are queued and merged rather than refused.
The startup replay of every compositor-backed preference routinely
overlaps a UI change, and refusing left the store and the compositor
disagreeing.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 00:24:51 -04:00
74 changed files with 8027 additions and 1143 deletions
+63
View File
@@ -44,6 +44,30 @@ Validate any change without leaving your session:
Hyprland --verify-config Hyprland --verify-config
``` ```
## Generated configuration
Two ecosystem tools cannot read the shared settings file, and their configs live
in this directory — which is a symlink into the Panama repository, so writing to
them at runtime would put machine state into a tracked file. Both are therefore
generated elsewhere:
| Tool | Generated to | Pointed there by |
|---|---|---|
| `hypridle` | `$XDG_STATE_HOME/panama/hypridle.conf` | a systemd user drop-in installed by `panama-idle install` |
| `hyprpaper` | not generated — the wallpaper is applied over IPC and re-applied at shell start | — |
`quickshell/scripts/panama-idle` regenerates the hypridle config from the
settings store and restarts the daemon. `hypridle.conf` in this directory
remains the shipped default and is what runs when the drop-in is not installed;
Panama Settings shows which of the two states you are in rather than presenting
controls that quietly do nothing.
Remove the drop-in and go back to the shipped config with:
```sh
~/.config/quickshell/scripts/panama-idle remove
```
## Settings: one file, both sides ## Settings: one file, both sides
`~/.config/panama/settings.json` is shared with the Quickshell side. The `~/.config/panama/settings.json` is shared with the Quickshell side. The
@@ -67,6 +91,45 @@ wrong-typed settings file costs you your customisations and nothing else;
`tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still `tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still
accepts the config in each of those states. accepts the config in each of those states.
### Adding a keybind: use `bind`, not `hl.bind`
Every bind in `keybinds.lua` goes through a local `bind()` wrapper that
substitutes the chord from a stored override, so shortcuts can be moved from
Panama Settings without editing this file.
```lua
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
```
Only the **chord** is ever taken from settings — the action is always the Lua
value written here. A stored override can therefore move a shortcut but can
never make one do something else, which is what makes reading overrides from a
file the user can edit safe.
Overrides are keyed by the **shipped chord**, not the description. Descriptions
are not unique: "Calculator" is both `SUPER + C` and the `XF86Calculator`
hardware key, and keying by description moved both onto the same new chord,
silently costing the hardware key.
An override whose value is not a plausible chord is ignored in favour of the
shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
### Keybind descriptions are required
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
one has nothing readable beside its chord, and Panama Settings drops it from the
Input & Shortcuts page rather than showing a mystery row.
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
this cannot regress silently.
```lua
hl.bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
```
The page groups shortcuts by what the description says they do, so a new bind
lands in the right section with no change to the UI.
### Never use `hyprctl keyword` ### Never use `hyprctl keyword`
On a Lua-configured Hyprland it refuses the write, prints On a Lua-configured Hyprland it refuses the write, prints
+133 -95
View File
@@ -11,6 +11,8 @@
-- SUPER + CTRL -> change layout structure (split, float, swap) -- SUPER + CTRL -> change layout structure (split, float, swap)
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
local mod = "SUPER" local mod = "SUPER"
-- Programs, matched to the GNOME custom keybindings and media-key settings. -- Programs, matched to the GNOME custom keybindings and media-key settings.
@@ -33,109 +35,145 @@ local launcher = "vicinae toggle"
local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end
-- ── Applications ──────────────────────────────────────────────────────────── -- ── Applications ────────────────────────────────────────────────────────────
hl.bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" }) -- ── User rebinding ──────────────────────────────────────────────────────────
hl.bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" }) -- Every bind below goes through `bind` rather than `hl.bind` directly, so a
hl.bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" }) -- chord can be replaced from Panama Settings without this file changing.
hl.bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" }) --
hl.bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" }) -- Overrides are keyed by the bind's SHIPPED chord, and ONLY the chord is taken
hl.bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" }) -- from settings -- the action is always the Lua value written here. A stored
hl.bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" }) -- override can therefore move a shortcut but can never make one do something
hl.bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" }) -- else, which is the property that makes reading them from a JSON file the
-- user can edit safe.
--
-- Keyed by chord rather than by description because chords are unique and
-- descriptions are not: "Calculator" is both SUPER+C and the XF86Calculator
-- hardware key, and keying by description moved both of them onto the same new
-- chord, silently costing the hardware key.
--
-- An override whose chord is not a plausible chord is ignored and the shipped
-- one is used, so a hand-edited settings file cannot cost you a keymap.
local overrides = prefs.get("keybindOverrides", {})
local function valid_chord(chord)
if type(chord) ~= "string" or chord == "" or #chord > 64 then
return false
end
-- "SUPER + SHIFT + K", "XF86AudioPlay", "Print", "SUPER + mouse:272"
return chord:match("^[%w_+%s:]+$") ~= nil
end
local function bind(chord, action, opts)
local override = overrides[chord]
if valid_chord(override) then
chord = override
end
return hl.bind(chord, action, opts)
end
bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" })
bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" })
bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" })
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
-- ── Launcher ──────────────────────────────────────────────────────────────── -- ── Launcher ────────────────────────────────────────────────────────────────
-- All three keys open the same launcher, on purpose: SUPER+A and SUPER+R were -- All three keys open the same launcher, on purpose: SUPER+A and SUPER+R were
-- the GNOME app-grid and run-dialog shortcuts, and SUPER+SPACE is here as a -- the GNOME app-grid and run-dialog shortcuts, and SUPER+SPACE is here as a
-- third option to settle on. Vicinae covers apps, calculator, files, clipboard, -- third option to settle on. Vicinae covers apps, calculator, files, clipboard,
-- emoji and window switching, so a separate run dialog and app grid are gone. -- emoji and window switching, so a separate run dialog and app grid are gone.
hl.bind(mod .. " + A", hl.dsp.exec_cmd(launcher), { description = "Launcher" }) bind(mod .. " + A", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
hl.bind(mod .. " + R", hl.dsp.exec_cmd(launcher), { description = "Launcher" }) bind(mod .. " + R", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
hl.bind(mod .. " + Space", hl.dsp.exec_cmd(launcher), { description = "Launcher" }) bind(mod .. " + Space", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
-- Emergency fallback launcher. Vicinae runs as a systemd user service and the -- Emergency fallback launcher. Vicinae runs as a systemd user service and the
-- bar is Quickshell; if either fails to come up, this is how you start an -- bar is Quickshell; if either fails to come up, this is how you start an
-- application without dropping to a TTY. Depends on nothing but wofi itself. -- application without dropping to a TTY. Depends on nothing but wofi itself.
hl.bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" }) bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" })
-- Clipboard history and emoji, straight into the relevant launcher view. -- Clipboard history and emoji, straight into the relevant launcher view.
-- Deeplink form is the one from vicinae's own Hyprland quickstart. -- Deeplink form is the one from vicinae's own Hyprland quickstart.
hl.bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"), bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"),
{ description = "Clipboard history" }) { description = "Clipboard history" })
hl.bind(mod .. " + Period", hl.dsp.exec_cmd("vicinae vicinae://launch/emoji/search"), bind(mod .. " + Period", hl.dsp.exec_cmd("vicinae vicinae://launch/emoji/search"),
{ description = "Emoji picker" }) { description = "Emoji picker" })
-- ── Shell surfaces (Quickshell) ───────────────────────────────────────────── -- ── Shell surfaces (Quickshell) ─────────────────────────────────────────────
-- SUPER+S was GNOME's quick settings; kept. -- SUPER+S was GNOME's quick settings; kept.
hl.bind(mod .. " + S", hl.dsp.exec_cmd(qs("quicksettings", "toggle")), { description = "Quick settings" }) bind(mod .. " + S", hl.dsp.exec_cmd(qs("quicksettings", "toggle")), { description = "Quick settings" })
-- Start a 45-minute focus session on the current workspace, or reveal its -- Start a 45-minute focus session on the current workspace, or reveal its
-- Signal Glass controls if one is already running. -- Signal Glass controls if one is already running.
hl.bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { description = "Focus session" }) bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { description = "Focus session" })
-- Workspace overview. GNOME put this on a bare SUPER tap; tap-detection on a -- Workspace overview. GNOME put this on a bare SUPER tap; tap-detection on a
-- modifier misfires when you're fast with SUPER+key combos, so it lives on a -- modifier misfires when you're fast with SUPER+key combos, so it lives on a
-- real chord instead. SUPER+grave was Forge's "cycle windows of same app", -- real chord instead. SUPER+grave was Forge's "cycle windows of same app",
-- which Hyprland has no equivalent for. -- which Hyprland has no equivalent for.
hl.bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" }) bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })
-- Notification centre. -- Notification centre.
hl.bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" }) bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" })
-- Screenshot / screen record. One key, then pick screen / window / region and -- Screenshot / screen record. One key, then pick screen / window / region and
-- whether to capture or record -- reproducing GNOME's Print-screen UI. -- whether to capture or record -- reproducing GNOME's Print-screen UI.
hl.bind("Print", hl.dsp.exec_cmd(qs("capture", "open")), { description = "Screenshot / record" }) bind("Print", hl.dsp.exec_cmd(qs("capture", "open")), { description = "Screenshot / record" })
-- The GNOME direct-capture variants, kept as shortcuts past the picker. -- The GNOME direct-capture variants, kept as shortcuts past the picker.
hl.bind("SHIFT + Print", hl.dsp.exec_cmd(qs("capture", "screenNow")), { description = "Screenshot: whole screen" }) bind("SHIFT + Print", hl.dsp.exec_cmd(qs("capture", "screenNow")), { description = "Screenshot: whole screen" })
hl.bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description = "Screenshot: window" }) bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description = "Screenshot: window" })
-- Local OCR and QR/barcode recognition through the same region picker. This -- Local OCR and QR/barcode recognition through the same region picker. This
-- opens directly in Selection + Read mode; Print still exposes every mode. -- opens directly in Selection + Read mode; Print still exposes every mode.
hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")), bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
{ description = "Screen Intelligence" }) { description = "Screen Intelligence" })
-- Colour picker: copies the hex under the cursor to the clipboard. -- Colour picker: copies the hex under the cursor to the clipboard.
hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Colour picker" }) bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Colour picker" })
-- ── Window management ─────────────────────────────────────────────────────── -- ── Window management ───────────────────────────────────────────────────────
hl.bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" }) bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
hl.bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" }) bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" })
-- Forge: window-toggle-float / window-toggle-always-float. -- Forge: window-toggle-float / window-toggle-always-float.
-- "Always float" has no Hyprland equivalent (it wrote a persistent rule); pin -- "Always float" has no Hyprland equivalent (it wrote a persistent rule); pin
-- is the nearest useful thing -- the window floats above every workspace. -- is the nearest useful thing -- the window floats above every workspace.
hl.bind(mod .. " + CTRL + C", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle float" }) bind(mod .. " + CTRL + C", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle float" })
hl.bind(mod .. " + CTRL + SHIFT + C", hl.dsp.window.pin({ action = "toggle" }), { description = "Pin window" }) bind(mod .. " + CTRL + SHIFT + C", hl.dsp.window.pin({ action = "toggle" }), { description = "Pin window" })
-- Forge: con-split-layout-toggle / con-split-horizontal / con-split-vertical. -- Forge: con-split-layout-toggle / con-split-horizontal / con-split-vertical.
hl.bind(mod .. " + CTRL + G", hl.dsp.layout("togglesplit"), { description = "Toggle split direction" }) bind(mod .. " + CTRL + G", hl.dsp.layout("togglesplit"), { description = "Toggle split direction" })
hl.bind(mod .. " + CTRL + Z", hl.dsp.layout("preselect r"), { description = "Next window splits right" }) bind(mod .. " + CTRL + Z", hl.dsp.layout("preselect r"), { description = "Next window splits right" })
hl.bind(mod .. " + CTRL + V", hl.dsp.layout("preselect d"), { description = "Next window splits down" }) bind(mod .. " + CTRL + V", hl.dsp.layout("preselect d"), { description = "Next window splits down" })
-- Forge: window-shrink / window-expand / window-reset-sizes. -- Forge: window-shrink / window-expand / window-reset-sizes.
hl.bind(mod .. " + bracketleft", hl.dsp.layout("splitratio -0.05"), { repeating = true, description = "Shrink" }) bind(mod .. " + bracketleft", hl.dsp.layout("splitratio -0.05"), { repeating = true, description = "Shrink" })
hl.bind(mod .. " + bracketright", hl.dsp.layout("splitratio +0.05"), { repeating = true, description = "Expand" }) bind(mod .. " + bracketright", hl.dsp.layout("splitratio +0.05"), { repeating = true, description = "Expand" })
hl.bind(mod .. " + equal", hl.dsp.layout("splitratio exact 0.5"), { description = "Reset split" }) bind(mod .. " + equal", hl.dsp.layout("splitratio exact 0.5"), { description = "Reset split" })
-- Focus (Forge: window-focus-*). Both vim keys and arrows, as in Forge. -- Focus (Forge: window-focus-*). Both vim keys and arrows, as in Forge.
hl.bind(mod .. " + H", hl.dsp.focus({ direction = "l" }), { description = "Focus left" }) bind(mod .. " + H", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
hl.bind(mod .. " + J", hl.dsp.focus({ direction = "d" }), { description = "Focus down" }) bind(mod .. " + J", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
hl.bind(mod .. " + K", hl.dsp.focus({ direction = "u" }), { description = "Focus up" }) bind(mod .. " + K", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
hl.bind(mod .. " + L", hl.dsp.focus({ direction = "r" }), { description = "Focus right" }) bind(mod .. " + L", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
hl.bind(mod .. " + left", hl.dsp.focus({ direction = "l" })) bind(mod .. " + left", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
hl.bind(mod .. " + down", hl.dsp.focus({ direction = "d" })) bind(mod .. " + down", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
hl.bind(mod .. " + up", hl.dsp.focus({ direction = "u" })) bind(mod .. " + up", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
hl.bind(mod .. " + right", hl.dsp.focus({ direction = "r" })) bind(mod .. " + right", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
-- Move (Forge: window-move-*). -- Move (Forge: window-move-*).
hl.bind(mod .. " + SHIFT + H", hl.dsp.window.move({ direction = "l" }), { description = "Move window left" }) bind(mod .. " + SHIFT + H", hl.dsp.window.move({ direction = "l" }), { description = "Move window left" })
hl.bind(mod .. " + SHIFT + J", hl.dsp.window.move({ direction = "d" }), { description = "Move window down" }) bind(mod .. " + SHIFT + J", hl.dsp.window.move({ direction = "d" }), { description = "Move window down" })
hl.bind(mod .. " + SHIFT + K", hl.dsp.window.move({ direction = "u" }), { description = "Move window up" }) bind(mod .. " + SHIFT + K", hl.dsp.window.move({ direction = "u" }), { description = "Move window up" })
hl.bind(mod .. " + SHIFT + L", hl.dsp.window.move({ direction = "r" }), { description = "Move window right" }) bind(mod .. " + SHIFT + L", hl.dsp.window.move({ direction = "r" }), { description = "Move window right" })
-- Swap (Forge: window-swap-*). -- Swap (Forge: window-swap-*).
hl.bind(mod .. " + CTRL + H", hl.dsp.window.swap({ direction = "l" }), { description = "Swap left" }) bind(mod .. " + CTRL + H", hl.dsp.window.swap({ direction = "l" }), { description = "Swap left" })
hl.bind(mod .. " + CTRL + J", hl.dsp.window.swap({ direction = "d" }), { description = "Swap down" }) bind(mod .. " + CTRL + J", hl.dsp.window.swap({ direction = "d" }), { description = "Swap down" })
hl.bind(mod .. " + CTRL + K", hl.dsp.window.swap({ direction = "u" }), { description = "Swap up" }) bind(mod .. " + CTRL + K", hl.dsp.window.swap({ direction = "u" }), { description = "Swap up" })
hl.bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { description = "Swap right" }) bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { description = "Swap right" })
-- Resize (Forge: window-resize-<edge>-<increase|decrease>). -- Resize (Forge: window-resize-<edge>-<increase|decrease>).
-- --
@@ -145,24 +183,24 @@ hl.bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { descrip
-- consistent with the original: Y/B/O/M are horizontal, I/P/U/N are vertical, -- consistent with the original: Y/B/O/M are horizontal, I/P/U/N are vertical,
-- and "increase" always grows while "decrease" always shrinks. -- and "increase" always grows while "decrease" always shrinks.
local step = 60 local step = 60
hl.bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" }) bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
hl.bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" }) bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
hl.bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" }) bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
hl.bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" }) bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
hl.bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" }) bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
hl.bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" }) bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
hl.bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" }) bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
hl.bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" }) bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
-- Window cycling (GNOME: cycle-windows on SUPER+Tab). -- Window cycling (GNOME: cycle-windows on SUPER+Tab).
hl.bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" }) bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
hl.bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" }) bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" })
-- Jump back to the previously focused window. -- Jump back to the previously focused window.
hl.bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" }) bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
-- Mouse: drag to move, right-drag to resize. -- Mouse: drag to move, right-drag to resize.
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true }) bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window with pointer" })
hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true }) bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window with pointer" })
-- ── Workspaces ────────────────────────────────────────────────────────────── -- ── Workspaces ──────────────────────────────────────────────────────────────
-- ALT is the workspace modifier, matching the GNOME setup. -- ALT is the workspace modifier, matching the GNOME setup.
@@ -170,27 +208,27 @@ hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces: -- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
-- moving right past the last workspace creates a new one, and moving left from -- moving right past the last workspace creates a new one, and moving left from
-- the first clamps instead of wrapping. -- the first clamps instead of wrapping.
hl.bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" }) bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
hl.bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" }) bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
hl.bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" }) bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
hl.bind("ALT + SHIFT + L", hl.dsp.window.move({ workspace = "+1" }), { description = "Move window to workspace right" }) bind("ALT + SHIFT + L", hl.dsp.window.move({ workspace = "+1" }), { description = "Move window to workspace right" })
-- GNOME also had these on CTRL+ALT+Up/Down. -- GNOME also had these on CTRL+ALT+Up/Down.
hl.bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" })) bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
hl.bind("CTRL + ALT + down", hl.dsp.focus({ workspace = "+1" })) bind("CTRL + ALT + down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
-- Direct jump. ALT+0 is workspace 10. -- Direct jump. ALT+0 is workspace 10.
for i = 1, 10 do for i = 1, 10 do
local key = i % 10 local key = i % 10
hl.bind("ALT + " .. key, hl.dsp.focus({ workspace = i }), { description = "Workspace " .. i }) bind("ALT + " .. key, hl.dsp.focus({ workspace = i }), { description = "Workspace " .. i })
hl.bind("ALT + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }), { description = "Move window to workspace " .. i }) bind("ALT + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }), { description = "Move window to workspace " .. i })
end end
-- Scroll the mouse wheel over the desktop with SUPER held to change workspace. -- Scroll the mouse wheel over the desktop with SUPER held to change workspace.
-- (Scrolling the workspace indicator in the bar does the same; that's handled -- (Scrolling the workspace indicator in the bar does the same; that's handled
-- in quickshell/modules/bar/Workspaces.qml.) -- in quickshell/modules/bar/Workspaces.qml.)
hl.bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" })) bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" })) bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
-- Minimise, as far as Hyprland has one. -- Minimise, as far as Hyprland has one.
-- --
@@ -202,42 +240,42 @@ hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }))
-- --
-- The scratchpad is the honest equivalent: the window goes away, and the same -- The scratchpad is the honest equivalent: the window goes away, and the same
-- key brings it back. Bound to X to match the muscle memory it replaces. -- key brings it back. Bound to X to match the muscle memory it replaces.
hl.bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimised)" }) bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimised)" })
hl.bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimise to scratchpad" }) bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimise to scratchpad" })
-- ── Session ───────────────────────────────────────────────────────────────── -- ── Session ─────────────────────────────────────────────────────────────────
-- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to -- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to
-- CTRL+ALT+L -- the other binding most people already have in muscle memory. -- CTRL+ALT+L -- the other binding most people already have in muscle memory.
hl.bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" }) bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
hl.bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { description = "Power menu" }) bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { description = "Power menu" })
-- ── Media and volume ──────────────────────────────────────────────────────── -- ── Media and volume ────────────────────────────────────────────────────────
-- locked = true keeps these working on the lock screen, as they do in GNOME. -- locked = true keeps these working on the lock screen, as they do in GNOME.
-- 6% steps match the GNOME volume-step setting. -- 6% steps match the GNOME volume-step setting.
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 6%+"), { locked = true, repeating = true }) bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 6%+"), { locked = true, repeating = true , description = "Volume up" })
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 6%-"), { locked = true, repeating = true }) bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 6%-"), { locked = true, repeating = true , description = "Volume down" })
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true }) bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true , description = "Mute" })
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true }) bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true , description = "Mute microphone" })
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers. -- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
hl.bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 1%+"), { locked = true, repeating = true }) bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 1%+"), { locked = true, repeating = true , description = "Volume up (fine)" })
hl.bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-"), { locked = true, repeating = true }) bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-"), { locked = true, repeating = true , description = "Volume down (fine)" })
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true }) bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" })
hl.bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true }) bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" })
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true }) bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true , description = "Next track" })
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true }) bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true , description = "Previous track" })
hl.bind("XF86AudioStop", hl.dsp.exec_cmd("playerctl stop"), { locked = true }) bind("XF86AudioStop", hl.dsp.exec_cmd("playerctl stop"), { locked = true , description = "Stop playback" })
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true }) bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true , description = "Brightness up" })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true }) bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true , description = "Brightness down" })
-- Hardware keys GNOME mapped that have obvious equivalents. -- Hardware keys GNOME mapped that have obvious equivalents.
hl.bind("XF86Tools", hl.dsp.exec_cmd(settings)) bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
hl.bind("XF86Calculator", hl.dsp.exec_cmd(calculator)) bind("XF86Calculator", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
hl.bind("XF86Explorer", hl.dsp.exec_cmd(files)) bind("XF86Explorer", hl.dsp.exec_cmd(files), { description = "Files" })
hl.bind("XF86WWW", hl.dsp.exec_cmd(browser)) bind("XF86WWW", hl.dsp.exec_cmd(browser), { description = "Browser" })
hl.bind("XF86Mail", hl.dsp.exec_cmd(mail)) bind("XF86Mail", hl.dsp.exec_cmd(mail), { description = "Mail" })
hl.bind("XF86Search", hl.dsp.exec_cmd(launcher)) bind("XF86Search", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
return true return true
@@ -79,6 +79,14 @@ Singleton {
persistTimer.restart(); persistTimer.restart();
} }
// Re-read the file from disk. Used after something outside the shell has
// rewritten it -- restoring a snapshot, or a hand edit -- so the running
// desktop reflects the new contents without waiting for the next change.
function reload(): void {
preferencesFile.reload();
root.load();
}
function load(): void { function load(): void {
let parsed = {}; let parsed = {};
try { try {
@@ -82,6 +82,14 @@ Singleton {
values.initialized = true; values.initialized = true;
} }
function resetHomeDefaults(): void {
persistTimer.stop();
values.favorites = [];
values.initialized = false;
root.saveError = "";
preferencesFile.writeAdapter();
}
function isSelected(entityId: string): bool { function isSelected(entityId: string): bool {
for (var index = 0; index < values.favorites.length; index++) { for (var index = 0; index < values.favorites.length; index++) {
if (values.favorites[index].id === entityId) { if (values.favorites[index].id === entityId) {
@@ -15,7 +15,7 @@ pragma Singleton
// //
// Entry fields // Entry fields
// key unique identifier; also the JSON key on disk // key unique identifier; also the JSON key on disk
// type "bool" | "int" | "real" | "string" | "enum" // type "bool" | "int" | "real" | "string" | "enum" | "json"
// def shipped default, used when the file is absent or a value is invalid // def shipped default, used when the file is absent or a value is invalid
// min/max inclusive bounds for int and real; values outside are clamped // min/max inclusive bounds for int and real; values outside are clamped
// step UI increment for int and real // step UI increment for int and real
@@ -25,6 +25,13 @@ pragma Singleton
// detail one line explaining what changing it does // detail one line explaining what changing it does
// internal true for state the shell keeps but the user never edits directly // internal true for state the shell keeps but the user never edits directly
// pattern for "string": a regular expression the value must match in full // pattern for "string": a regular expression the value must match in full
//
// "json" holds a structured value -- a list or an object -- that the schema
// stores and resets but does not validate field by field. It exists so that
// settings like the dock's pinned applications live in the same file, and are
// covered by the same reset, as everything else rather than growing a fourth
// preference store. The service that owns such a value is responsible for
// validating it; see services/Dock-related consumers.
// hypr present when the setting maps onto an Hyprland option: // hypr present when the setting maps onto an Hyprland option:
// path the hl.config table path, e.g. ["decoration","blur","size"] // path the hl.config table path, e.g. ["decoration","blur","size"]
// option the getoption path used to read the value back // option the getoption path used to read the value back
@@ -86,12 +93,14 @@ Singleton {
}, },
{ {
key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25, key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25,
unit: "ms",
group: "dock", group: "dock",
label: "Reveal delay", label: "Reveal delay",
detail: "Zero reveals the Dock the instant the pointer reaches the edge" detail: "Zero reveals the Dock the instant the pointer reaches the edge"
}, },
{ {
key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25, key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25,
unit: "ms",
group: "dock", group: "dock",
label: "Hide delay", label: "Hide delay",
detail: "Prevents flicker when crossing between icons" detail: "Prevents flicker when crossing between icons"
@@ -100,6 +109,7 @@ Singleton {
// ── Focus ─────────────────────────────────────────────────────────── // ── Focus ───────────────────────────────────────────────────────────
{ {
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5, key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
unit: "min",
group: "focus", group: "focus",
label: "Focus session length", label: "Focus session length",
detail: "How long a focus session runs before it ends itself" detail: "How long a focus session runs before it ends itself"
@@ -143,6 +153,7 @@ Singleton {
// the Hyprland config still stands on its own. // the Hyprland config still stands on its own.
{ {
key: "gapsIn", type: "int", def: 5, min: 0, max: 40, step: 1, key: "gapsIn", type: "int", def: 5, min: 0, max: 40, step: 1,
unit: "px",
group: "windows", group: "windows",
label: "Inner gaps", label: "Inner gaps",
detail: "Space between neighbouring tiled windows", detail: "Space between neighbouring tiled windows",
@@ -150,6 +161,7 @@ Singleton {
}, },
{ {
key: "gapsOut", type: "int", def: 10, min: 0, max: 80, step: 1, key: "gapsOut", type: "int", def: 10, min: 0, max: 80, step: 1,
unit: "px",
group: "windows", group: "windows",
label: "Outer gaps", label: "Outer gaps",
detail: "Space between the tiled area and the screen edge", detail: "Space between the tiled area and the screen edge",
@@ -157,6 +169,7 @@ Singleton {
}, },
{ {
key: "borderSize", type: "int", def: 2, min: 0, max: 10, step: 1, key: "borderSize", type: "int", def: 2, min: 0, max: 10, step: 1,
unit: "px",
group: "windows", group: "windows",
label: "Border width", label: "Border width",
detail: "Thickness of the gradient border on the focused window", detail: "Thickness of the gradient border on the focused window",
@@ -164,6 +177,7 @@ Singleton {
}, },
{ {
key: "windowRounding", type: "int", def: 18, min: 0, max: 40, step: 1, key: "windowRounding", type: "int", def: 18, min: 0, max: 40, step: 1,
unit: "px",
group: "windows", group: "windows",
label: "Corner radius", label: "Corner radius",
detail: "Matches the shell's popover radius so windows and panels agree", detail: "Matches the shell's popover radius so windows and panels agree",
@@ -206,6 +220,7 @@ Singleton {
}, },
{ {
key: "shadowRange", type: "int", def: 20, min: 0, max: 60, step: 1, key: "shadowRange", type: "int", def: 20, min: 0, max: 60, step: 1,
unit: "px",
group: "effects", group: "effects",
label: "Shadow size", label: "Shadow size",
detail: "How far the shadow spreads from the window edge", detail: "How far the shadow spreads from the window edge",
@@ -219,6 +234,7 @@ Singleton {
}, },
{ {
key: "glowRange", type: "int", def: 8, min: 0, max: 30, step: 1, key: "glowRange", type: "int", def: 8, min: 0, max: 30, step: 1,
unit: "px",
group: "effects", group: "effects",
label: "Glow size", label: "Glow size",
detail: "Kept small deliberately: the gradient border is the signature", detail: "Kept small deliberately: the gradient border is the signature",
@@ -249,6 +265,7 @@ Singleton {
}, },
{ {
key: "keyRepeatDelay", type: "int", def: 500, min: 150, max: 1000, step: 25, key: "keyRepeatDelay", type: "int", def: 500, min: 150, max: 1000, step: 25,
unit: "ms",
group: "input", group: "input",
label: "Repeat delay", label: "Repeat delay",
detail: "How long a key is held before it starts repeating", detail: "How long a key is held before it starts repeating",
@@ -256,6 +273,7 @@ Singleton {
}, },
{ {
key: "keyRepeatRate", type: "int", def: 33, min: 5, max: 100, step: 1, key: "keyRepeatRate", type: "int", def: 33, min: 5, max: 100, step: 1,
unit: "/s",
group: "input", group: "input",
label: "Repeat rate", label: "Repeat rate",
detail: "How many characters a second a held key produces", detail: "How many characters a second a held key produces",
@@ -281,10 +299,15 @@ Singleton {
}, },
{ {
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1, key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
unit: "s",
group: "input", group: "input",
label: "Hide pointer after", label: "Hide pointer after",
detail: "Seconds of stillness before the pointer fades out; 0 never hides it", detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "int" } // Reported as a float even though it is only ever set to whole
// seconds. `readAs` describes what getoption answers with, not what
// the setting means -- getting this wrong makes every write to it
// look rejected.
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
}, },
// ── Night light ───────────────────────────────────────────────────── // ── Night light ─────────────────────────────────────────────────────
@@ -300,11 +323,191 @@ Singleton {
}, },
{ {
key: "nightLightTemperature", type: "int", def: 3500, min: 2000, max: 6500, step: 100, key: "nightLightTemperature", type: "int", def: 3500, min: 2000, max: 6500, step: 100,
unit: "K",
group: "nightLight", group: "nightLight",
label: "Color temperature", label: "Color temperature",
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"
},
// ── Weather ─────────────────────────────────────────────────────────
{
key: "temperatureUnit", type: "enum", def: "fahrenheit", group: "weather",
label: "Temperature unit",
detail: "Choose Fahrenheit or Celsius for the weather card",
options: [
{ value: "fahrenheit", label: "Fahrenheit" },
{ value: "celsius", label: "Celsius" }
]
},
{
key: "weatherRefreshMinutes", type: "int", def: 20, min: 5, max: 120, step: 5,
unit: "min", group: "weather",
label: "Weather refresh",
detail: "How often Panama updates the current conditions"
},
// ── Vitals refresh ──────────────────────────────────────────────────
{
key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500,
unit: "ms", group: "vitals",
label: "Vitals refresh",
detail: "How often processor, memory, and graphics usage update"
},
// ── Notifications ───────────────────────────────────────────────────
{
key: "notificationTimeoutMs", type: "int", def: 5000, min: 1000, max: 30000, step: 500,
unit: "ms", group: "notifications",
label: "Notification duration",
detail: "How long ordinary notification banners remain visible"
},
{
key: "notificationTimeoutCriticalMs", type: "int", def: 0, min: 0, max: 60000, step: 1000,
unit: "ms", group: "notifications",
label: "Critical notification duration",
detail: "Zero keeps critical notification banners visible until dismissed"
},
{
key: "notificationHistoryLimit", type: "int", def: 100, min: 10, max: 500, step: 10,
group: "notifications",
label: "Notification history",
detail: "Maximum notifications retained in the notification center"
},
{
key: "maxVisibleToasts", type: "int", def: 4, min: 1, max: 8, step: 1,
group: "notifications",
label: "Visible banners",
detail: "Maximum notification banners shown at once"
},
// ── Capture ─────────────────────────────────────────────────────────
// Directories and encoder arguments are enums rather than free text:
// both are handed to a recorder process, and an arbitrary string there
// is a much larger surface than a settings page needs to expose.
{
key: "screenshotDir", type: "enum", def: "Pictures/Screenshots", group: "capture",
label: "Screenshot folder",
detail: "Folder under your home directory for screenshots",
options: [
{ value: "Pictures/Screenshots", label: "Pictures / Screenshots" },
{ value: "Pictures", label: "Pictures" },
{ value: "Desktop", label: "Desktop" }
]
},
{
key: "recordingDir", type: "enum", def: "Videos/Recordings", group: "capture",
label: "Recording folder",
detail: "Folder under your home directory for screen recordings",
options: [
{ value: "Videos/Recordings", label: "Videos / Recordings" },
{ value: "Videos", label: "Videos" },
{ value: "Desktop", label: "Desktop" }
]
},
{
key: "recorderArgs", type: "enum", def: "-c h264_vaapi -d /dev/dri/renderD128",
group: "capture",
label: "Recording encoder",
detail: "Hardware encoding keeps recording off the processor while gaming",
options: [
{ value: "-c h264_vaapi -d /dev/dri/renderD128", label: "VAAPI H.264" },
{ value: "-c hevc_vaapi -d /dev/dri/renderD128", label: "VAAPI HEVC" },
{ value: "-c libx264", label: "CPU x264" }
]
},
// ── Dock contents ───────────────────────────────────────────────────
// A "json" value: the ordered list of desktop entry ids pinned to the
// dock. Kept in the shared store so that reordering the dock is covered
// by Restore defaults like everything else, rather than living in its
// own file. The shipped order is the GNOME dash it replaced.
{
key: "dockPinned", type: "json", group: "dock",
label: "Pinned applications",
detail: "Applications that stay in the Dock whether or not they are running",
def: [
"org.gnome.Settings", "kitty", "org.gnome.Nautilus",
"com.bitwarden.desktop", "org.gnome.Software", "helium",
"org.mozilla.thunderbird_esr", "com.slack.Slack",
"app.bluebubbles.BlueBubbles", "rustdesk",
"io.podman_desktop.PodmanDesktop", "claude-desktop",
"codex-desktop", "md.obsidian.Obsidian",
"com.obsproject.Studio", "steam"
]
},
// ── Keyboard shortcut overrides ─────────────────────────────────────
// { "<bind description>": "<chord>" }. Only the chord is stored: the
// action always comes from hypr/keybinds.lua, so an override can move a
// shortcut but can never make one do something else. The Lua validates
// each chord and falls back to the shipped one, so a hand-edited file
// cannot cost you a keymap.
//
// Edited through the Input & Shortcuts page rather than as a row, hence
// internal.
{
key: "keybindOverrides", type: "json", def: ({}), group: "input",
internal: true,
label: "Keyboard shortcut overrides",
detail: "Shortcuts you have moved from their shipped chord"
},
// ── Internal ──────────────────────────────────────────────────────── // ── Internal ────────────────────────────────────────────────────────
{ {
key: "lastPage", type: "string", def: "home", group: "internal", key: "lastPage", type: "string", def: "home", group: "internal",
@@ -382,6 +585,13 @@ Singleton {
case "enum": case "enum":
return entry.options.some(option => option.value === value) ? value : undefined; return entry.options.some(option => option.value === value) ? value : undefined;
case "json":
// Accepted as-is. Anything JSON.parse produced is representable,
// and per-field meaning belongs to the owning service rather than
// here. A scalar is rejected so a corrupt file falls back to the
// default instead of handing a list-shaped consumer a number.
return (typeof value === "object") ? value : undefined;
case "string": { case "string": {
const text = typeof value === "string" ? value : String(value); const text = typeof value === "string" ? value : String(value);
// A constrained string is rejected rather than sanitised. Several // A constrained string is rejected rather than sanitised. Several
+11 -28
View File
@@ -28,13 +28,13 @@ Singleton {
// label deliberately general rather than exposing precise coordinates in // label deliberately general rather than exposing precise coordinates in
// the UI or guessing at a city from them. // the UI or guessing at a city from them.
readonly property string weatherLocation: "Local weather" readonly property string weatherLocation: "Local weather"
readonly property string temperatureUnit: "fahrenheit" readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit")
readonly property int weatherRefreshMinutes: 20 readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes")
// ── Vitals ────────────────────────────────────────────────────────────── // ── Vitals ──────────────────────────────────────────────────────────────
// The GNOME Vitals extension showed processor usage, memory usage and GPU // The GNOME Vitals extension showed processor usage, memory usage and GPU
// usage, in that order. Same here. // usage, in that order. Same here.
readonly property int vitalsIntervalMs: 2000 readonly property int vitalsIntervalMs: DesktopPreferences.get("vitalsIntervalMs")
readonly property bool showCpu: DesktopPreferences.get("showCpu") readonly property bool showCpu: DesktopPreferences.get("showCpu")
readonly property bool showMemory: DesktopPreferences.get("showMemory") readonly property bool showMemory: DesktopPreferences.get("showMemory")
readonly property bool showGpu: DesktopPreferences.get("showGpu") readonly property bool showGpu: DesktopPreferences.get("showGpu")
@@ -51,10 +51,10 @@ Singleton {
readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled") readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled")
// ── Notifications ─────────────────────────────────────────────────────── // ── Notifications ───────────────────────────────────────────────────────
readonly property int notificationTimeoutMs: 5000 readonly property int notificationTimeoutMs: DesktopPreferences.get("notificationTimeoutMs")
readonly property int notificationTimeoutCriticalMs: 0 // 0 = never auto-expire readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire
readonly property int notificationHistoryLimit: 100 readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
readonly property int maxVisibleToasts: 4 readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
// ── Focus ────────────────────────────────────────────────────────────── // ── Focus ──────────────────────────────────────────────────────────────
// One deliberate default rather than a preset picker: quick settings and // One deliberate default rather than a preset picker: quick settings and
@@ -63,24 +63,7 @@ Singleton {
// ── Dock ──────────────────────────────────────────────────────────────── // ── Dock ────────────────────────────────────────────────────────────────
// Pinned apps, in order, taken from the GNOME dash favourites. // Pinned apps, in order, taken from the GNOME dash favourites.
readonly property list<string> dockPinned: [ readonly property var dockPinned: DesktopPreferences.get("dockPinned")
"org.gnome.Settings",
"kitty",
"org.gnome.Nautilus",
"com.bitwarden.desktop",
"org.gnome.Software",
"helium",
"org.mozilla.thunderbird_esr",
"com.slack.Slack",
"app.bluebubbles.BlueBubbles",
"rustdesk",
"io.podman_desktop.PodmanDesktop",
"claude-desktop",
"codex-desktop",
"md.obsidian.Obsidian",
"com.obsproject.Studio",
"steam"
]
// Dash-to-Dock was set to intellihide against all windows: the dock hides // Dash-to-Dock was set to intellihide against all windows: the dock hides
// when any window would overlap it, and comes back on hover. // when any window would overlap it, and comes back on hover.
@@ -96,9 +79,9 @@ Singleton {
readonly property int dockHideDelayMs: DesktopPreferences.get("dockHideDelayMs") readonly property int dockHideDelayMs: DesktopPreferences.get("dockHideDelayMs")
// ── Capture ───────────────────────────────────────────────────────────── // ── Capture ─────────────────────────────────────────────────────────────
readonly property string screenshotDir: "Pictures/Screenshots" readonly property string screenshotDir: DesktopPreferences.get("screenshotDir")
readonly property string recordingDir: "Videos/Recordings" readonly property string recordingDir: DesktopPreferences.get("recordingDir")
// Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not // Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not
// cost CPU while gaming. // cost CPU while gaming.
readonly property string recorderArgs: "-c h264_vaapi -d /dev/dri/renderD128" readonly property string recorderArgs: DesktopPreferences.get("recorderArgs")
} }
@@ -13,6 +13,7 @@ ShellRoot {
function alias(id: string, value: string): void { HomePreferences.setAlias(id, value); } function alias(id: string, value: string): void { HomePreferences.setAlias(id, value); }
function move(id: string, index: int): void { HomePreferences.move(id, index); } function move(id: string, index: int): void { HomePreferences.move(id, index); }
function remove(id: string): void { HomePreferences.remove(id); } function remove(id: string): void { HomePreferences.remove(id); }
function reset(): void { HomePreferences.resetHomeDefaults(); }
function status(): string { function status(): string {
return JSON.stringify({ return JSON.stringify({
initialized: HomePreferences.initialized, initialized: HomePreferences.initialized,
@@ -0,0 +1,58 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import qs.services
ShellRoot {
IpcHandler {
target: "keybinds-test"
function rebind(current: string, next: string): bool {
return Keybinds.rebind(current, next);
}
function resetBind(current: string): bool { return Keybinds.resetBind(current); }
function resetAll(): void { Keybinds.resetAll(); }
function seedResetCollision(shipped: string, current: string, occupantShipped: string): void {
const overrides = {};
overrides[shipped] = current;
overrides[occupantShipped] = shipped;
DesktopPreferences.set("keybindOverrides", overrides);
}
function chordFor(description: string): string {
const found = Keybinds.binds.find(bind => bind.description === description);
return found ? found.luaChord : "";
}
function overrideState(): string {
return JSON.stringify({
overrides: Keybinds.overrides,
count: Object.keys(Keybinds.overrides).length,
lastError: Keybinds.lastError
});
}
function status(): string {
const grouped = Keybinds.grouped();
let groupedCount = 0;
for (const group of grouped)
groupedCount += group.binds.length;
const superT = Keybinds.binds.find(bind => bind.description === "Terminal");
return JSON.stringify({
loaded: Keybinds.loaded,
count: Keybinds.binds.length,
groupedCount: groupedCount,
groups: grouped.map(group => group.name),
sample: superT ? superT.chord : "",
emptyDescriptions: Keybinds.binds.filter(bind => !bind.description).length,
lastError: Keybinds.lastError
});
}
}
}
@@ -3,39 +3,49 @@ import Quickshell
import qs.config import qs.config
import qs.services import qs.services
Item { SettingsPage {
Flickable { title: "About Panama"
anchors.fill: parent lede: "A curated Hyprland desktop built around focus, speed, and good taste."
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { SettingsCard {
id: content title: "Panama Desktop"
width: parent.width - 68 subtitle: "Tokyo Night Moon · Prism glass · native tiling"
x: 34
y: 30
spacing: 16
Text { text: "About Panama"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } TextRow {
Text { text: "A curated Hyprland desktop built around focus, speed, and good taste."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } label: "Hyprland"
value: SystemSettings.hyprlandVersion || "Detecting…"
}
TextRow {
label: "Quickshell"
value: SystemSettings.quickshellVersion
}
TextRow {
label: "Display"
value: SystemSettings.monitorName || "Detecting…"
}
TextRow {
label: "Configuration"
detail: Quickshell.shellDir
value: "Local"
divider: false
}
}
SettingsCard { SettingsCard {
title: "Panama Desktop" title: "Design principles"
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
SettingRow { label: "Hyprland"; value: SystemSettings.hyprlandVersion || "Detecting…" }
SettingRow { label: "Quickshell"; value: SystemSettings.quickshellVersion }
SettingRow { label: "Display"; value: SystemSettings.monitorName || "Detecting…" }
SettingRow { label: "Configuration"; detail: Quickshell.shellDir; value: "Local"; divider: false }
}
SettingsCard { TextRow {
title: "Design principles" label: "Curated by default"
SettingRow { label: "Curated by default"; detail: "Strong choices instead of an incoherent matrix of switches" } detail: "Strong choices instead of an incoherent matrix of switches"
SettingRow { label: "Quiet while idle"; detail: "No continuous decorative repaint loops" } }
SettingRow { label: "Real system boundaries"; detail: "Every control either works or clearly hands off to its owner"; divider: false } TextRow {
} label: "Quiet while idle"
detail: "No continuous decorative repaint loops"
}
TextRow {
label: "Real system boundaries"
detail: "Every control either works or clearly hands off to its owner"
divider: false
} }
} }
} }
@@ -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
}
}
@@ -0,0 +1,34 @@
// A row whose trailing control is a button rather than a setting.
//
// ActionRow {
// label: "Keyboard"
// detail: "Use Fedora's hardware-backed input panels"
// action: "Open keyboard"
// onTriggered: SystemSettings.openGnomePanel("keyboard")
// }
//
// Not schema-bound: these hand off to GNOME, launch an application, or run a
// one-shot like restoring defaults. There is no key to name.
import QtQuick
import qs.config
SettingRow {
id: root
property string action: ""
property bool enabled: true
signal triggered
controlWidth: Math.max(110, button.implicitWidth + 8)
SettingsButton {
id: button
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.action
enabled: root.enabled
onClicked: root.triggered()
}
}
@@ -1,61 +1,116 @@
// Appearance — the page the rest of the settings vocabulary was built for.
//
// Before Stage 3 this page adjusted a clock format and three vitals toggles,
// and its "Theme" card was two rows of text pretending to be controls. It now
// drives the compositor directly: every row here is applied through
// `hyprctl eval` and confirmed by reading the value back before it is stored.
//
// The preview is pinned above the controls rather than sitting inline, because
// the numbers are meaningless on their own -- "outer gaps 24" only means
// something once you have watched the windows move apart by that much, at the
// scale of the display you actually use.
import QtQuick import QtQuick
import qs.config import qs.config
import qs.services
Item { SettingsPage {
Flickable { id: root
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
title: "Appearance"
lede: "Drag anything below. The preview above is your real geometry, to scale."
header: Component {
Column { Column {
id: content spacing: 9
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text { text: "Appearance"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } DesktopPreview {
Text { text: "Tokyo Night Moon, tuned for clarity and quiet motion."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } width: parent.width
SettingsCard {
title: "Theme"
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes."
SettingRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
SettingRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
} }
SettingsCard { Text {
title: "Clock" width: parent.width
SettingRow { horizontalAlignment: Text.AlignHCenter
label: "24-hour time" text: SystemSettings.lastError !== ""
detail: "Use 18:30 instead of 6:30 PM" ? SystemSettings.lastError
controlWidth: 48 : "Live preview — the focused window wears the prism border"
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("use24Hour"); onToggled: value => DesktopPreferences.set("use24Hour", value) } color: SystemSettings.lastError !== "" ? Theme.warn : Theme.fgMuted
} font.family: Theme.fontFamily
SettingRow { font.pixelSize: Theme.fontSizeSmall
label: "Show seconds" elide: Text.ElideRight
detail: "Keep a precise clock in the center of the bar"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showSeconds"); onToggled: value => DesktopPreferences.set("showSeconds", value) }
}
SettingRow {
label: "Show weekday"
detail: "Include the abbreviated weekday before the date"
divider: false
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showWeekday"); onToggled: value => DesktopPreferences.set("showWeekday", value) }
}
}
SettingsCard {
title: "System vitals"
subtitle: "Choose what appears beside the workspace indicator."
SettingRow { label: "Processor"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showCpu"); onToggled: value => DesktopPreferences.set("showCpu", value) } }
SettingRow { label: "Memory"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showMemory"); onToggled: value => DesktopPreferences.set("showMemory", value) } }
SettingRow { label: "Graphics"; divider: false; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showGpu"); onToggled: value => DesktopPreferences.set("showGpu", value) } }
} }
} }
} }
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."
SliderRow { setting: "windowRounding" }
SliderRow { setting: "gapsIn" }
SliderRow { setting: "gapsOut" }
SliderRow { setting: "borderSize"; zeroLabel: "None" }
SliderRow { setting: "inactiveOpacity"; divider: false }
}
SettingsCard {
title: "Effects"
subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming."
ToggleRow { setting: "blurEnabled" }
SliderRow { setting: "blurSize" }
SliderRow { setting: "blurPasses" }
ToggleRow { setting: "shadowEnabled" }
SliderRow { setting: "shadowRange"; zeroLabel: "None" }
ToggleRow { setting: "glowEnabled" }
SliderRow { setting: "glowRange"; zeroLabel: "None" }
ToggleRow { setting: "animationsEnabled"; divider: false }
}
SettingsCard {
title: "Clock"
ToggleRow { setting: "use24Hour" }
ToggleRow { setting: "showSeconds" }
ToggleRow { setting: "showWeekday"; divider: false }
}
SettingsCard {
title: "System vitals"
subtitle: "Choose what appears beside the workspace indicator."
ToggleRow { setting: "showCpu" }
ToggleRow { setting: "showMemory" }
ToggleRow { setting: "showGpu"; divider: false }
}
SettingsCard {
title: "Theme"
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
}
} }
@@ -0,0 +1,217 @@
// Applications and session startup.
import Quickshell
import QtQuick
import qs.services
SettingsPage {
id: root
objectName: "applications"
title: "Applications"
lede: "Choose what opens your files and links, and what starts with your session."
property string expandedRole: ""
readonly property var applications: DesktopEntries.applications.values
readonly property var roles: [
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
{ key: "mail", label: "Mail", detail: "Email links", categorySets: [["email"]], terms: ["mail client", "email client"] },
{ key: "files", label: "Files", detail: "Folders and file locations", categorySets: [["filemanager"]], terms: ["file manager"] },
{ key: "terminal", label: "Terminal", detail: "Terminal links and command-line handoffs", categorySets: [["terminalemulator"]], terms: ["terminal emulator", "terminal"] },
{ key: "music", label: "Music", detail: "MP3 audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] },
{ key: "images", label: "Images", detail: "PNG images", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] },
{ key: "video", label: "Video", detail: "MP4 video", categorySets: [["video"]], terms: ["video player", "movie player"] }
]
function desktopId(entry: var): string {
const entryId = String(entry?.id ?? "");
return entryId.endsWith(".desktop") ? entryId : entryId + ".desktop";
}
function displayName(entry: var): string {
return String(entry?.name || entry?.genericName || root.desktopId(entry));
}
function currentHandler(role: string): string {
return String(DefaultApps.handlers[role] ?? "");
}
function currentEntry(role: string): var {
const handler = root.currentHandler(role);
return root.applications.find(entry => root.desktopId(entry) === handler) ?? null;
}
function matchesRole(entry: var, role: var): bool {
const rawCategories = Array.isArray(entry.categories)
? entry.categories
: [String(entry.categories ?? "")];
const categories = [];
for (const rawCategory of rawCategories) {
for (const value of String(rawCategory).split(";")) {
const category = value.trim().toLowerCase();
if (category !== "")
categories.push(category);
}
}
const metadata = [entry.name, entry.genericName]
.map(value => String(value ?? "").toLowerCase())
.join(" ");
return role.categorySets.some(set => set.every(category => categories.includes(category)))
|| role.terms.some(term => metadata.includes(term));
}
function choicesForRole(role: var): var {
const choices = root.applications.filter(entry => root.matchesRole(entry, role));
const currentEntry = root.currentEntry(role.key);
if (currentEntry && !choices.some(entry => root.desktopId(entry) === root.desktopId(currentEntry)))
choices.push(currentEntry);
return choices.sort((left, right) => root.displayName(left).localeCompare(root.displayName(right)));
}
TextRow {
visible: DefaultApps.lastError !== ""
label: "Application settings need attention"
detail: DefaultApps.lastError
value: ""
divider: false
}
SettingsCard {
title: "Default applications"
subtitle: "Open a row to choose from applications that advertise the matching role."
Repeater {
model: root.roles
delegate: Column {
id: roleBlock
required property var modelData
required property int index
readonly property var choices: root.choicesForRole(roleBlock.modelData)
readonly property var selectedEntry: root.currentEntry(roleBlock.modelData.key)
width: parent.width
SettingRow {
label: roleBlock.modelData.label
detail: roleBlock.modelData.detail
value: DefaultApps.busy ? "Loading…" : (
roleBlock.selectedEntry
? root.displayName(roleBlock.selectedEntry)
: (root.currentHandler(roleBlock.modelData.key) || "Not set")
)
activatable: roleBlock.choices.length > 0 && !DefaultApps.busy
divider: root.expandedRole !== roleBlock.modelData.key && roleBlock.index < root.roles.length - 1
onActivated: {
root.expandedRole = root.expandedRole === roleBlock.modelData.key
? ""
: roleBlock.modelData.key;
}
}
Column {
width: parent.width
visible: root.expandedRole === roleBlock.modelData.key
Repeater {
model: roleBlock.choices
delegate: SettingRow {
id: candidateRow
required property var modelData
required property int index
readonly property string candidateId: root.desktopId(candidateRow.modelData)
readonly property bool selected: candidateRow.candidateId === root.currentHandler(roleBlock.modelData.key)
label: root.displayName(candidateRow.modelData)
detail: String(candidateRow.modelData.genericName || candidateRow.modelData.comment || candidateRow.candidateId)
value: candidateRow.selected ? "Current" : ""
activatable: !candidateRow.selected && !DefaultApps.busy
divider: candidateRow.index < roleBlock.choices.length - 1 || roleBlock.index < root.roles.length - 1
onActivated: {
DefaultApps.setDefault(roleBlock.modelData.key, candidateRow.candidateId);
root.expandedRole = "";
}
}
}
}
}
}
}
SettingsCard {
title: "User autostart"
subtitle: "These desktop entries live in your user configuration. Select a row to toggle it."
TextRow {
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
label: "No user autostart entries"
detail: "Applications can add entries to ~/.config/autostart."
value: ""
divider: false
}
Repeater {
model: DefaultApps.autostartEntries
delegate: SettingRow {
id: autostartRow
required property var modelData
required property int index
label: autostartRow.modelData.name
detail: autostartRow.modelData.id
value: autostartRow.modelData.enabled ? "Enabled" : "Disabled"
activatable: !DefaultApps.busy
divider: autostartRow.index < DefaultApps.autostartEntries.length - 1
onActivated: DefaultApps.setAutostart(autostartRow.modelData.id, !autostartRow.modelData.enabled)
}
}
}
SettingsCard {
title: "Compositor autostart"
subtitle: "Panama starts these from Hyprland configuration. They are read-only here."
TextRow {
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
label: "No compositor entries found"
detail: "No hl.exec_cmd entries were found in config/dot/hypr/autostart.lua."
value: ""
divider: false
}
Repeater {
model: DefaultApps.luaAutostartEntries
delegate: TextRow {
id: luaRow
required property var modelData
required property int index
label: luaRow.modelData.name
detail: luaRow.modelData.command
value: "Hyprland"
divider: luaRow.index < DefaultApps.luaAutostartEntries.length - 1
}
}
}
SettingsCard {
title: "Refresh"
ActionRow {
label: "Reload application settings"
detail: "Re-read desktop entries, defaults, and user autostart files"
action: DefaultApps.busy ? "Refreshing…" : "Refresh"
enabled: !DefaultApps.busy
divider: false
onTriggered: DefaultApps.refresh()
}
}
}
@@ -0,0 +1,98 @@
// An enum setting, bound to a schema key by name.
//
// ChoiceRow { setting: "vrrPolicy" }
//
// The options are the schema's, so a row can never offer a value the store
// would reject. Rendered as a segmented control rather than a dropdown: every
// choice here has two or three options, and showing them all is both faster to
// use and self-documenting.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
required property string setting
readonly property var spec: PreferenceSchema.spec(root.setting)
readonly property var options: root.spec && root.spec.options ? root.spec.options : []
readonly property var current: DesktopPreferences.get(root.setting)
label: root.spec ? root.spec.label : root.setting
detail: root.spec ? root.spec.detail : ""
controlWidth: Math.max(120, root.options.length * 92)
Rectangle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
implicitWidth: segments.implicitWidth + 4
implicitHeight: 30
radius: 9
color: Theme.alpha(Theme.fg, 0.07)
border.width: 0
Row {
id: segments
anchors.centerIn: parent
spacing: 0
Repeater {
model: root.options
Rectangle {
id: segment
required property var modelData
readonly property bool selected: root.current === segment.modelData.value
implicitWidth: Math.max(70, caption.implicitWidth + 24)
implicitHeight: 26
radius: 7
border.width: 0
color: "transparent"
// The selected segment is the only place the prism appears
// in a row: blue leads into orchid, never orchid alone.
Rectangle {
anchors.fill: parent
radius: parent.radius
visible: segment.selected
border.width: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.30) }
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.30) }
}
}
Rectangle {
anchors.fill: parent
radius: parent.radius
border.width: 0
visible: !segment.selected && hover.hovered
color: Theme.alpha(Theme.fg, 0.06)
}
Text {
id: caption
anchors.centerIn: parent
text: segment.modelData.label
color: segment.selected ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: segment.selected ? Font.DemiBold : Font.Normal
}
HoverHandler { id: hover }
TapHandler {
onTapped: SystemSettings.commitPreference(root.setting, segment.modelData.value)
}
}
}
}
}
}
@@ -5,9 +5,12 @@ import qs.config
import qs.services import qs.services
import qs.modules.quicksettings import qs.modules.quicksettings
Item { SettingsPage {
id: root id: root
title: "Network & Devices"
lede: "Connect graphically—no terminal workflow required."
readonly property var wifiDevice: { readonly property var wifiDevice: {
for (const device of Networking.devices.values) { for (const device of Networking.devices.values) {
if (device.type === DeviceType.Wifi) if (device.type === DeviceType.Wifi)
@@ -17,72 +20,73 @@ Item {
} }
readonly property var bluetoothAdapter: Bluetooth.defaultAdapter readonly property var bluetoothAdapter: Bluetooth.defaultAdapter
Flickable { SettingsCard {
anchors.fill: parent title: "Wi‑Fi"
clip: true subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off"
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { SettingRow {
id: content label: "Wi‑Fi"
width: parent.width - 68 detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found"
x: 34 controlWidth: 48
y: 30
spacing: 16
Text { text: "Network & Devices"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } SettingsToggle {
Text { text: "Connect graphically—no terminal workflow required."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
SettingsCard { checked: Networking.wifiEnabled
title: "Wi‑Fi" enabled: Networking.wifiHardwareEnabled
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off" onToggled: value => Networking.wifiEnabled = value
SettingRow {
label: "Wi‑Fi"
detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Networking.wifiEnabled
enabled: Networking.wifiHardwareEnabled
onToggled: value => Networking.wifiEnabled = value
}
}
WifiList { width: parent.width; device: root.wifiDevice; active: true; maxHeight: 240 }
SettingRow {
label: "Advanced network settings"
detail: "VPN, wired profiles, DNS, and connection details"
divider: false
controlWidth: 104
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("network") }
}
} }
}
SettingsCard { WifiList {
title: "Bluetooth" width: parent.width
subtitle: root.bluetoothAdapter?.enabled ? "Nearby and paired devices" : "Bluetooth is off" device: root.wifiDevice
SettingRow { active: true
label: "Bluetooth" maxHeight: 240
detail: root.bluetoothAdapter ? "Pair and reconnect without leaving Settings" : "No Bluetooth adapter found" }
controlWidth: 48
SettingsToggle { ActionRow {
anchors.right: parent.right label: "Advanced network settings"
anchors.verticalCenter: parent.verticalCenter detail: "VPN, wired profiles, DNS, and connection details"
checked: root.bluetoothAdapter?.enabled ?? false divider: false
enabled: root.bluetoothAdapter !== null action: "Open panel"
onToggled: value => { if (root.bluetoothAdapter) root.bluetoothAdapter.enabled = value; } onTriggered: SystemSettings.openGnomePanel("network")
} }
} }
BluetoothList { width: parent.width; active: true; maxHeight: 220 }
SettingRow { SettingsCard {
label: "Advanced Bluetooth settings" title: "Bluetooth"
detail: "Device details and system-level options" subtitle: root.bluetoothAdapter?.enabled ? "Nearby and paired devices" : "Bluetooth is off"
divider: false
controlWidth: 104 SettingRow {
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("bluetooth") } label: "Bluetooth"
detail: root.bluetoothAdapter ? "Pair and reconnect without leaving Settings" : "No Bluetooth adapter found"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.bluetoothAdapter?.enabled ?? false
enabled: root.bluetoothAdapter !== null
onToggled: value => {
if (root.bluetoothAdapter)
root.bluetoothAdapter.enabled = value;
} }
} }
} }
BluetoothList {
width: parent.width
active: true
maxHeight: 220
}
ActionRow {
label: "Advanced Bluetooth settings"
detail: "Device details and system-level options"
divider: false
action: "Open panel"
onTriggered: SystemSettings.openGnomePanel("bluetooth")
}
} }
} }
@@ -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
}
}
@@ -1,55 +1,124 @@
// Desktop & Dock.
//
// The dock timings used to be shown here as text -- "Instant", "250 ms" -- even
// though they were already stored, mutable integers. They are controls now.
// The window-layout card keeps text rows because those really are facts about
// how Panama tiles rather than settings: the adjustable parts of window
// appearance live on the Appearance page, next to the preview that explains
// them.
import QtQuick import QtQuick
import qs.config import qs.config
import qs.services
Item { SettingsPage {
Flickable { id: root
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { title: "Desktop & Dock"
id: content lede: "Keep the shell instant, spatial, and out of your way."
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text { text: "Desktop & Dock"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } SettingsCard {
Text { text: "Keep the shell instant, spatial, and out of your way."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } title: "Dock"
SettingsCard { ToggleRow { setting: "dockAutohide" }
title: "Dock" SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
SettingRow { SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant"; divider: false }
label: "Automatically hide the Dock" }
detail: "Reveal it at the bottom edge when a workspace is occupied"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("dockAutohide"); onToggled: value => DesktopPreferences.set("dockAutohide", value) }
}
SettingRow { label: "Reveal response"; detail: "The Dock appears as soon as the pointer reaches the edge"; value: DesktopPreferences.get("dockRevealDelayMs") === 0 ? "Instant" : `${DesktopPreferences.get("dockRevealDelayMs")} ms` }
SettingRow { label: "Hide delay"; detail: "Prevents flicker when crossing icons"; value: `${DesktopPreferences.get("dockHideDelayMs")} ms`; divider: false }
}
SettingsCard { SettingsCard {
title: "Window layout" title: "Pinned applications"
subtitle: "Panama follows the Forge mental model with native Hyprland tiling." subtitle: "What sits in the Dock whether or not it is running. Order here is the order on screen."
SettingRow { label: "Layout"; detail: "Dwindle with preserved split direction"; value: "Tiling" }
SettingRow { label: "Window corners"; detail: "Shared with Panama glass surfaces"; value: "18 px" }
SettingRow { label: "Workspace movement"; detail: "Alt+H / Alt+L, add Shift to move a window"; value: "Dynamic"; divider: false }
}
SettingsCard { DockPinsEditor {
title: "Reset" id: pins
subtitle: "Restore Panama's curated clock, vitals, dock, focus, and display-policy defaults." width: parent.width
SettingRow { }
label: "Desktop preferences" }
detail: "Your pinned applications and files are not changed"
divider: false SettingsCard {
controlWidth: 122 title: "Pin another application"
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Restore defaults"; onClicked: DesktopPreferences.resetDesktopDefaults() }
} DockAppPicker {
width: parent.width
pinned: pins.pinned
onPicked: id => pins.add(id)
}
}
SettingsCard {
title: "Window layout"
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
TextRow {
label: "Layout"
detail: "Dwindle with preserved split direction"
value: "Tiling"
}
TextRow {
label: "Workspace movement"
detail: "Alt+H / Alt+L, add Shift to move a window"
value: "Dynamic"
}
ActionRow {
label: "Gaps, corners, and effects"
detail: "Adjusted on the Appearance page, beside a live preview"
action: "Open Appearance"
divider: false
onTriggered: ShellState.openSettings("appearance")
}
}
SettingsCard {
title: "Focus"
SliderRow { setting: "focusDurationMinutes"; divider: false }
}
SettingsCard {
title: "Snapshots"
subtitle: SettingsBackup.lastError !== ""
? SettingsBackup.lastError
: "Your whole desktop configuration is one file, so a snapshot is a copy of it. Restoring also snapshots what it replaces, so it is itself undoable."
ActionRow {
label: "Back up current settings"
detail: SettingsBackup.snapshots.length === 0
? "No snapshots yet"
: SettingsBackup.snapshots.length + (SettingsBackup.snapshots.length === 1 ? " snapshot kept" : " snapshots kept") + ", newest first"
action: "Back up now"
enabled: !SettingsBackup.busy
divider: SettingsBackup.snapshots.length > 0
onTriggered: SettingsBackup.save()
}
Repeater {
id: snapshotRows
model: SettingsBackup.snapshots
ActionRow {
required property var modelData
required property int index
label: modelData.when
detail: modelData.keys + " settings"
action: "Restore"
enabled: !SettingsBackup.busy
divider: index < snapshotRows.count - 1
onTriggered: SettingsBackup.restore(modelData.name)
} }
} }
} }
SettingsCard {
title: "Reset"
subtitle: "Restores Panama's appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
ActionRow {
label: "Restore Panama defaults"
detail: "Applies immediately, including to the compositor"
action: "Restore defaults"
divider: false
onTriggered: SystemSettings.restoreDefaults()
}
}
} }
@@ -0,0 +1,214 @@
// A miniature of the real desktop that redraws as you change Appearance.
//
// The point is that "outer gaps 24" and "radius 9" mean nothing as numbers. This
// shows two tiled windows at the settings currently in effect: the focused one
// wearing the prism border and its glow, the unfocused one carrying the
// inactive-opacity setting, both inside real gaps at a real corner radius.
//
// Geometry is scaled by the ratio between this preview's width and the actual
// monitor's, so proportions are honest rather than decorative -- a 10px gap on
// a 4500px display genuinely is almost invisible, and the preview says so.
//
// Everything here is driven by bindings on DesktopPreferences, so the preview
// shows what is actually stored and applied. It has no state of its own and
// nothing animates while idle.
import QtQuick
import QtQuick.Effects
import qs.config
import qs.services
Rectangle {
id: root
implicitHeight: 232
radius: Theme.cardRadius
clip: true
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.09)
// Falls back to a sane width if the monitor has not been read yet, so the
// preview is never wrong by a factor of ten on first paint.
readonly property real monitorWidth: SystemSettings.monitorWidth > 0 ? SystemSettings.monitorWidth : 3000
readonly property real scale: width > 0 ? width / root.monitorWidth : 0.25
readonly property int gapsIn: DesktopPreferences.get("gapsIn")
readonly property int gapsOut: DesktopPreferences.get("gapsOut")
readonly property int borderSize: DesktopPreferences.get("borderSize")
readonly property int rounding: DesktopPreferences.get("windowRounding")
readonly property real inactiveOpacity: DesktopPreferences.get("inactiveOpacity")
readonly property bool shadowOn: DesktopPreferences.get("shadowEnabled")
readonly property int shadowRange: DesktopPreferences.get("shadowRange")
readonly property bool glowOn: DesktopPreferences.get("glowEnabled")
readonly property int glowRange: DesktopPreferences.get("glowRange")
// Scaled geometry, floored so a small-but-nonzero setting stays visible
// rather than rounding away to nothing.
function px(value: real): real {
return value <= 0 ? 0 : Math.max(1, value * root.scale);
}
// Stands in for the wallpaper. Static: an animated gradient here would
// repaint forever behind a settings page.
gradient: Gradient {
orientation: Gradient.Vertical
GradientStop { position: 0.0; color: "#2b3050" }
GradientStop { position: 1.0; color: "#241f33" }
}
// The bar, so the preview reads as this desktop and not a generic one.
Rectangle {
id: miniBar
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 20
color: Theme.alpha(Theme.bgDark, 0.4)
border.width: 0
Row {
anchors.left: parent.left
anchors.leftMargin: 9
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Repeater {
model: 3
Rectangle {
required property int index
width: index === 0 ? 12 : 5
height: 5
radius: 2.5
border.width: 0
color: index === 0 ? Theme.accent : Theme.alpha(Theme.fg, 0.3)
anchors.verticalCenter: parent.verticalCenter
}
}
}
Text {
anchors.centerIn: parent
text: "Panama"
color: Theme.alpha(Theme.fg, 0.45)
font.family: Theme.fontFamily
font.pixelSize: 9
}
}
Row {
id: tiles
anchors.top: miniBar.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: root.px(root.gapsOut)
spacing: root.px(root.gapsIn) * 2
MiniWindow {
width: (tiles.width - tiles.spacing) / 2
height: tiles.height
focused: true
}
MiniWindow {
width: (tiles.width - tiles.spacing) / 2
height: tiles.height
focused: false
}
}
component MiniWindow: Item {
id: win
required property bool focused
// The gradient border is drawn as a filled rounded rect with the window
// body inset on top of it: QML's Rectangle border takes a colour, not a
// gradient, and the prism border is the whole signature here.
Rectangle {
id: frame
anchors.fill: parent
radius: root.px(root.rounding)
border.width: 0
visible: win.focused && root.borderSize > 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.accent }
GradientStop { position: 1.0; color: Theme.accentSecondary }
}
}
Rectangle {
id: body
anchors.fill: parent
anchors.margins: win.focused ? root.px(root.borderSize) : 0
radius: Math.max(0, root.px(root.rounding) - (win.focused ? root.px(root.borderSize) : 0))
color: Theme.alpha(Theme.bgDark, 0.92)
opacity: win.focused ? 1.0 : root.inactiveOpacity
border.width: win.focused ? 0 : 1
border.color: Theme.alpha(Theme.gutter, 0.6)
clip: true
Rectangle {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 13
color: Theme.alpha(Theme.fg, 0.05)
border.width: 0
}
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 22
anchors.margins: 10
spacing: 5
Repeater {
model: [1.0, 0.74, 0.52]
Rectangle {
required property real modelData
width: parent.width * modelData
height: 4
radius: 2
border.width: 0
color: Theme.alpha(Theme.fg, win.focused ? 0.2 : 0.13)
}
}
}
Text {
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.margins: 8
text: win.focused ? "focused" : "unfocused"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 9
}
}
// Shadow and glow both come from MultiEffect. Only the focused window
// gets the glow, matching looks.lua where glow.color_inactive is fully
// transparent.
MultiEffect {
anchors.fill: body
source: body
visible: root.shadowOn || (win.focused && root.glowOn)
z: -1
shadowEnabled: true
shadowColor: win.focused && root.glowOn
? Theme.alpha(Theme.accent, 0.5)
: Theme.alpha("#15161e", 0.85)
shadowBlur: win.focused && root.glowOn
? Math.min(1.0, root.px(root.glowRange) / 12)
: Math.min(1.0, root.px(root.shadowRange) / 24)
shadowVerticalOffset: win.focused && root.glowOn ? 0 : root.px(4)
shadowHorizontalOffset: 0
}
}
}
@@ -3,64 +3,50 @@ import qs.config
import qs.services import qs.services
import qs.widgets import qs.widgets
Item { SettingsPage {
Flickable { title: "Displays"
anchors.fill: parent lede: SystemSettings.monitorDescription || "Reading the active display…"
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { SettingsCard {
id: content title: SystemSettings.monitorName || "Active display"
width: parent.width - 68 subtitle: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight} at ${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale · ${SystemSettings.monitorFormat}`
x: 34 TextRow { label: "Color mode"; detail: "Wide-gamut SDR desktop at 10-bit"; value: SystemSettings.colorPreset || "wide" }
y: 30 TextRow { label: "Variable refresh"; detail: SystemSettings.monitorVrrActive ? "Active for current fullscreen content" : "Ready when game or video content requests it"; value: SystemSettings.monitorVrrActive ? "Active" : "Standby"; divider: false }
spacing: 16 }
Text { text: "Displays"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } SettingsCard {
Text { text: SystemSettings.monitorDescription || "Reading the active display…"; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } title: "Gaming display policy"
subtitle: "These values apply immediately and are restored when Panama starts."
SettingsCard { SettingRow {
title: SystemSettings.monitorName || "Active display"
subtitle: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight} at ${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale · ${SystemSettings.monitorFormat}`
SettingRow { label: "Color mode"; detail: "Wide-gamut SDR desktop at 10-bit"; value: SystemSettings.colorPreset || "wide" }
SettingRow { label: "Variable refresh"; detail: SystemSettings.monitorVrrActive ? "Active for current fullscreen content" : "Ready when game or video content requests it"; value: SystemSettings.monitorVrrActive ? "Active" : "Standby"; divider: false }
}
SettingsCard {
title: "Gaming display policy"
subtitle: "These values apply immediately and are restored when Panama starts."
SettingRow {
label: "Game-aware HDR" label: "Game-aware HDR"
detail: "Enter HDR only for fullscreen content that requests it" detail: "Enter HDR only for fullscreen content that requests it"
controlWidth: 48 controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.autoHdr; onToggled: value => SystemSettings.setAutoHdr(value) } SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.autoHdr; onToggled: value => SystemSettings.setAutoHdr(value) }
} }
SettingRow { SettingRow {
label: "Content-aware VRR" label: "Content-aware VRR"
detail: "Enable variable refresh only for game and video content" detail: "Enable variable refresh only for game and video content"
controlWidth: 48 controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.vrrPolicy === 3; onToggled: value => SystemSettings.setVrrPolicy(value ? 3 : 0) } SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.vrrPolicy === 3; onToggled: value => SystemSettings.setVrrPolicy(value ? 3 : 0) }
} }
SettingRow { SettingRow {
label: "Direct scanout for games" label: "Direct scanout for games"
detail: "Bypass compositing only for windows classified as games" detail: "Bypass compositing only for windows classified as games"
divider: false divider: false
controlWidth: 48 controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.directScanoutPolicy === 2; onToggled: value => SystemSettings.setDirectScanoutPolicy(value ? 2 : 0) } SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.directScanoutPolicy === 2; onToggled: value => SystemSettings.setDirectScanoutPolicy(value ? 2 : 0) }
} }
} }
SettingsCard { SettingsCard {
title: "Night Light" title: "Night Light"
SettingRow { SettingRow {
label: "Warm display colors" label: "Warm display colors"
detail: NightLight.automatic ? "Following the evening schedule" : "Manual control" detail: NightLight.automatic ? "Following the evening schedule" : "Manual control"
controlWidth: 48 controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: NightLight.active; onToggled: NightLight.toggle() } SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: NightLight.active; onToggled: NightLight.toggle() }
} }
SettingRow { SettingRow {
label: "Color temperature" label: "Color temperature"
detail: `${NightLight.temperature} K` detail: `${NightLight.temperature} K`
divider: false divider: false
@@ -71,27 +57,25 @@ Item {
icon: "weather-clear-night-symbolic" icon: "weather-clear-night-symbolic"
onMoved: value => NightLight.temperature = Math.round((6500 - value * 4000) / 50) * 50 onMoved: value => NightLight.temperature = Math.round((6500 - value * 4000) / 50) * 50
} }
} }
} }
Rectangle { Rectangle {
width: parent.width width: parent.width
height: warningText.implicitHeight + 30 height: warningText.implicitHeight + 30
radius: Theme.cardRadius radius: Theme.cardRadius
color: Theme.alpha(Theme.warn, 0.085) color: Theme.alpha(Theme.warn, 0.085)
border.width: 1 border.width: 1
border.color: Theme.alpha(Theme.warn, 0.22) border.color: Theme.alpha(Theme.warn, 0.22)
Text { Text {
id: warningText id: warningText
anchors.fill: parent anchors.fill: parent
anchors.margins: 15 anchors.margins: 15
text: "Full-time desktop HDR stays unavailable here because the current compositor path can break screenshots, OBS, Sunshine, and lock-screen capture. Game-aware HDR keeps the desktop dependable without giving up HDR games." text: "Full-time desktop HDR stays unavailable here because the current compositor path can break screenshots, OBS, Sunshine, and lock-screen capture. Game-aware HDR keeps the desktop dependable without giving up HDR games."
color: Theme.mix(Theme.fg, Theme.warn, 0.25) color: Theme.mix(Theme.fg, Theme.warn, 0.25)
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
}
}
} }
} }
} }
@@ -0,0 +1,71 @@
// Adds an installed application to the Dock.
//
// Filtered rather than a full list: there are several hundred desktop entries
// on a normal system, and rendering them all into a page that is already
// scrolling is both slow and useless. Typing narrows; nothing shows until you
// do, which also keeps the card short when you are not using it.
import QtQuick
import Quickshell
import qs.config
import qs.modules.clipboard
Column {
id: root
spacing: 0
required property var pinned
signal picked(string id)
readonly property var matches: {
const needle = search.text.trim().toLowerCase();
if (needle === "")
return [];
const out = [];
for (const entry of DesktopEntries.applications.values) {
if (entry.noDisplay)
continue;
if (root.pinned.indexOf(entry.id) >= 0)
continue;
if (String(entry.name).toLowerCase().indexOf(needle) >= 0)
out.push(entry);
if (out.length >= 8)
break;
}
return out;
}
SearchField {
id: search
width: parent.width
placeholder: "Search installed applications"
}
Repeater {
model: root.matches
SettingRow {
id: candidate
required property var modelData
required property int index
label: candidate.modelData.name
detail: candidate.modelData.id
divider: candidate.index < root.matches.length - 1
controlWidth: 86
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Pin"
onClicked: {
root.picked(candidate.modelData.id);
search.text = "";
}
}
}
}
}
@@ -0,0 +1,112 @@
// The Dock's pinned applications: reorder, remove, and add.
//
// The list was a sixteen-entry literal in Settings.qml, so changing what sits
// in the Dock meant editing a QML file and reloading the shell. It is a plain
// ordered list of desktop entry ids, stored in the shared settings file, which
// means it is covered by Restore defaults like everything else.
//
// Move up / move down rather than drag-and-drop. Dragging inside a Flickable
// that is itself inside a scrolling page is a genuinely hard interaction to get
// right, and it fails in a way the user reads as the app being broken; two
// buttons are unambiguous and keyboard-reachable.
import QtQuick
import Quickshell
import qs.config
import qs.services
import qs.widgets
Column {
id: root
spacing: 0
readonly property var pinned: {
const stored = DesktopPreferences.get("dockPinned");
return Array.isArray(stored) ? stored : [];
}
// DesktopEntries populates asynchronously, so this must be read as a
// binding rather than looked up inside one -- byId() called during
// evaluation registers no dependency and answers from an empty list.
readonly property var entriesById: {
const index = {};
for (const entry of DesktopEntries.applications.values)
index[entry.id] = entry;
return index;
}
function nameFor(id: string): string {
const entry = root.entriesById[id];
return entry ? entry.name : id;
}
function commit(next: var): void {
DesktopPreferences.set("dockPinned", next);
}
function move(from: int, to: int): void {
if (to < 0 || to >= root.pinned.length)
return;
const next = root.pinned.slice();
const moved = next.splice(from, 1)[0];
next.splice(to, 0, moved);
root.commit(next);
}
function remove(index: int): void {
const next = root.pinned.slice();
next.splice(index, 1);
root.commit(next);
}
function add(id: string): void {
if (root.pinned.indexOf(id) >= 0)
return;
root.commit(root.pinned.concat([id]));
}
Repeater {
model: root.pinned
SettingRow {
id: pin
required property var modelData
required property int index
label: root.nameFor(pin.modelData)
detail: pin.modelData
divider: pin.index < root.pinned.length - 1
controlWidth: 132
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 4
SettingsButton {
text: "↑"
enabled: pin.index > 0
onClicked: root.move(pin.index, pin.index - 1)
}
SettingsButton {
text: "↓"
enabled: pin.index < root.pinned.length - 1
onClicked: root.move(pin.index, pin.index + 1)
}
SettingsButton {
text: "Unpin"
onClicked: root.remove(pin.index)
}
}
}
}
SettingRow {
visible: root.pinned.length === 0
label: "Nothing is pinned"
detail: "The Dock will only show running applications"
divider: false
}
}
@@ -2,160 +2,159 @@ import QtQuick
import qs.config import qs.config
import qs.services import qs.services
Item { SettingsPage {
id: root id: root
readonly property int openedHour: new Date().getHours() readonly property int openedHour: new Date().getHours()
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening") readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
Flickable { title: `${root.greeting}, Gabriel`
anchors.fill: parent lede: "Your Panama desktop is configured and ready."
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { SettingsCard {
id: content title: SystemSettings.monitorDescription || "Active display"
width: parent.width - 68 subtitle: SystemSettings.monitorName || "Detecting your display…"
x: 34
y: 30
spacing: 16
Text { Grid {
text: `${root.greeting}, Gabriel` id: monitorLayout
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 27
font.weight: Font.DemiBold
}
Text { width: parent.width
text: "Your Panama desktop is configured and ready." columns: width >= 620 ? 2 : 1
color: Theme.fgDim columnSpacing: 28
font.family: Theme.fontFamily rowSpacing: 16
font.pixelSize: Theme.fontSize
bottomPadding: 6
}
SettingsCard { Item {
title: SystemSettings.monitorDescription || "Active display" width: monitorLayout.columns === 2
subtitle: SystemSettings.monitorName || "Detecting your display…" ? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
: monitorLayout.width
height: 164
Row { Rectangle {
width: parent.width width: Math.min(parent.width - 24, 260)
height: 164 height: width * 0.64
spacing: 28 anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 5
radius: 12
color: Theme.alpha(Theme.bgDark, 0.94)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.34)
Item { Rectangle {
width: parent.width * 0.47 anchors.fill: parent
height: parent.height anchors.margins: 10
radius: 7
Rectangle { gradient: Gradient {
width: Math.min(parent.width - 24, 260) orientation: Gradient.Horizontal
height: width * 0.64 GradientStop { position: 0; color: Theme.mix(Theme.bg, Theme.accent, 0.08) }
anchors.horizontalCenter: parent.horizontalCenter GradientStop { position: 1; color: Theme.mix(Theme.bg, Theme.accentSecondary, 0.08) }
anchors.top: parent.top
anchors.topMargin: 5
radius: 12
color: Theme.alpha(Theme.bgDark, 0.94)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.34)
Rectangle {
anchors.fill: parent
anchors.margins: 10
radius: 7
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.mix(Theme.bg, Theme.accent, 0.08) }
GradientStop { position: 1; color: Theme.mix(Theme.bg, Theme.accentSecondary, 0.08) }
}
Text {
anchors.centerIn: parent
text: SystemSettings.monitorName || "DISPLAY"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.letterSpacing: 1.5
}
}
} }
}
Column {
width: parent.width * 0.47
anchors.verticalCenter: parent.verticalCenter
spacing: 13
Text { Text {
text: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight}` anchors.centerIn: parent
color: Theme.fg text: SystemSettings.monitorName || "DISPLAY"
color: Theme.fgMuted
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.features: Theme.tabularFigures font.pixelSize: Theme.fontSizeSmall
font.pixelSize: Theme.fontSizeLarge font.letterSpacing: 1.5
font.weight: Font.DemiBold
}
Text {
text: `${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale`
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
}
Text {
text: `${SystemSettings.monitorFormat || "Detecting format"} · ${SystemSettings.colorPreset || "standard color"}`
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
text: SystemSettings.autoHdr ? "Game-aware HDR is ready" : "Game-aware HDR is off"
color: SystemSettings.autoHdr ? Theme.ok : Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
} }
} }
} }
} }
Row { Column {
width: parent.width width: monitorLayout.columns === 2
spacing: 16 ? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
: monitorLayout.width
height: monitorLayout.columns === 2 ? 164 : implicitHeight
spacing: 13
SettingsCard { Text {
width: (parent.width - parent.spacing) / 2 text: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight}`
title: "Quiet focus" color: Theme.fg
subtitle: "Notifications and focused work" font.family: Theme.fontFamily
font.features: Theme.tabularFigures
SettingRow { font.pixelSize: Theme.fontSizeLarge
label: "Do Not Disturb" font.weight: Font.DemiBold
detail: Notifs.doNotDisturb ? "Banners are currently quiet" : "Notification banners are visible"
divider: false
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.doNotDisturb
onToggled: value => Notifs.doNotDisturb = value
}
}
} }
Text {
SettingsCard { text: `${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale`
width: (parent.width - parent.spacing) / 2 color: Theme.fgDim
title: "Desktop services" font.family: Theme.fontFamily
subtitle: "The essentials are running" font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
SettingRow { }
label: "Sync & remote access" Text {
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}` text: `${SystemSettings.monitorFormat || "Detecting format"} · ${SystemSettings.colorPreset || "standard color"}`
divider: false color: Theme.fgDim
value: SystemSettings.nextcloudActive && SystemSettings.rustdeskActive ? "Healthy" : "Review" font.family: Theme.fontFamily
} font.pixelSize: Theme.fontSize
}
Text {
text: SystemSettings.autoHdr ? "Game-aware HDR is ready" : "Game-aware HDR is off"
color: SystemSettings.autoHdr ? Theme.ok : Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
} }
} }
} }
} }
SettingsCard {
title: "Weather"
subtitle: "Local conditions in the date menu"
ChoiceRow { setting: "temperatureUnit" }
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
}
SettingsCard {
title: "System vitals"
subtitle: "Processor, memory, and graphics activity in the bar"
SliderRow { setting: "vitalsIntervalMs"; divider: false }
}
Grid {
id: summaryCards
width: parent.width
columns: width >= 720 ? 2 : 1
columnSpacing: 16
rowSpacing: 16
SettingsCard {
width: summaryCards.columns === 2
? (summaryCards.width - summaryCards.columnSpacing) / 2
: summaryCards.width
title: "Quiet focus"
subtitle: "Notifications and focused work"
SettingRow {
label: "Do Not Disturb"
detail: Notifs.doNotDisturb ? "Banners are currently quiet" : "Notification banners are visible"
divider: false
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.doNotDisturb
onToggled: value => Notifs.doNotDisturb = value
}
}
}
SettingsCard {
width: summaryCards.columns === 2
? (summaryCards.width - summaryCards.columnSpacing) / 2
: summaryCards.width
title: "Desktop services"
subtitle: "The essentials are running"
TextRow {
label: "Sync & remote access"
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}`
divider: false
value: SystemSettings.nextcloudActive && SystemSettings.rustdeskActive ? "Healthy" : "Review"
}
}
}
} }
@@ -2,9 +2,11 @@ import QtQuick
import qs.config import qs.config
import qs.services import qs.services
Item { SettingsPage {
id: root id: root
objectName: "home-phone-page" objectName: "home-phone-page"
title: "Home & Phone"
lede: "Choose what appears in Control Center and keep phone continuity close at hand."
property string lightQuery: searchInput.text.trim().toLowerCase() property string lightQuery: searchInput.text.trim().toLowerCase()
@@ -41,274 +43,242 @@ Item {
return "Home Assistant is unavailable"; return "Home Assistant is unavailable";
} }
Flickable { SettingsCard {
anchors.fill: parent title: "Home Assistant"
clip: true subtitle: root.homeStatus()
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { SettingRow {
id: content label: "Light catalog"
width: parent.width - 68 detail: "Panama reads light state through the Home Assistant helper."
x: 34 divider: false
y: 30 controlWidth: 176
spacing: 16
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
id: refreshButton
text: "Refresh"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: HomeAssistant.refresh()
Keys.onReturnPressed: HomeAssistant.refresh()
Keys.onSpacePressed: HomeAssistant.refresh()
}
SettingsButton {
id: openHomeButton
text: "Open"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: HomeAssistant.open()
Keys.onReturnPressed: HomeAssistant.open()
Keys.onSpacePressed: HomeAssistant.open()
}
}
}
}
SettingsCard {
title: "Control Center lights"
subtitle: HomeAssistant.selectedEntities.length === 0
? "Select the lights that belong on your shelf."
: `${Math.min(4, HomeAssistant.selectedEntities.length)} in Control Center · ${HomeAssistant.selectedEntities.length} selected`
Text {
width: parent.width
visible: HomeAssistant.selectedEntities.length === 0
text: "Choose lights below to build your Control Center shelf."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
wrapMode: Text.WordWrap
topPadding: 3
bottomPadding: 13
}
GridView {
id: favoritesGrid
width: parent.width
height: Math.ceil(count / 2) * cellHeight
visible: count > 0
interactive: false
clip: false
cellWidth: width / 2
cellHeight: 116
model: HomeAssistant.selectedEntities
delegate: HomeFavoriteCard {
required property var modelData
width: GridView.view.cellWidth - 6
height: 108
favorite: ({
id: modelData.id,
alias: modelData.name === modelData.sourceName ? "" : modelData.name
})
sourceName: modelData.sourceName
featured: index < 4
onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias)
onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex)
onRemoveRequested: id => HomePreferences.remove(id)
}
}
Rectangle {
width: parent.width
height: 46
visible: HomePreferences.saveError !== ""
radius: 9
color: Theme.alpha(Theme.warn, 0.09)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.24)
Text { Text {
text: "Home & Phone" anchors.left: parent.left
color: Theme.fg anchors.leftMargin: 12
anchors.right: retryButton.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: HomePreferences.saveError
color: Theme.warn
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: 27 font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold elide: Text.ElideRight
} }
SettingsButton {
id: retryButton
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: "Retry"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.warn : Theme.alpha(Theme.fg, 0.08)
onClicked: HomePreferences.retrySave()
Keys.onReturnPressed: HomePreferences.retrySave()
Keys.onSpacePressed: HomePreferences.retrySave()
}
}
}
SettingsCard {
title: "Available lights"
subtitle: "Search the Home Assistant catalog by source name or entity ID."
Rectangle {
width: parent.width
height: 38
radius: 10
color: Theme.alpha(Theme.fg, searchInput.activeFocus ? 0.08 : 0.05)
border.width: searchInput.activeFocus ? 2 : 1
border.color: searchInput.activeFocus
? Theme.alpha(Theme.accent, 0.72)
: Theme.alpha(Theme.fg, 0.065)
Text { Text {
text: "Choose what appears in Control Center and keep phone continuity close at hand." anchors.left: parent.left
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "\u{F0349}"
color: Theme.fgDim color: Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 14
}
TextInput {
id: searchInput
anchors.left: parent.left
anchors.leftMargin: 36
anchors.right: parent.right
anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter
activeFocusOnTab: true
color: Theme.fg
selectionColor: Theme.accent
selectedTextColor: Theme.bgDark
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize font.pixelSize: Theme.fontSize
bottomPadding: 6 clip: true
}
SettingsCard {
title: "Home Assistant"
subtitle: root.homeStatus()
SettingRow {
label: "Light catalog"
detail: "Panama reads light state through the Home Assistant helper."
divider: false
controlWidth: 176
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
id: refreshButton
text: "Refresh"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: HomeAssistant.refresh()
Keys.onReturnPressed: HomeAssistant.refresh()
Keys.onSpacePressed: HomeAssistant.refresh()
}
SettingsButton {
id: openHomeButton
text: "Open"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: HomeAssistant.open()
Keys.onReturnPressed: HomeAssistant.open()
Keys.onSpacePressed: HomeAssistant.open()
}
}
}
}
SettingsCard {
title: "Control Center lights"
subtitle: HomeAssistant.selectedEntities.length === 0
? "Select the lights that belong on your shelf."
: `${Math.min(4, HomeAssistant.selectedEntities.length)} in Control Center · ${HomeAssistant.selectedEntities.length} selected`
Text { Text {
width: parent.width anchors.fill: parent
visible: HomeAssistant.selectedEntities.length === 0 visible: searchInput.text === "" && !searchInput.activeFocus
text: "Choose lights below to build your Control Center shelf." text: "Search available lights"
color: Theme.fgDim color: Theme.fgMuted
font.family: Theme.fontFamily font: searchInput.font
font.pixelSize: Theme.fontSize verticalAlignment: Text.AlignVCenter
wrapMode: Text.WordWrap
topPadding: 3
bottomPadding: 13
}
GridView {
id: favoritesGrid
width: parent.width
height: Math.ceil(count / 2) * cellHeight
visible: count > 0
interactive: false
clip: false
cellWidth: width / 2
cellHeight: 116
model: HomeAssistant.selectedEntities
delegate: HomeFavoriteCard {
required property var modelData
width: GridView.view.cellWidth - 6
height: 108
favorite: ({
id: modelData.id,
alias: modelData.name === modelData.sourceName ? "" : modelData.name
})
sourceName: modelData.sourceName
featured: index < 4
onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias)
onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex)
onRemoveRequested: id => HomePreferences.remove(id)
}
}
Rectangle {
width: parent.width
height: 46
visible: HomePreferences.saveError !== ""
radius: 9
color: Theme.alpha(Theme.warn, 0.09)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.24)
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: retryButton.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: HomePreferences.saveError
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
SettingsButton {
id: retryButton
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: "Retry"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.warn : Theme.alpha(Theme.fg, 0.08)
onClicked: HomePreferences.retrySave()
Keys.onReturnPressed: HomePreferences.retrySave()
Keys.onSpacePressed: HomePreferences.retrySave()
}
} }
} }
}
SettingsCard { Column {
title: "Available lights" width: parent.width
subtitle: "Search the Home Assistant catalog by source name or entity ID." visible: root.availableLights.length > 0
Rectangle { Repeater {
model: root.availableLights
AvailableLightRow {
required property var modelData
width: parent.width width: parent.width
height: 38 entity: modelData
radius: 10 onAddRequested: id => HomePreferences.add(id)
color: Theme.alpha(Theme.fg, searchInput.activeFocus ? 0.08 : 0.05)
border.width: searchInput.activeFocus ? 2 : 1
border.color: searchInput.activeFocus
? Theme.alpha(Theme.accent, 0.72)
: Theme.alpha(Theme.fg, 0.065)
Text {
anchors.left: parent.left
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "\u{F0349}"
color: Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 14
}
TextInput {
id: searchInput
anchors.left: parent.left
anchors.leftMargin: 36
anchors.right: parent.right
anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter
activeFocusOnTab: true
color: Theme.fg
selectionColor: Theme.accent
selectedTextColor: Theme.bgDark
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
clip: true
Text {
anchors.fill: parent
visible: searchInput.text === "" && !searchInput.activeFocus
text: "Search available lights"
color: Theme.fgMuted
font: searchInput.font
verticalAlignment: Text.AlignVCenter
}
}
} }
}
}
Column { Text {
width: parent.width width: parent.width
visible: root.availableLights.length > 0 visible: root.availableLights.length === 0
text: root.availableEmptyText
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
topPadding: 18
bottomPadding: 10
}
}
Repeater { SettingsCard {
model: root.availableLights title: "Phone continuity"
subtitle: "Keep the Messages handoff independent from phone connectivity."
AvailableLightRow { SettingRow {
required property var modelData label: "Messages"
width: parent.width detail: "Opens BlueBubbles"
entity: modelData divider: false
onAddRequested: id => HomePreferences.add(id) controlWidth: 204
}
} Row {
} anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 12
Text { Text {
width: parent.width anchors.verticalCenter: parent.verticalCenter
visible: root.availableLights.length === 0 text: SystemSettings.bluebubblesAvailable ? "Installed" : "Unavailable"
text: root.availableEmptyText color: SystemSettings.bluebubblesAvailable ? Theme.ok : Theme.fgMuted
color: Theme.fgDim
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
topPadding: 18
bottomPadding: 10
} }
}
SettingsCard { SettingsButton {
title: "Phone continuity" id: openBlueBubblesButton
subtitle: "Keep the Messages handoff independent from phone connectivity." text: "Open"
enabled: SystemSettings.bluebubblesAvailable
SettingRow { activeFocusOnTab: enabled
label: "Messages" border.width: activeFocus ? 2 : 1
detail: "Opens BlueBubbles" border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
divider: false onClicked: SystemSettings.openApplication("bluebubbles")
controlWidth: 204 Keys.onReturnPressed: if (enabled) SystemSettings.openApplication("bluebubbles")
Keys.onSpacePressed: if (enabled) SystemSettings.openApplication("bluebubbles")
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 12
Text {
anchors.verticalCenter: parent.verticalCenter
text: SystemSettings.bluebubblesAvailable ? "Installed" : "Unavailable"
color: SystemSettings.bluebubblesAvailable ? Theme.ok : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
SettingsButton {
id: openBlueBubblesButton
text: "Open"
enabled: SystemSettings.bluebubblesAvailable
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: SystemSettings.openApplication("bluebubbles")
Keys.onReturnPressed: if (enabled) SystemSettings.openApplication("bluebubbles")
Keys.onSpacePressed: if (enabled) SystemSettings.openApplication("bluebubbles")
}
}
} }
} }
} }
@@ -2,78 +2,97 @@ import QtQuick
import qs.config import qs.config
import qs.services import qs.services
Item { SettingsPage {
Flickable { title: "Notifications & Focus"
anchors.fill: parent lede: "Control interruptions without losing useful history."
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { SettingsCard {
id: content title: "Notifications"
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text { text: "Notifications & Focus"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } SettingRow {
Text { text: "Control interruptions without losing useful history."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } label: "Do Not Disturb"
detail: "Keep notifications in the center but suppress banners"
controlWidth: 48
SettingsCard { SettingsToggle {
title: "Notifications" anchors.right: parent.right
SettingRow { anchors.verticalCenter: parent.verticalCenter
label: "Do Not Disturb" checked: Notifs.doNotDisturb
detail: "Keep notifications in the center but suppress banners" onToggled: value => Notifs.doNotDisturb = value
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: Notifs.doNotDisturb; onToggled: value => Notifs.doNotDisturb = value }
}
SettingRow { label: "Notification history"; detail: "Live notifications retained by Panama"; value: `${Notifs.history.length} items` }
SettingRow {
label: "Clear notification history"
detail: "Dismiss every item currently in the notification center"
divider: false
controlWidth: 94
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Clear all"; enabled: Notifs.history.length > 0; onClicked: Notifs.dismissAll() }
}
} }
}
TextRow {
label: "Notification history"
detail: "Live notifications retained by Panama"
value: `${Notifs.history.length} items`
}
ActionRow {
label: "Clear notification history"
detail: "Dismiss every item currently in the notification center"
divider: false
action: "Clear all"
enabled: Notifs.history.length > 0
onTriggered: Notifs.dismissAll()
}
}
SettingsCard {
title: "Banner behavior"
SliderRow { setting: "notificationTimeoutMs" }
SliderRow {
setting: "notificationTimeoutCriticalMs"
zeroLabel: "Never"
}
SliderRow { setting: "notificationHistoryLimit" }
SliderRow { setting: "maxVisibleToasts"; divider: false }
}
SettingsCard {
title: "Focus sessions"
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
SettingRow {
id: durationRow
label: "Default duration"
detail: "Used by Super+Shift+F and Quick Settings"
controlWidth: 264
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Repeater {
model: [25, 45, 60, 90]
SettingsCard {
title: "Focus sessions"
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
SettingRow {
label: "Default duration"
detail: "Used by Super+Shift+F and Quick Settings"
controlWidth: 264
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Repeater {
model: [25, 45, 60, 90]
SettingsButton {
required property int modelData
text: `${modelData}m`
tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal"
onClicked: DesktopPreferences.set("focusDurationMinutes", modelData)
}
}
}
}
SettingRow {
label: FocusSession.active ? `Active on ${FocusSession.workspaceLabel}` : "No active focus session"
detail: FocusSession.active ? `${FocusSession.remainingText} remaining` : "Start one without leaving Settings"
divider: false
controlWidth: 120
SettingsButton { SettingsButton {
anchors.right: parent.right required property int modelData
anchors.verticalCenter: parent.verticalCenter
text: FocusSession.active ? "Show controls" : "Start focus" text: `${modelData}m`
tone: FocusSession.active ? "normal" : "accent" tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal"
onClicked: FocusSession.reveal() onClicked: SystemSettings.commitPreference("focusDurationMinutes", modelData)
} }
} }
} }
} }
SettingRow {
label: FocusSession.active ? `Active on ${FocusSession.workspaceLabel}` : "No active focus session"
detail: FocusSession.active ? `${FocusSession.remainingText} remaining` : "Start one without leaving Settings"
divider: false
controlWidth: 120
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: FocusSession.active ? "Show controls" : "Start focus"
tone: FocusSession.active ? "normal" : "accent"
onClicked: FocusSession.reveal()
}
}
} }
} }
@@ -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()
}
}
}
@@ -0,0 +1,109 @@
# Panama Settings
The control centre for everything Panama owns. Anything the system owns —
hardware, accounts, printers — is delegated to GNOME Settings and labelled as
such rather than half-reimplemented.
## Adding a setting
One schema entry. That is the whole job.
```qml
// config/PreferenceSchema.qml
{
key: "blurSize", type: "int", def: 8, min: 1, max: 20, step: 1,
unit: "px", group: "effects",
label: "Blur radius",
detail: "Larger is softer and costs more frame time",
hypr: { path: ["decoration", "blur", "size"], option: "decoration:blur:size", readAs: "int" }
}
```
```qml
// the page
SliderRow { setting: "blurSize" }
```
Persistence, validation, clamping, reset, search indexing, and — with a `hypr`
block — live application to the compositor and the startup replay all derive
from that entry. There is nothing else to register.
If it is compositor-backed, add the matching `prefs.get("blurSize", 8)` in
`hypr/looks.lua` so the Hyprland config still stands alone with no settings
file.
## The rows
| Component | For |
|---|---|
| `SettingsPage` | The page scaffold: title, lede, optional pinned `header` |
| `ToggleRow { setting }` | A boolean |
| `SliderRow { setting }` | A number; `zeroLabel` renders 0 as "Never"/"Instant"/"None" |
| `ChoiceRow { setting }` | An enum, as a segmented control |
| `ActionRow` | A button: opens a GNOME panel, runs a one-shot |
| `TextRow` | A genuinely read-only fact |
`TextRow` is for facts, not for settings that were merely expensive to wire.
Before Stage 3 more than half of all rows were static text standing in for
controls; that is the failure this vocabulary exists to prevent.
Rows write through `SystemSettings.commitPreference(key, value)`, which routes
compositor-backed keys through apply-and-verify and local keys straight to the
store. A row never needs to know which kind it holds.
## Things that will bite you
**`readAs` describes the answer, not the setting.** `hyprctl getoption` returns
the value in a different JSON field per type — `int`, `bool`, `float`, `str`,
and `css` for gaps (a four-value box). Declaring the wrong one does not fail
loudly: it makes every write to that key look *rejected*, and the user sees an
error for a change that worked. `tests/quickshell/schema-hypr-shape-contract.sh`
asks the compositor for the real shape of every mapped option.
**Never trust an exit code from `hyprctl`.** `keyword` refuses to work on a
Lua-configured Hyprland, prints the refusal to stdout, and exits 0. `eval` exits
0 on syntax and runtime errors too. The only trustworthy signal that a write
landed is reading the value back.
**The Settings window is tiled.** `implicitWidth` is a hint; the layout decides,
and it ranges from a half-screen split to the full display. `SliderRow` stacks
its control under the label below 520px. Test narrow.
**Binding an anchor to `undefined` does not reliably release it.** Switching
layouts that way left a slider anchored to both edges with the label squeezed
into what was left. Position explicitly instead.
**Inside a `SettingsCard`, `parent` is the card's internal Column.** So
`parent.modelData` in a nested `Repeater` is undefined and the rows silently
never appear — you get a card with a heading and nothing under it. Address the
outer model through an explicit `id`.
**A `TapHandler` declared as a child of `SettingRow` lands in the trailing
slot**, because that is the row's default property, so only the right-hand edge
becomes clickable. Use `activatable: true` with `onActivated` for a whole-row
target.
**A copy of the Quickshell config shares the live shell's ID.** Quickshell
derives the Shell ID from config *content*, not path, so
`cp -a config/dot/quickshell $tmp && qs -p $tmp kill` kills the running
desktop, and `qs -p $tmp ipc call …` can drive it. Harnesses that point at a
single distinct `.qml` file are safe; copying the whole directory is not.
## Where state lives
| File | Holds |
|---|---|
| `~/.config/panama/settings.json` | Everything in the schema. Read by the shell *and* by `hypr/prefs.lua` |
| `$XDG_STATE_HOME/panama/panama-home.json` | Home accessory favourites and aliases |
| `$XDG_STATE_HOME/panama/backups/` | Settings snapshots |
| `$XDG_STATE_HOME/panama/hypridle.conf` | Generated idle config |
`SystemSettings.restoreDefaults()` spans all of them. A reset that silently
skipped one would be worse than having no reset, because nothing would say so.
## Not stored by Panama
Timezone and network time are read from and written to `timedatectl` directly.
They belong to the machine and are shared with sessions that never see Panama's
file; storing a copy would create a second answer to a question the system
already answers.
@@ -2,9 +2,12 @@ import QtQuick
import qs.config import qs.config
import qs.services import qs.services
Item { SettingsPage {
id: root id: root
title: "Screen Intelligence"
lede: "Turn text and codes on screen into content you can use."
Component.onCompleted: ScreenIntelligence.refresh() Component.onCompleted: ScreenIntelligence.refresh()
Timer { Timer {
@@ -13,98 +16,78 @@ Item {
onTriggered: Capture.openIntelligence() onTriggered: Capture.openIntelligence()
} }
Flickable { SettingsCard {
anchors.fill: parent title: "Read anything on screen"
clip: true subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions."
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { SettingRow {
id: content icon: "󰗊"
width: parent.width - 68 label: "Read screen text"
x: 34 detail: "Copy, search, translate, or open detected links"
y: 30 controlWidth: 160
spacing: 16 divider: false
Text { SettingsButton {
text: "Screen Intelligence" anchors.right: parent.right
color: Theme.fg anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily text: "Start reading"
font.pixelSize: 27 tone: "accent"
font.weight: Font.DemiBold enabled: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady
} onClicked: {
Text { ShellState.closeSettings();
text: "Turn text and codes on screen into content you can use." launchDelay.restart();
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
bottomPadding: 6
}
SettingsCard {
title: "Read anything on screen"
subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions."
SettingRow {
icon: "󰗊"
label: "Read screen text"
detail: "Copy, search, translate, or open detected links"
controlWidth: 160
divider: false
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Start reading"
tone: "accent"
enabled: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady
onClicked: {
ShellState.closeSettings();
launchDelay.restart();
}
}
}
}
SettingsCard {
title: "Local recognition"
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result."
SettingRow {
label: "Text recognition"
detail: "Tesseract with the English language model"
value: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady ? "Ready" : "Needs install"
}
SettingRow {
label: "QR & barcodes"
detail: "ZBar recognizes codes alongside ordinary text"
value: ScreenIntelligence.codeReady ? "Ready" : "Needs install"
}
SettingRow {
label: "Privacy"
detail: "Only Search, Translate, and Open send the selected result to another application or service"
value: "Local first"
divider: false
}
}
SettingsCard {
title: "Shortcut"
SettingRow {
label: "Read a screen selection"
detail: "Also available as Read in the Print-screen picker"
value: "Super + Shift + S"
controlWidth: 190
divider: ScreenIntelligence.ocrReady && ScreenIntelligence.codeReady && ScreenIntelligence.englishReady
}
SettingRow {
visible: !ScreenIntelligence.ocrReady || !ScreenIntelligence.codeReady || !ScreenIntelligence.englishReady
label: "Install recognition engines"
detail: "sudo dnf install -y tesseract zbar"
value: "Required once"
divider: false
} }
} }
} }
} }
SettingsCard {
title: "Capture preferences"
subtitle: "Choose where captures go and how recordings are encoded."
ChoiceRow { setting: "screenshotDir" }
ChoiceRow { setting: "recordingDir" }
ChoiceRow { setting: "recorderArgs"; divider: false }
}
SettingsCard {
title: "Local recognition"
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result."
TextRow {
label: "Text recognition"
detail: "Tesseract with the English language model"
value: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady ? "Ready" : "Needs install"
}
TextRow {
label: "QR & barcodes"
detail: "ZBar recognizes codes alongside ordinary text"
value: ScreenIntelligence.codeReady ? "Ready" : "Needs install"
}
TextRow {
label: "Privacy"
detail: "Only Search, Translate, and Open send the selected result to another application or service"
value: "Local first"
divider: false
}
}
SettingsCard {
title: "Shortcut"
TextRow {
label: "Read a screen selection"
detail: "Also available as Read in the Print-screen picker"
value: "Super + Shift + S"
controlWidth: 190
divider: ScreenIntelligence.ocrReady && ScreenIntelligence.codeReady && ScreenIntelligence.englishReady
}
TextRow {
visible: !ScreenIntelligence.ocrReady || !ScreenIntelligence.codeReady || !ScreenIntelligence.englishReady
label: "Install recognition engines"
detail: "sudo dnf install -y tesseract zbar"
value: "Required once"
divider: false
}
}
} }
@@ -2,89 +2,132 @@ import QtQuick
import qs.config import qs.config
import qs.services import qs.services
Item { SettingsPage {
function status(active: bool): string { return active ? "Running" : "Stopped"; } title: "Startup & Services"
lede: "A clear view of the background tools that make the desktop feel complete."
Flickable { function status(active: bool): string {
anchors.fill: parent return active ? "Running" : "Stopped";
clip: true }
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { Item {
id: content width: parent.width
width: parent.width - 68 implicitHeight: refresh.implicitHeight
x: 34
y: 30 SettingsButton {
spacing: 16 id: refresh
anchors.right: parent.right
text: SystemSettings.busy ? "Refreshing…" : "Refresh"
enabled: !SystemSettings.busy
onClicked: SystemSettings.refresh()
}
}
SettingsCard {
title: "Your services"
SettingRow {
label: "Nextcloud"
detail: "File synchronization and tray status"
controlWidth: 190
Row { Row {
width: parent.width anchors.right: parent.right
Text { width: parent.width - refresh.width; text: "Startup & Services"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } anchors.verticalCenter: parent.verticalCenter
SettingsButton { id: refresh; text: SystemSettings.busy ? "Refreshing…" : "Refresh"; enabled: !SystemSettings.busy; onClicked: SystemSettings.refresh() } spacing: 10
} Text {
Text { text: "A clear view of the background tools that make the desktop feel complete."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } anchors.verticalCenter: parent.verticalCenter
text: status(SystemSettings.nextcloudActive)
SettingsCard { color: Theme.fgDim
title: "Your services" font.family: Theme.fontFamily
SettingRow { font.pixelSize: Theme.fontSize
label: "Nextcloud"
detail: "File synchronization and tray status"
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.nextcloudActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("nextcloud") }
}
} }
SettingRow { SettingsButton {
label: "RustDesk" text: "Open"
detail: "Remote access through the enabled system service" onClicked: SystemSettings.openApplication("nextcloud")
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.rustdeskActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("rustdesk") }
}
}
SettingRow {
label: "KDE Connect"
detail: "Phone pairing, clipboard, files, and remote controls"
divider: false
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.kdeconnectActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("kdeconnect") }
}
} }
} }
}
SettingsCard { SettingRow {
title: "Desktop foundation" label: "RustDesk"
SettingRow { label: "Hyprpaper"; detail: "Wallpaper service"; value: status(SystemSettings.hyprpaperActive) } detail: "Remote access through the enabled system service"
SettingRow { label: "Hypridle"; detail: "Idle and lock policy"; value: status(SystemSettings.hypridleActive) } controlWidth: 190
SettingRow { label: "Vicinae"; detail: "Spotlight-style launcher daemon"; value: status(SystemSettings.vicinaeActive); divider: false }
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text {
anchors.verticalCenter: parent.verticalCenter
text: status(SystemSettings.rustdeskActive)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
text: "Open"
onClicked: SystemSettings.openApplication("rustdesk")
}
} }
}
SettingsCard { SettingRow {
title: "Fedora system settings" label: "KDE Connect"
subtitle: "These remain owned by trusted system services and GNOME's mature panels." detail: "Phone pairing, clipboard, files, and remote controls"
SettingRow { divider: false
label: "Network, Bluetooth, printers, users, and accounts" controlWidth: 190
detail: "GNOME Settings remains searchable from the launcher too"
divider: false Row {
controlWidth: 122 anchors.right: parent.right
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open network"; onClicked: SystemSettings.openGnomePanel("network") } anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text {
anchors.verticalCenter: parent.verticalCenter
text: status(SystemSettings.kdeconnectActive)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
text: "Open"
onClicked: SystemSettings.openApplication("kdeconnect")
} }
} }
} }
} }
SettingsCard {
title: "Desktop foundation"
TextRow {
label: "Hyprpaper"
detail: "Wallpaper service"
value: status(SystemSettings.hyprpaperActive)
}
TextRow {
label: "Hypridle"
detail: "Idle and lock policy"
value: status(SystemSettings.hypridleActive)
}
TextRow {
label: "Vicinae"
detail: "Spotlight-style launcher daemon"
value: status(SystemSettings.vicinaeActive)
divider: false
}
}
SettingsCard {
title: "Fedora system settings"
subtitle: "These remain owned by trusted system services and GNOME's mature panels."
ActionRow {
label: "Network, Bluetooth, printers, users, and accounts"
detail: "GNOME Settings remains searchable from the launcher too"
divider: false
action: "Open network"
onTriggered: SystemSettings.openGnomePanel("network")
}
}
} }
@@ -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()
}
} }
@@ -0,0 +1,80 @@
// The scaffold every settings page shares: a scrolling column with a title, a
// one-line lede, and consistent margins.
//
// This existed eleven times as copy-pasted Flickable/Column/x:34/y:30 blocks,
// which is a large part of why several pages settled for read-only text instead
// of real controls -- adding a page was expensive enough that the cheap thing
// won. Pages are now just their content:
//
// SettingsPage {
// title: "Appearance"
// lede: "Tokyo Night Moon, tuned for clarity and quiet motion."
//
// SettingsCard { title: "Windows"; ToggleRow { setting: "blurEnabled" } }
// }
import QtQuick
import qs.config
Item {
id: root
default property alias content: column.data
property string title: ""
property string lede: ""
// Anything that should sit above the title and stay put while the rest
// scrolls -- the Appearance page pins its live preview here.
property Component header: null
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: layout.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: layout
width: parent.width - 68
x: 34
y: 30
spacing: 16
Loader {
width: parent.width
active: root.header !== null
sourceComponent: root.header
}
Text {
width: parent.width
visible: root.title !== ""
text: root.title
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 27
font.weight: Font.DemiBold
}
Text {
width: parent.width
visible: root.lede !== ""
text: root.lede
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
wrapMode: Text.WordWrap
bottomPadding: 6
}
Column {
id: column
width: parent.width
spacing: 16
}
}
}
}
@@ -99,6 +99,10 @@ 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 "applications": return applicationsPage;
case "services": return servicesPage; case "services": return servicesPage;
case "about": return aboutPage; case "about": return aboutPage;
default: return homePage; default: return homePage;
@@ -136,6 +140,10 @@ Rectangle {
} }
Component { id: homePage; HomePage {} } Component { id: homePage; HomePage {} }
Component { id: applicationsPage; ApplicationsPage {} }
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,18 @@ 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)
onQueryChanged: {
if (sidebarScroll)
sidebarScroll.contentY = 0;
}
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 +32,10 @@ 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: "applications", label: "Applications", icon: "\u{F003B}" },
{ 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}" }
] ]
@@ -28,8 +45,15 @@ Rectangle {
border.width: 0 border.width: 0
Column { Column {
anchors.fill: parent id: sidebarHeader
anchors.margins: 18
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 18
anchors.rightMargin: 18
anchors.topMargin: 18
height: implicitHeight
spacing: 12 spacing: 12
Text { Text {
@@ -91,70 +115,175 @@ Rectangle {
onClicked: searchInput.forceActiveFocus() onClicked: searchInput.forceActiveFocus()
} }
} }
}
Flickable {
id: sidebarScroll
anchors.left: parent.left
anchors.right: parent.right
anchors.top: sidebarHeader.bottom
anchors.bottom: healthFooter.top
anchors.leftMargin: 18
anchors.rightMargin: 18
anchors.topMargin: 12
anchors.bottomMargin: 12
contentWidth: width
contentHeight: scrollContent.implicitHeight
flickableDirection: Flickable.VerticalFlick
boundsBehavior: Flickable.StopAtBounds
clip: true
Column { Column {
width: parent.width id: scrollContent
spacing: 4
Repeater { width: sidebarScroll.width
model: root.destinations.filter(item => root.query === "" || item.label.toLowerCase().includes(root.query))
Rectangle { // ── Search results ──────────────────────────────────────────────
id: navItem // Typing searches the settings themselves, not page names.
required property var modelData Column {
id: searchResults
width: parent.width
spacing: 3
visible: root.query !== ""
Text {
width: parent.width width: parent.width
height: 40 leftPadding: 4
radius: 10 bottomPadding: 4
color: modelData.page === root.selectedPage text: root.results.length === 0
? Theme.alpha(Theme.accent, 0.17) ? "Nothing matches"
: (navMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha * 0.55) : Theme.alpha(Theme.fg, 0)) : root.results.length + (root.results.length === 1 ? " result" : " results")
border.width: modelData.page === root.selectedPage ? 1 : 0 color: Theme.fgMuted
border.color: Theme.alpha(Theme.accent, 0.26) font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Repeater {
model: root.results
Rectangle { Rectangle {
width: 2 id: hit
height: 18
radius: 1 required property var modelData
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter width: parent.width
visible: navItem.modelData.page === root.selectedPage height: 44
gradient: Gradient { radius: 10
GradientStop { position: 0; color: Theme.accent } color: hitMouse.containsMouse ? Theme.alpha(Theme.fg, 0.08) : "transparent"
GradientStop { position: 1; color: Theme.accentSecondary } 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 = "";
}
} }
} }
}
}
Text { Column {
anchors.left: parent.left id: navigationList
anchors.leftMargin: 13
anchors.verticalCenter: parent.verticalCenter
width: 25
text: navItem.modelData.icon
color: navItem.modelData.page === root.selectedPage ? Theme.accent : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 15
}
Text { width: parent.width
anchors.left: parent.left spacing: 4
anchors.leftMargin: 47 visible: root.query === ""
anchors.right: parent.right
anchors.rightMargin: 9
anchors.verticalCenter: parent.verticalCenter
text: navItem.modelData.label
color: navItem.modelData.page === root.selectedPage ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: navItem.modelData.page === root.selectedPage ? Font.Medium : Font.Normal
elide: Text.ElideRight
}
MouseArea { Repeater {
id: navMouse model: root.destinations
anchors.fill: parent
hoverEnabled: true Rectangle {
cursorShape: Qt.PointingHandCursor id: navItem
onClicked: root.pageRequested(navItem.modelData.page) required property var modelData
width: parent.width
height: 40
radius: 10
color: modelData.page === root.selectedPage
? Theme.alpha(Theme.accent, 0.17)
: (navMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha * 0.55) : Theme.alpha(Theme.fg, 0))
border.width: modelData.page === root.selectedPage ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.26)
Rectangle {
width: 2
height: 18
radius: 1
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
visible: navItem.modelData.page === root.selectedPage
gradient: Gradient {
GradientStop { position: 0; color: Theme.accent }
GradientStop { position: 1; color: Theme.accentSecondary }
}
}
Text {
anchors.left: parent.left
anchors.leftMargin: 13
anchors.verticalCenter: parent.verticalCenter
width: 25
text: navItem.modelData.icon
color: navItem.modelData.page === root.selectedPage ? Theme.accent : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 15
}
Text {
anchors.left: parent.left
anchors.leftMargin: 47
anchors.right: parent.right
anchors.rightMargin: 9
anchors.verticalCenter: parent.verticalCenter
text: navItem.modelData.label
color: navItem.modelData.page === root.selectedPage ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: navItem.modelData.page === root.selectedPage ? Font.Medium : Font.Normal
elide: Text.ElideRight
}
MouseArea {
id: navMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.pageRequested(navItem.modelData.page)
}
} }
} }
} }
@@ -162,6 +291,8 @@ Rectangle {
} }
Rectangle { Rectangle {
id: healthFooter
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
@@ -0,0 +1,132 @@
// Captures a key chord for rebinding.
//
// Shown in place of a shortcut's chord while it is being changed. It takes
// keyboard focus, waits for a non-modifier key, and reports the chord in the
// form hypr/keybinds.lua uses.
//
// Modifier-only presses are ignored rather than accepted, because every press
// of a chord passes through them: holding Super to type Super+K would otherwise
// be captured as "SUPER" the moment the modifier went down.
//
// Escape cancels. Not every Qt key has a keysym name Hyprland would accept, so
// an unmapped key is refused with a message rather than written as something
// that would silently fail to bind.
import QtQuick
import qs.config
FocusScope {
id: root
signal captured(string chord)
signal cancelled
property string message: ""
implicitWidth: 230
implicitHeight: 30
// Qt key codes to the keysym names Hyprland expects. Letters and digits
// fall out of the ASCII range; these are the rest that come up in practice.
readonly property var namedKeys: ({
0x01000000: "Escape",
0x01000001: "Tab",
0x01000004: "Return",
0x01000005: "Return",
0x01000003: "BackSpace",
0x01000006: "Insert",
0x01000007: "Delete",
0x01000010: "Home",
0x01000011: "End",
0x01000016: "Page_Up",
0x01000017: "Page_Down",
0x01000012: "left",
0x01000013: "up",
0x01000014: "right",
0x01000015: "down",
0x20: "space",
0x2c: "comma",
0x2e: "period",
0x2f: "slash",
0x3b: "semicolon",
0x27: "apostrophe",
0x5b: "bracketleft",
0x5d: "bracketright",
0x5c: "backslash",
0x60: "grave",
0x2d: "minus",
0x3d: "equal",
0x01000009: "Print"
})
function keysymFor(key: int): string {
if (key >= 0x41 && key <= 0x5a) // A-Z
return String.fromCharCode(key);
if (key >= 0x30 && key <= 0x39) // 0-9
return String.fromCharCode(key);
if (key >= 0x01000030 && key <= 0x0100003b) // F1-F12
return "F" + (key - 0x01000030 + 1);
return root.namedKeys[key] ?? "";
}
function isModifierOnly(key: int): bool {
return key === 0x01000020 || key === 0x01000021 || key === 0x01000022
|| key === 0x01000023 || key === 0x01000024;
}
Rectangle {
anchors.fill: parent
radius: 9
color: Theme.alpha(Theme.accent, 0.14)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.5)
Text {
anchors.centerIn: parent
width: parent.width - 16
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
text: root.message !== "" ? root.message : "Press a shortcut… Esc to cancel"
color: root.message !== "" ? Theme.warn : Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Keys.onPressed: event => {
event.accepted = true;
if (event.key === 0x01000000) { // Escape
root.cancelled();
return;
}
if (root.isModifierOnly(event.key))
return;
const keysym = root.keysymFor(event.key);
if (keysym === "") {
root.message = "That key cannot be used";
return;
}
const parts = [];
if (event.modifiers & Qt.MetaModifier) parts.push("SUPER");
if (event.modifiers & Qt.ControlModifier) parts.push("CTRL");
if (event.modifiers & Qt.AltModifier) parts.push("ALT");
if (event.modifiers & Qt.ShiftModifier) parts.push("SHIFT");
if (parts.length === 0 && keysym.length === 1) {
// A bare letter or digit would swallow ordinary typing.
root.message = "Add a modifier";
return;
}
parts.push(keysym);
root.captured(parts.join(" + "));
}
onActiveFocusChanged: {
if (!activeFocus)
root.cancelled();
}
}
@@ -1,73 +1,179 @@
// Input & Shortcuts.
//
// The shortcut list is generated from `hyprctl binds -j` rather than typed out
// here. The previous version was a hand-maintained array of nineteen entries
// against a real keymap of a hundred and thirteen: it could not show the rest,
// and it went stale the moment a bind changed. Every bind now carries its own
// description in hypr/keybinds.lua, and this page just groups and renders them.
//
// The hardware settings above the list are real controls. Keyboard layout,
// repeat behaviour, and pointer response are Hyprland's, so Panama owns them;
// device-specific configuration stays with GNOME.
import QtQuick import QtQuick
import qs.config import qs.config
import qs.services import qs.services
Item { SettingsPage {
id: root id: root
readonly property var shortcuts: [
{ key: "Super + T", action: "Terminal" },
{ key: "Super + N", action: "Neovim in the current directory" },
{ key: "Super + W", action: "Web browser" },
{ key: "Super + F", action: "Files" },
{ key: "Super + I", action: "Panama Settings" },
{ key: "Super + Space", action: "Launcher" },
{ key: "Super + Grave", action: "Continuum overview" },
{ key: "Super + V", action: "Clipboard history" },
{ key: "Super + S", action: "Quick Settings" },
{ key: "Super + B", action: "Notifications" },
{ key: "Super + Shift + S", action: "Screen Intelligence" },
{ key: "Super + Shift + F", action: "Focus session" },
{ key: "Alt + H / L", action: "Previous / next workspace" },
{ key: "Alt + Shift + H / L", action: "Move window between workspaces" },
{ key: "Super + H / J / K / L", action: "Focus a tiled window" },
{ key: "Super + Shift + H / J / K / L", action: "Move a tiled window" },
{ key: "Super + Shift + X", action: "Send window to scratchpad" },
{ key: "Super + X", action: "Toggle scratchpad" },
{ key: "Print", action: "Capture, record, or read" }
]
Flickable { // The chord of the bind currently being re-recorded, in the Lua form; empty
anchors.fill: parent // when nothing is being captured. Held here rather than per row so that
clip: true // starting a new capture cancels any other.
contentWidth: width property string capturingChord: ""
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { title: "Input & Shortcuts"
id: content lede: "The Forge mental model, carried forward into native tiling."
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text { text: "Input & Shortcuts"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } SettingsCard {
Text { text: "The Forge mental model, carried forward into native tiling."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } title: "Keyboard"
SettingsCard { SliderRow { setting: "keyRepeatDelay" }
title: "Panama shortcuts" SliderRow { setting: "keyRepeatRate" }
Repeater { ToggleRow { setting: "numlockByDefault"; divider: false }
model: root.shortcuts }
SettingRow {
required property var modelData SettingsCard {
required property int index title: "Pointer"
label: modelData.action
value: modelData.key ChoiceRow { setting: "followMouse" }
controlWidth: 210 SliderRow { setting: "pointerSensitivity" }
divider: index < root.shortcuts.length - 1 SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
} }
}
} SettingsCard {
title: "Hardware input"
ActionRow {
label: "Mouse, touchpad, and keyboard devices"
detail: "Device-specific settings stay with Fedora's hardware-backed panels"
action: "Open keyboard"
divider: false
onTriggered: SystemSettings.openGnomePanel("keyboard")
}
}
// One card per group, built from what the compositor actually has bound.
//
// Both Repeaters address their model through an explicit id. Inside a
// SettingsCard the surrounding `parent` is the card's internal Column, not
// the card, so `parent.modelData` is undefined there and the rows silently
// never appear -- the cards render with their heading and nothing under it.
Repeater {
model: Keybinds.grouped()
SettingsCard {
id: groupCard
required property var modelData
title: groupCard.modelData.name
subtitle: groupCard.modelData.binds.length === 1
? "1 shortcut"
: `${groupCard.modelData.binds.length} shortcuts`
Repeater {
id: bindRows
model: groupCard.modelData.binds
SettingsCard {
title: "Hardware input"
SettingRow { SettingRow {
label: "Mouse, touchpad, and keyboard devices" id: bindRow
detail: "Use Fedora's hardware-backed input panels"
divider: false required property var modelData
controlWidth: 126 required property int index
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open keyboard"; onClicked: SystemSettings.openGnomePanel("keyboard") }
readonly property bool capturing: root.capturingChord === bindRow.modelData.luaChord
readonly property bool overridden: Keybinds.isOverridden(bindRow.modelData.luaChord)
label: bindRow.modelData.description
detail: bindRow.overridden
? "Moved from " + Keybinds.shippedChordFor(bindRow.modelData.luaChord)
: ""
controlWidth: 300
divider: bindRow.index < bindRows.count - 1
Item {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 300
height: 30
ShortcutCapture {
anchors.right: parent.right
width: 230
height: 30
visible: bindRow.capturing
focus: bindRow.capturing
onCaptured: chord => {
Keybinds.rebind(bindRow.modelData.luaChord, chord);
root.capturingChord = "";
}
onCancelled: root.capturingChord = ""
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: !bindRow.capturing
spacing: 8
Text {
anchors.verticalCenter: parent.verticalCenter
text: bindRow.modelData.chord
color: bindRow.overridden ? Theme.accent : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: "Change"
enabled: !Keybinds.reloading && !bindRow.modelData.mouse
onClicked: root.capturingChord = bindRow.modelData.luaChord
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: bindRow.overridden
text: "Reset"
enabled: !Keybinds.reloading
onClicked: Keybinds.resetBind(bindRow.modelData.luaChord)
}
}
}
} }
} }
} }
} }
SettingsCard {
visible: Object.keys(Keybinds.overrides).length > 0
title: "Changed shortcuts"
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from Panama's configuration."
ActionRow {
label: "Restore every shipped shortcut"
detail: Object.keys(Keybinds.overrides).length
+ (Object.keys(Keybinds.overrides).length === 1 ? " shortcut moved" : " shortcuts moved")
action: "Restore all"
divider: false
enabled: !Keybinds.reloading
onTriggered: Keybinds.resetAll()
}
}
SettingsCard {
visible: Keybinds.lastError !== ""
title: "Shortcuts unavailable"
subtitle: Keybinds.lastError
ActionRow {
label: "Read the keymap again"
detail: "Shortcuts are read from the running compositor"
action: "Retry"
divider: false
onTriggered: Keybinds.refresh()
}
}
} }
@@ -0,0 +1,193 @@
// A numeric setting, bound to a schema key by name.
//
// SliderRow { setting: "windowRounding" }
//
// Range, step, label, explanation, and unit all come from PreferenceSchema.
//
// Three behaviours worth knowing:
//
// * The row is responsive. The Settings window is a normal tiled window, so
// its width is whatever the layout gives it -- anywhere from a half-screen
// split to the full 4500px display, and `implicitWidth` is only a hint.
// Below a usable inline width the slider moves onto its own line under the
// label instead of squeezing the explanation into a five-line column.
// * The readout follows the drag immediately, but the value is only committed
// after a short quiet period. Compositor-backed settings are applied and
// verified one batch at a time, and a slider fires dozens of changes per
// second -- committing each would spend the whole drag rejecting
// overlapping writes.
// * Between commits the row shows what you are dragging; once settled it
// shows what is actually stored. If the compositor refuses a value the row
// falls back to the stored one rather than displaying a value nothing
// accepted.
//
// This does not extend SettingRow: that component fixes the control to a
// trailing column of a set width, which is the layout this row needs to be able
// to abandon. The label, explanation, and divider match it exactly.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Item {
id: root
required property string setting
property bool divider: true
property string zeroLabel: ""
readonly property var spec: PreferenceSchema.spec(root.setting)
readonly property string label: root.spec ? root.spec.label : root.setting
readonly property string detail: root.spec ? root.spec.detail : ""
readonly property real minimum: root.spec && root.spec.min !== undefined ? root.spec.min : 0
readonly property real maximum: root.spec && root.spec.max !== undefined ? root.spec.max : 100
readonly property real step: root.spec && root.spec.step !== undefined ? root.spec.step : 1
readonly property string unit: root.spec && root.spec.unit !== undefined ? root.spec.unit : ""
readonly property real stored: {
const value = DesktopPreferences.get(root.setting);
return typeof value === "number" ? value : root.minimum;
}
// Shown while dragging; -1 means "nothing pending, show what is stored".
property real pending: -1
readonly property real shown: root.pending >= 0 ? root.pending : root.stored
// Below this the label and a usable slider cannot share a line without one
// of them becoming useless.
readonly property bool inline: width >= 520
// A fixed trailing width rather than a share of the row. A proportional
// control looks reasonable at 900px and absurd at 4500px, where the slider
// would be a metre long next to a two-word label -- and this window is
// tiled, so it really can be that wide.
readonly property int controlSpan: 300
width: parent ? parent.width : 620
implicitHeight: root.inline
? Math.max(56, copy.implicitHeight + 20)
: copy.implicitHeight + 32 + 30
function quantise(ratio: real): real {
const raw = root.minimum + ratio * (root.maximum - root.minimum);
const snapped = Math.round(raw / root.step) * root.step;
const clamped = Math.max(root.minimum, Math.min(root.maximum, snapped));
// Steps below 1 are fractional (opacity, pointer speed); rounding to two
// places keeps 0.8500000000000001 out of the readout and the store.
return root.step < 1 ? Math.round(clamped * 100) / 100 : clamped;
}
function display(value: real): string {
if (value === 0 && root.zeroLabel !== "")
return root.zeroLabel;
const text = root.step < 1 ? value.toFixed(2) : String(value);
return root.unit === "" ? text : `${text} ${root.unit}`;
}
// Both children are positioned explicitly rather than by anchors. Binding
// an anchor to `undefined` to switch layouts does not reliably release it,
// which left the slider anchored to both edges and the label squeezed into
// whatever was left.
Column {
id: copy
x: 0
y: root.inline ? (root.height - height) / 2 : 10
width: root.inline ? root.width - root.controlSpan - 20 : root.width
spacing: 3
Text {
width: parent.width
text: root.label
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
visible: root.detail !== ""
text: root.detail
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
Item {
id: control
width: root.inline ? root.controlSpan : root.width
height: 32
x: root.inline ? root.width - width : 0
y: root.inline ? (root.height - height) / 2 : copy.y + copy.height + 10
ValueSlider {
id: slider
anchors.left: parent.left
anchors.right: readout.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
value: root.maximum > root.minimum
? (root.shown - root.minimum) / (root.maximum - root.minimum)
: 0
onMoved: ratio => {
root.pending = root.quantise(ratio);
commitTimer.restart();
}
}
Text {
id: readout
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 58
horizontalAlignment: Text.AlignRight
text: root.display(root.shown)
color: Theme.fgDim
font.family: Theme.fontFamily
// The readout changes digit by digit under the pointer; tabular
// figures stop it twitching sideways as it does.
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
elide: Text.ElideRight
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
visible: root.divider
color: Theme.alpha(Theme.fg, 0.065)
}
Timer {
id: commitTimer
interval: 140
onTriggered: {
if (root.pending < 0)
return;
SystemSettings.commitPreference(root.setting, root.pending);
// Hand the display back to the stored value. If the write was
// refused, the row snaps back to what is really in effect.
releaseTimer.restart();
}
}
Timer {
id: releaseTimer
interval: 160
onTriggered: root.pending = -1
}
}
@@ -4,50 +4,61 @@ import qs.config
import qs.services import qs.services
import qs.modules.quicksettings import qs.modules.quicksettings
Item { SettingsPage {
Flickable { title: "Sound"
anchors.fill: parent lede: "Live PipeWire output, input, and device selection."
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { SettingsCard {
id: content title: "Output"
width: parent.width - 68 subtitle: Pipewire.defaultAudioSink?.description ?? "No output device"
x: 34
y: 30
spacing: 16
Text { text: "Sound"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } AudioSlider {
Text { text: "Live PipeWire output, input, and device selection."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } width: parent.width
node: Pipewire.defaultAudioSink
output: true
}
Rectangle {
width: parent.width
height: 1
color: Theme.alpha(Theme.fg, 0.06)
}
AudioDeviceList {
width: parent.width
output: true
maxHeight: 190
}
}
SettingsCard { SettingsCard {
title: "Output" title: "Input"
subtitle: Pipewire.defaultAudioSink?.description ?? "No output device" subtitle: Pipewire.defaultAudioSource?.description ?? "No input device"
AudioSlider { width: parent.width; node: Pipewire.defaultAudioSink; output: true }
Rectangle { width: parent.width; height: 1; color: Theme.alpha(Theme.fg, 0.06) }
AudioDeviceList { width: parent.width; output: true; maxHeight: 190 }
}
SettingsCard { AudioSlider {
title: "Input" width: parent.width
subtitle: Pipewire.defaultAudioSource?.description ?? "No input device" node: Pipewire.defaultAudioSource
AudioSlider { width: parent.width; node: Pipewire.defaultAudioSource; output: false } output: false
Rectangle { width: parent.width; height: 1; color: Theme.alpha(Theme.fg, 0.06) } }
AudioDeviceList { width: parent.width; output: false; maxHeight: 160 } Rectangle {
} width: parent.width
height: 1
color: Theme.alpha(Theme.fg, 0.06)
}
AudioDeviceList {
width: parent.width
output: false
maxHeight: 160
}
}
SettingsCard { SettingsCard {
title: "Advanced sound" title: "Advanced sound"
SettingRow {
label: "Application volumes and profiles" ActionRow {
detail: "Open Fedora's complete sound panel" label: "Application volumes and profiles"
divider: false detail: "Open Fedora's complete sound panel"
controlWidth: 104 divider: false
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("sound") } action: "Open panel"
} onTriggered: SystemSettings.openGnomePanel("sound")
}
} }
} }
} }
@@ -0,0 +1,19 @@
// A row that only reports something.
//
// TextRow { label: "Graphics"; detail: "Rendering device"; value: "AMD Radeon" }
//
// Genuinely read-only facts -- the active display's identity, a version string,
// a detected capability -- belong here. A setting the user could reasonably
// change does NOT: before Stage 3 more than half of Panama's settings rows were
// static text standing in for a control that was simply expensive to add, and
// this component exists for the honest remainder rather than to make that easy
// to do again.
import QtQuick
import qs.config
SettingRow {
id: root
controlWidth: 210
}
@@ -0,0 +1,33 @@
// A boolean setting, bound to a schema key by name.
//
// ToggleRow { setting: "blurEnabled" }
//
// Label, explanation, and validation all come from PreferenceSchema, so a row
// cannot drift from the setting it edits, and the writing side does not care
// whether the key is stored locally or applied to the compositor first.
// Override `label` or `detail` only when a page needs different wording than
// the schema's default.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
required property string setting
readonly property var spec: PreferenceSchema.spec(root.setting)
readonly property bool checked: DesktopPreferences.get(root.setting) === true
label: root.spec ? root.spec.label : root.setting
detail: root.spec ? root.spec.detail : ""
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.checked
onToggled: value => SystemSettings.commitPreference(root.setting, value)
}
}
@@ -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
}
}
@@ -20,3 +20,18 @@ SettingsToggle 1.0 SettingsToggle.qml
SettingsWindow 1.0 SettingsWindow.qml SettingsWindow 1.0 SettingsWindow.qml
ShortcutsPage 1.0 ShortcutsPage.qml ShortcutsPage 1.0 ShortcutsPage.qml
SoundPage 1.0 SoundPage.qml SoundPage 1.0 SoundPage.qml
SettingsPage 1.0 SettingsPage.qml
ToggleRow 1.0 ToggleRow.qml
SliderRow 1.0 SliderRow.qml
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
ApplicationsPage 1.0 ApplicationsPage.qml
DockPinsEditor 1.0 DockPinsEditor.qml
DockAppPicker 1.0 DockAppPicker.qml
ShortcutCapture 1.0 ShortcutCapture.qml
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Read and update freedesktop defaults for Panama's settings page."""
from __future__ import annotations
import ast
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
ROLE_TARGETS = {
"browser": ("settings", "default-web-browser"),
"mail": ("mime", "x-scheme-handler/mailto"),
"files": ("mime", "inode/directory"),
"terminal": ("mime", "x-scheme-handler/terminal"),
"music": ("mime", "audio/mpeg"),
"images": ("mime", "image/png"),
"video": ("mime", "video/mp4"),
}
DESKTOP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$")
EXEC_CMD = re.compile(r"hl\.exec_cmd\(\s*(\"(?:\\.|[^\"\\])*\")\s*\)")
class BoundaryError(RuntimeError):
"""A user-visible validation or command failure."""
def xdg_data_roots() -> list[Path]:
data_home = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share"))
data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
return [data_home, *(Path(item) for item in data_dirs.split(":") if item)]
def discovered_desktop_ids() -> set[str]:
desktop_ids: set[str] = set()
for root in xdg_data_roots():
applications = root / "applications"
if not applications.is_dir():
continue
for path in applications.rglob("*.desktop"):
if not path.is_file():
continue
relative = path.relative_to(applications)
desktop_ids.add("-".join(relative.parts))
return desktop_ids
def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None:
if not DESKTOP_ID.fullmatch(desktop_id) or desktop_id not in discovered:
raise BoundaryError("That application is not available.")
def run(command: list[str]) -> str:
completed = subprocess.run(command, check=False, capture_output=True, text=True)
if completed.returncode != 0:
detail = completed.stderr.strip()
raise BoundaryError(detail or "The system default could not be updated.")
return completed.stdout.strip()
def query_handlers() -> dict[str, str]:
handlers: dict[str, str] = {}
for role, (kind, target) in ROLE_TARGETS.items():
command = (
["xdg-settings", "get", target]
if kind == "settings"
else ["xdg-mime", "query", "default", target]
)
output = run(command)
handlers[role] = output.splitlines()[0] if output else ""
return handlers
def parse_desktop_entry(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
section = ""
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError) as error:
raise BoundaryError(f"Could not read {path.name}.") from error
for line in lines:
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
section = stripped[1:-1]
continue
if section != "Desktop Entry" or "=" not in line or stripped.startswith("#"):
continue
key, value = line.split("=", 1)
values.setdefault(key.strip(), value.strip())
return values
def autostart_directory() -> Path:
config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
return config_home / "autostart"
def user_autostart_entries() -> list[dict[str, object]]:
directory = autostart_directory()
if not directory.is_dir():
return []
entries: list[dict[str, object]] = []
for path in directory.glob("*.desktop"):
if path.is_symlink() or not path.is_file():
continue
values = parse_desktop_entry(path)
entries.append(
{
"id": path.name,
"name": values.get("Name", path.stem),
"enabled": values.get("Hidden", "false").lower() != "true",
}
)
return sorted(entries, key=lambda entry: (str(entry["name"]).casefold(), str(entry["id"])))
def hypr_autostart_path() -> Path:
override = os.environ.get("PANAMA_HYPR_AUTOSTART")
if override:
return Path(override)
return Path(__file__).resolve().parents[2] / "hypr" / "autostart.lua"
def lua_autostart_entries() -> list[dict[str, object]]:
path = hypr_autostart_path()
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError):
return []
commands: list[str] = []
in_start_handler = False
for line in lines:
if not in_start_handler:
in_start_handler = bool(re.search(r'hl\.on\(\s*"hyprland\.start"', line))
continue
if line.strip() == "end)":
break
match = EXEC_CMD.search(line)
if match:
try:
commands.append(ast.literal_eval(match.group(1)))
except (SyntaxError, ValueError):
continue
return [
{
"id": f"hyprland:{index}",
"name": command.split()[0].rsplit("/", 1)[-1],
"command": command,
"enabled": True,
"readOnly": True,
"source": "config/dot/hypr/autostart.lua",
}
for index, command in enumerate(commands, start=1)
]
def snapshot() -> dict[str, object]:
return {
"handlers": query_handlers(),
"autostartEntries": user_autostart_entries(),
"luaAutostartEntries": lua_autostart_entries(),
}
def set_default(role: str, desktop_id: str) -> None:
target = ROLE_TARGETS.get(role)
if target is None:
raise BoundaryError("That default application role is not supported.")
require_desktop_id(desktop_id, discovered=discovered_desktop_ids())
kind, setting = target
command = (
["xdg-settings", "set", setting, desktop_id]
if kind == "settings"
else ["xdg-mime", "default", desktop_id, setting]
)
run(command)
def update_hidden(path: Path, *, hidden: bool) -> None:
try:
original = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise BoundaryError("That autostart entry could not be read.") from error
lines = original.splitlines()
output: list[str] = []
section = ""
found_section = False
wrote_hidden = False
for line in lines:
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
if section == "Desktop Entry" and not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}")
wrote_hidden = True
section = stripped[1:-1]
found_section = found_section or section == "Desktop Entry"
output.append(line)
continue
if section == "Desktop Entry" and line.split("=", 1)[0].strip() == "Hidden":
if not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}")
wrote_hidden = True
continue
output.append(line)
if not found_section:
raise BoundaryError("That autostart entry is not a desktop file.")
if not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}")
mode = path.stat().st_mode
try:
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
) as temporary:
temporary.write("\n".join(output) + "\n")
temporary.flush()
os.fsync(temporary.fileno())
temporary_path = Path(temporary.name)
temporary_path.chmod(mode)
os.replace(temporary_path, path)
except OSError as error:
if "temporary_path" in locals():
temporary_path.unlink(missing_ok=True)
raise BoundaryError("That autostart entry could not be updated.") from error
def set_autostart(desktop_id: str, enabled_text: str) -> None:
if enabled_text not in {"true", "false"}:
raise BoundaryError("Autostart state must be true or false.")
if not DESKTOP_ID.fullmatch(desktop_id):
raise BoundaryError("That autostart entry is not available.")
directory = autostart_directory()
path = directory / desktop_id
try:
resolved_directory = directory.resolve(strict=True)
resolved_path = path.resolve(strict=True)
except OSError as error:
raise BoundaryError("That autostart entry is not available.") from error
if path.is_symlink() or resolved_path.parent != resolved_directory or not resolved_path.is_file():
raise BoundaryError("That autostart entry is not available.")
update_hidden(resolved_path, hidden=enabled_text == "false")
def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
elif len(arguments) == 3 and arguments[0] == "set-default":
set_default(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-autostart":
set_autostart(arguments[1], arguments[2])
else:
raise BoundaryError(
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | "
"set-autostart DESKTOP_ID true|false"
)
except BoundaryError as error:
print(str(error), file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+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
+625
View File
@@ -0,0 +1,625 @@
#!/usr/bin/env python3
"""Crash-safe snapshots of Panama's desktop and Home preference stores.
A restore is a two-file transaction. Its fixed journal and artifacts live at
`$XDG_STATE_HOME/panama/transactions/settings-restore`; they contain no
caller-provided paths. The journal is fsynced before either destination changes
and is removed only after both replacements are durable. Every invocation
recovers an incomplete transaction before doing any other work.
"""
from __future__ import annotations
import json
import os
import re
import stat
import sys
import tempfile
import time
import fcntl
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Any, Iterator, NoReturn
HOME = Path(os.environ.get("HOME", str(Path.home())))
CONFIG_ROOT = Path(os.environ.get("XDG_CONFIG_HOME", str(HOME / ".config")))
STATE_ROOT = Path(os.environ.get("XDG_STATE_HOME", str(HOME / ".local/state")))
SETTINGS = CONFIG_ROOT / "panama/settings.json"
HOME_STATE = STATE_ROOT / "panama/panama-home.json"
BACKUP_DIR = STATE_ROOT / "panama/backups"
TRANSACTION_PARENT = STATE_ROOT / "panama/transactions"
TRANSACTION_DIR = TRANSACTION_PARENT / "settings-restore"
JOURNAL = TRANSACTION_DIR / "journal.json"
LOCK_FILE = TRANSACTION_PARENT / "settings-backup.lock"
KEEP = 15
SNAPSHOT_RE = re.compile(r"^settings-[0-9]{8}-[0-9]{9}\.json$")
ENTITY_RE = re.compile(r"^light\.[a-z0-9_]+$")
class BackupError(RuntimeError):
pass
def fail(message: str) -> NoReturn:
raise BackupError(message)
def fsync_directory(path: Path) -> None:
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def ensure_directory(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
if path.is_symlink() or not path.is_dir():
fail(f"{path} is not a safe directory.")
@contextmanager
def process_lock() -> Iterator[None]:
ensure_directory(TRANSACTION_PARENT)
if LOCK_FILE.is_symlink():
fail("The settings transaction lock is a symbolic link.")
descriptor = os.open(
LOCK_FILE,
os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0),
0o600,
)
try:
os.fchmod(descriptor, 0o600)
fcntl.flock(descriptor, fcntl.LOCK_EX)
yield
finally:
fcntl.flock(descriptor, fcntl.LOCK_UN)
os.close(descriptor)
def is_present(path: Path) -> bool:
return path.exists() or path.is_symlink()
def require_regular(path: Path, label: str) -> None:
if path.is_symlink():
fail(f"{label} is a symbolic link and cannot be used safely.")
try:
mode = path.stat().st_mode
except FileNotFoundError:
fail(f"{label} is missing.")
if not stat.S_ISREG(mode):
fail(f"{label} is not a regular file.")
def read_json(path: Path, label: str) -> dict[str, Any]:
require_regular(path, label)
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise BackupError(f"{label} is not valid JSON.") from error
if not isinstance(value, dict):
fail(f"{label} is not a JSON object.")
return value
def valid_home(value: Any) -> bool:
if not isinstance(value, dict):
return False
initialized = value.get("initialized")
favorites = value.get("favorites")
if not isinstance(initialized, bool) or not isinstance(favorites, list):
return False
if not initialized and favorites:
return False
seen: set[str] = set()
for favorite in favorites:
if not isinstance(favorite, dict):
return False
entity_id = favorite.get("id")
alias = favorite.get("alias")
if (
not isinstance(entity_id, str)
or ENTITY_RE.fullmatch(entity_id) is None
or not isinstance(alias, str)
or entity_id in seen
):
return False
seen.add(entity_id)
return True
def validate_home(value: Any, label: str) -> dict[str, Any]:
if not valid_home(value):
fail(f"{label} does not contain valid Home favourites.")
return value
def json_bytes(value: Any) -> bytes:
return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
def atomic_write_bytes(path: Path, content: bytes) -> None:
ensure_directory(path.parent)
if path.is_symlink():
fail(f"{path} is a symbolic link and cannot be replaced safely.")
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
temporary = Path(temporary_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
fsync_directory(path.parent)
finally:
if temporary.exists() or temporary.is_symlink():
temporary.unlink()
def atomic_write_json(path: Path, value: Any) -> None:
atomic_write_bytes(path, json_bytes(value))
def durable_remove(path: Path) -> None:
if path.exists() or path.is_symlink():
path.unlink()
fsync_directory(path.parent)
def transaction_path(name: str) -> Path:
if name not in {
"journal.json",
"desktop.old",
"desktop.new",
"home.old",
"home.new",
}:
fail("The restore transaction contains an unknown artifact name.")
path = TRANSACTION_DIR / name
resolved_parent = path.parent.resolve(strict=False)
if resolved_parent != TRANSACTION_DIR.resolve(strict=False):
fail("The restore transaction escaped its contained state directory.")
return path
def clean_transaction_artifacts() -> None:
if not TRANSACTION_DIR.exists() and not TRANSACTION_DIR.is_symlink():
return
if TRANSACTION_DIR.is_symlink() or not TRANSACTION_DIR.is_dir():
fail("The restore transaction path is not a safe directory.")
for child in list(TRANSACTION_DIR.iterdir()):
if child.name not in {
"journal.json",
"desktop.old",
"desktop.new",
"home.old",
"home.new",
} and not child.name.startswith(".journal.json."):
fail("The restore transaction directory contains an unknown artifact.")
if child.is_dir() and not child.is_symlink():
fail("The restore transaction contains an unexpected directory.")
child.unlink()
fsync_directory(TRANSACTION_DIR)
TRANSACTION_DIR.rmdir()
fsync_directory(TRANSACTION_PARENT)
def clean_stale_atomic_files() -> None:
locations = (
(SETTINGS.parent, (".settings.json.",)),
(HOME_STATE.parent, (".panama-home.json.",)),
(BACKUP_DIR, (".settings-",)),
)
for directory, prefixes in locations:
if not directory.exists():
continue
if directory.is_symlink() or not directory.is_dir():
fail(f"{directory} is not a safe directory.")
changed = False
for child in directory.iterdir():
if not any(child.name.startswith(prefix) for prefix in prefixes):
continue
# Only Panama's hidden atomic-write names are eligible. A matching
# directory is unexpected and is never recursively removed.
if child.is_dir() and not child.is_symlink():
fail("A stale settings temporary path is an unexpected directory.")
child.unlink()
changed = True
if changed:
fsync_directory(directory)
def validate_journal_side(value: Any) -> dict[str, bool]:
if not isinstance(value, dict):
fail("The restore journal is malformed.")
if set(value) != {"touch", "oldPresent", "newPresent"}:
fail("The restore journal is malformed.")
if not all(isinstance(value[key], bool) for key in value):
fail("The restore journal is malformed.")
return value
def read_journal() -> dict[str, Any]:
value = read_json(JOURNAL, "The restore journal")
if set(value) != {"version", "desktop", "home"} or value.get("version") != 1:
fail("The restore journal uses an unsupported format.")
return {
"version": 1,
"desktop": validate_journal_side(value.get("desktop")),
"home": validate_journal_side(value.get("home")),
}
def target_for(store: str) -> Path:
if store == "desktop":
return SETTINGS
if store == "home":
return HOME_STATE
fail("The restore journal names an unknown store.")
def apply_artifact(store: str, generation: str, present: bool) -> None:
target = target_for(store)
if present:
artifact = transaction_path(f"{store}.{generation}")
require_regular(artifact, "A restore transaction artifact")
atomic_write_bytes(target, artifact.read_bytes())
else:
ensure_directory(target.parent)
if target.is_symlink():
fail(f"{target} is a symbolic link and cannot be replaced safely.")
durable_remove(target)
def recover_transaction() -> None:
ensure_directory(TRANSACTION_PARENT)
if not TRANSACTION_DIR.exists() and not TRANSACTION_DIR.is_symlink():
return
if TRANSACTION_DIR.is_symlink() or not TRANSACTION_DIR.is_dir():
fail("The restore transaction path is not a safe directory.")
if not JOURNAL.exists() and not JOURNAL.is_symlink():
clean_transaction_artifacts()
return
journal = read_journal()
for store in ("desktop", "home"):
side = journal[store]
if side["touch"]:
apply_artifact(store, "old", side["oldPresent"])
# Journal absence is the durable commit marker for recovery too. If a
# second power loss occurs above, the journal remains and recovery retries.
durable_remove(JOURNAL)
clean_transaction_artifacts()
def is_v2_side(value: Any) -> bool:
return (
isinstance(value, dict)
and isinstance(value.get("present"), bool)
and (not value["present"] or isinstance(value.get("data"), dict))
)
def is_v2_envelope(value: Any) -> bool:
return (
isinstance(value, dict)
and value.get("version") == 2
and is_v2_side(value.get("desktop"))
and is_v2_side(value.get("home"))
)
def validate_snapshot(value: dict[str, Any]) -> tuple[str, dict[str, Any]]:
if not is_v2_envelope(value):
return "legacy", value
if value["home"]["present"]:
validate_home(value["home"]["data"], "That snapshot")
return "versioned", value
def current_store(path: Path, label: str, *, home_store: bool = False) -> tuple[bool, Any]:
if not is_present(path):
return False, None
value = read_json(path, label)
if home_store:
validate_home(value, label)
return True, value
def next_snapshot_path() -> Path:
ensure_directory(BACKUP_DIR)
while True:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S%f")[:18]
candidate = BACKUP_DIR / f"settings-{stamp}.json"
if not is_present(candidate):
return candidate
time.sleep(0.002)
def prune_snapshots() -> None:
snapshots = sorted(
(
path
for path in BACKUP_DIR.iterdir()
if SNAPSHOT_RE.fullmatch(path.name)
and path.is_file()
and not path.is_symlink()
),
key=lambda path: path.stat().st_mtime_ns,
reverse=True,
)
for old in snapshots[KEEP:]:
durable_remove(old)
def save_snapshot(*, require_any: bool, validate: bool) -> Path | None:
try:
desktop_present, desktop = current_store(
SETTINGS, "The current settings file"
)
home_present, home = current_store(
HOME_STATE, "The current Home state file", home_store=True
)
except BackupError:
if validate:
raise
return None
if not desktop_present and not home_present:
if require_any:
fail("No Panama settings exist to back up.")
return None
envelope: dict[str, Any] = {
"version": 2,
"desktop": {"present": desktop_present},
"home": {"present": home_present},
}
if desktop_present:
envelope["desktop"]["data"] = desktop
if home_present:
envelope["home"]["data"] = home
destination = next_snapshot_path()
atomic_write_json(destination, envelope)
prune_snapshots()
return destination
def snapshot_source(name: str) -> Path:
if SNAPSHOT_RE.fullmatch(name) is None:
fail("Not a snapshot name.")
ensure_directory(BACKUP_DIR)
candidate = BACKUP_DIR / name
require_regular(candidate, "That snapshot")
if candidate.resolve(strict=True).parent != BACKUP_DIR.resolve(strict=True):
fail("That snapshot is outside the backup directory.")
return candidate
def stage_artifact(name: str, content: bytes) -> None:
path = transaction_path(name)
if path.exists() or path.is_symlink():
fail("A stale restore transaction artifact was not recovered.")
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
finally:
# fdopen owns the descriptor after construction.
pass
fsync_directory(TRANSACTION_DIR)
def capture_old(store: str, target: Path) -> bool:
if not is_present(target):
return False
require_regular(target, f"The current {store} settings file")
stage_artifact(f"{store}.old", target.read_bytes())
return True
def prepare_transaction(
desktop_present: bool,
desktop_data: dict[str, Any] | None,
home_touch: bool,
home_present: bool,
home_data: dict[str, Any] | None,
) -> dict[str, Any]:
# Cleanup is active before the first artifact is created. A pre-journal
# error removes every staged/rollback file; a process death is recovered as
# stale preparation by the next invocation.
ensure_directory(TRANSACTION_PARENT)
clean_transaction_artifacts()
ensure_directory(TRANSACTION_DIR)
fsync_directory(TRANSACTION_PARENT)
try:
desktop_old = capture_old("desktop", SETTINGS)
if desktop_present:
stage_artifact("desktop.new", json_bytes(desktop_data))
if os.environ.get("PANAMA_SETTINGS_BACKUP_TEST_FAIL") == "after-desktop-stage":
fail("Injected failure after desktop staging.")
home_old = capture_old("home", HOME_STATE) if home_touch else False
if home_touch and home_present:
stage_artifact("home.new", json_bytes(home_data))
journal = {
"version": 1,
"desktop": {
"touch": True,
"oldPresent": desktop_old,
"newPresent": desktop_present,
},
"home": {
"touch": home_touch,
"oldPresent": home_old,
"newPresent": home_present,
},
}
atomic_write_json(JOURNAL, journal)
return journal
except BaseException:
# SIGKILL/os._exit bypass this block by design; the next invocation
# cleans a pre-journal directory or recovers a journalled transaction.
if not JOURNAL.exists() and not JOURNAL.is_symlink():
clean_transaction_artifacts()
raise
def commit_restore(journal: dict[str, Any]) -> None:
try:
desktop = journal["desktop"]
apply_artifact("desktop", "new", desktop["newPresent"])
if os.environ.get("PANAMA_SETTINGS_BACKUP_TEST_CRASH") == "after-desktop":
os._exit(86)
home = journal["home"]
if home["touch"]:
apply_artifact("home", "new", home["newPresent"])
# Both targets and their parent directories are durable. Removing and
# fsyncing the journal is the transaction's commit record.
durable_remove(JOURNAL)
clean_transaction_artifacts()
except BaseException:
# Ordinary failures roll back immediately. Process death leaves the
# journal in place and takes this same path on the next invocation.
recover_transaction()
raise
def write_live_home(text: str) -> None:
try:
value = json.loads(text)
except json.JSONDecodeError as error:
raise BackupError("The live Home state is not valid JSON.") from error
validate_home(value, "The live Home state")
atomic_write_json(HOME_STATE, value)
def command_save(arguments: list[str]) -> None:
if arguments:
write_live_home(arguments[0])
destination = save_snapshot(require_any=True, validate=True)
assert destination is not None
print(json.dumps({"saved": destination.name}, separators=(",", ":")))
def snapshot_files() -> list[Path]:
ensure_directory(BACKUP_DIR)
return sorted(
(
path
for path in BACKUP_DIR.iterdir()
if SNAPSHOT_RE.fullmatch(path.name)
and path.is_file()
and not path.is_symlink()
),
key=lambda path: path.stat().st_mtime_ns,
reverse=True,
)
def command_list() -> None:
output: list[dict[str, Any]] = []
for path in snapshot_files():
try:
value = read_json(path, "A snapshot")
if is_v2_envelope(value):
desktop = value["desktop"]
keys = len(desktop["data"]) if desktop["present"] else 0
else:
keys = len(value)
except BackupError:
keys = 0
raw = path.name.removeprefix("settings-").removesuffix(".json")
pretty = (
f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]} "
f"{raw[9:11]}:{raw[11:13]}:{raw[13:15]}"
)
output.append({"name": path.name, "when": pretty, "keys": keys})
print(json.dumps(output, separators=(",", ":")))
def command_restore(arguments: list[str]) -> None:
if not arguments:
fail("Which snapshot?")
name = arguments[0]
source = snapshot_source(name)
snapshot = read_json(source, "That snapshot")
snapshot_format, value = validate_snapshot(snapshot)
if snapshot_format == "versioned":
desktop_present = value["desktop"]["present"]
desktop_data = value["desktop"].get("data")
home_touch = True
home_present = value["home"]["present"]
home_data = value["home"].get("data")
else:
desktop_present = True
desktop_data = value
home_touch = False
home_present = False
home_data = None
# Restoring remains undoable, but a corrupt current file must not prevent a
# known-good snapshot from recovering the desktop.
save_snapshot(require_any=False, validate=False)
journal = prepare_transaction(
desktop_present,
desktop_data,
home_touch,
home_present,
home_data,
)
commit_restore(journal)
if not home_touch:
home_result: dict[str, Any] = {"preserve": True}
elif home_present:
home_result = {"present": True, "data": home_data}
else:
home_result = {"present": False}
print(
json.dumps(
{"restored": name, "home": home_result},
separators=(",", ":"),
)
)
def main() -> None:
with process_lock():
clean_stale_atomic_files()
recover_transaction()
command = sys.argv[1] if len(sys.argv) > 1 else "list"
arguments = sys.argv[2:]
if command == "save":
command_save(arguments)
elif command == "list":
command_list()
elif command == "restore":
command_restore(arguments)
else:
fail("usage: panama-settings-backup [save|list|restore <name>]")
if __name__ == "__main__":
try:
main()
except BackupError as error:
print(str(error), file=sys.stderr)
raise SystemExit(1) from error
except OSError as error:
print("The settings backup could not access its state files.", file=sys.stderr)
raise SystemExit(1) from error
@@ -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;
}
}
@@ -0,0 +1,102 @@
pragma Singleton
// Freedesktop default handlers and session autostart entries.
//
// The helper owns parsing and atomic desktop-file writes. This singleton keeps
// the QML side typed and reactive, and every external command crosses Process
// as an argument array.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property var handlers: ({})
property var autostartEntries: []
property var luaAutostartEntries: []
property string lastError: ""
readonly property bool busy: snapshotProcess.running || mutationProcess.running
readonly property string helper: Quickshell.shellDir + "/scripts/panama-default-apps"
readonly property var supportedRoles: ["browser", "mail", "files", "terminal", "music", "images", "video"]
Process {
id: snapshotProcess
stdout: StdioCollector {
onStreamFinished: root.applySnapshot(this.text)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "Default applications could not be read. Try refreshing."
}
}
Process {
id: mutationProcess
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
root.lastError = "That application setting could not be changed."
return;
}
root.refresh();
}
}
function applySnapshot(text: string): void {
try {
const payload = JSON.parse(text);
root.handlers = payload.handlers ?? ({});
root.autostartEntries = payload.autostartEntries ?? [];
root.luaAutostartEntries = payload.luaAutostartEntries ?? [];
root.lastError = "";
} catch (error) {
root.lastError = "Default applications returned an unreadable response."
}
}
function refresh(): void {
if (root.busy)
return;
root.lastError = "";
snapshotProcess.exec([root.helper, "snapshot"]);
}
function knownDesktopId(desktopId: string): bool {
if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$/.test(desktopId))
return false;
const entries = DesktopEntries.applications.values;
return entries.some(entry => {
const entryId = String(entry.id ?? "");
return entryId === desktopId || entryId + ".desktop" === desktopId;
});
}
function setDefault(role: string, desktopId: string): void {
if (root.busy)
return;
if (!root.supportedRoles.includes(role) || !root.knownDesktopId(desktopId)) {
root.lastError = "Choose an application from the available list."
return;
}
root.lastError = "";
mutationProcess.exec([root.helper, "set-default", role, desktopId]);
}
function setAutostart(desktopId: string, enabled: bool): void {
if (root.busy)
return;
const known = root.autostartEntries.some(entry => entry.id === desktopId);
if (!known) {
root.lastError = "That user autostart entry is no longer available."
return;
}
root.lastError = "";
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]);
}
Component.onCompleted: root.refresh()
}
+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()
}
}
+318
View File
@@ -0,0 +1,318 @@
pragma Singleton
// The keymap, read from the compositor rather than restated.
//
// The Shortcuts page used to hold a hand-typed array of nineteen entries while
// keybinds.lua produced a hundred and thirteen. It could not show the other
// ninety-four, and it drifted the moment a bind was edited. `hyprctl binds -j`
// is the only description of the keymap that cannot be wrong, so this reads
// that and every bind carries its own human label (see the `description`
// argument in hypr/keybinds.lua).
//
// Refreshed on demand, not polled: binds only change when the config is
// reloaded, and nothing in this shell should wake up to re-read something that
// has not moved.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
// [{ chord, description, group, mouse, repeating, locked }], ordered as
// Hyprland reports them, which follows the order they appear in the config.
property var binds: []
property bool loaded: false
property string lastError: ""
readonly property bool busy: query.running
// Hyprland's modmask bits. SUPER is the Panama modifier.
readonly property var modifierBits: [
{ bit: 64, name: "Super" },
{ bit: 4, name: "Ctrl" },
{ bit: 8, name: "Alt" },
{ bit: 1, name: "Shift" }
]
// Keysyms whose raw names would be noise in a shortcuts list.
readonly property var keyNames: ({
"mouse_up": "Scroll up",
"mouse_down": "Scroll down",
"mouse:272": "Left click",
"mouse:273": "Right click",
"mouse:274": "Middle click",
"bracketleft": "[",
"bracketright": "]",
"grave": "`",
"Print": "Print Screen",
"left": "←",
"right": "→",
"up": "↑",
"down": "↓"
})
Process {
id: query
command: ["hyprctl", "-j", "binds"]
stdout: StdioCollector {
onStreamFinished: root.parse(this.text)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "Could not read the keymap from Hyprland.";
}
}
// ── Rebinding ───────────────────────────────────────────────────────────
// Overrides map a SHIPPED chord to a replacement. hypr/keybinds.lua reads
// them and substitutes only the chord -- the action is always the Lua value
// written in that file -- so an override can move a shortcut but can never
// make one do something else.
//
// Applying needs `hyprctl reload` rather than a live `hl.bind`: Hyprland
// reports Lua-defined binds with dispatcher "__lua" and a bytecode offset,
// so the action cannot be reconstructed from the outside to re-bind it.
// Reload re-runs the config, which re-reads the settings file.
readonly property var overrides: {
const stored = DesktopPreferences.get("keybindOverrides");
return (stored && typeof stored === "object") ? stored : ({});
}
property bool reloading: false
// The chord a bind ships with, given the chord it currently answers to.
// Displayed binds come from the compositor and so already reflect any
// override; the override map is what tells us where they started.
function shippedChordFor(currentChord: string): string {
for (const shipped in root.overrides) {
if (root.overrides[shipped] === currentChord)
return shipped;
}
return currentChord;
}
function isOverridden(currentChord: string): bool {
return root.shippedChordFor(currentChord) !== currentChord;
}
// Refuses a chord already answering to something else, so rebinding cannot
// quietly shadow an existing shortcut.
function conflictFor(chord: string, exceptCurrent: string): string {
for (const bind of root.binds) {
if (bind.luaChord === chord && bind.luaChord !== exceptCurrent)
return bind.description;
}
return "";
}
// A moved shortcut vacates its shipped chord, so another override may use
// it legitimately. Refuse to reset the first shortcut until that occupant
// moves away; otherwise Hyprland would receive two binds on one chord.
function overrideOccupantFor(chord: string, exceptShipped: string): string {
for (const shipped in root.overrides) {
if (shipped !== exceptShipped && root.overrides[shipped] === chord)
return shipped;
}
return "";
}
function rebind(currentChord: string, newChord: string): bool {
if (newChord === "" || newChord === currentChord)
return false;
const conflict = root.conflictFor(newChord, currentChord);
if (conflict !== "") {
root.lastError = `${newChord} is already ${conflict}.`;
return false;
}
const shipped = root.shippedChordFor(currentChord);
const next = Object.assign({}, root.overrides);
if (newChord === shipped)
delete next[shipped];
else
next[shipped] = newChord;
if (!DesktopPreferences.set("keybindOverrides", next)) {
root.lastError = "That shortcut could not be saved.";
return false;
}
root.applyReload();
return true;
}
function resetBind(currentChord: string): bool {
const shipped = root.shippedChordFor(currentChord);
if (shipped === currentChord)
return true;
const occupant = root.overrideOccupantFor(shipped, shipped);
if (occupant !== "") {
root.lastError = `${shipped} is used by another rebound shortcut. Reset that shortcut first.`;
return false;
}
const next = Object.assign({}, root.overrides);
delete next[shipped];
if (!DesktopPreferences.set("keybindOverrides", next)) {
root.lastError = "That shortcut could not be reset.";
return false;
}
root.applyReload();
return true;
}
function resetAll(): void {
if (Object.keys(root.overrides).length === 0)
return;
DesktopPreferences.set("keybindOverrides", ({}));
root.applyReload();
}
Process {
id: reloadRun
command: ["hyprctl", "reload"]
onExited: (exitCode, exitStatus) => {
root.reloading = false;
if (exitCode !== 0) {
root.lastError = "The compositor did not reload.";
return;
}
root.lastError = "";
// The settings file is written on a timer, so re-read the keymap
// once the reload has had a moment to pick it up.
settle.restart();
}
}
Timer {
id: settle
interval: 350
onTriggered: root.refresh()
}
function applyReload(): void {
if (reloadRun.running)
return;
root.reloading = true;
// Give DesktopPreferences' coalescing write a moment to land first.
reloadDelay.restart();
}
Timer {
id: reloadDelay
interval: 120
onTriggered: reloadRun.running = true
}
Component.onCompleted: root.refresh()
function refresh(): void {
if (query.running)
return;
root.lastError = "";
query.running = true;
}
function parse(text: string): void {
try {
const raw = JSON.parse(text);
const out = [];
for (const bind of raw) {
const description = String(bind.description ?? "").trim();
// A bind with no description cannot be presented usefully --
// the dispatcher is "__lua" and the argument is a bytecode
// offset. Showing the chord alone would be worse than omitting
// it, and tests/quickshell/keybinds-contract.sh fails the build
// if any exist, so this should never be reached in practice.
if (description === "")
continue;
out.push({
chord: root.formatChord(bind),
// The same chord in the form hypr/keybinds.lua writes, which
// is what an override is keyed by. The display form
// prettifies modifiers and arrow keys and so cannot be used
// for that.
luaChord: root.luaChord(bind),
description: description,
group: root.groupFor(description, bind),
mouse: bind.mouse === true,
repeating: bind.repeat === true,
locked: bind.locked === true
});
}
root.binds = out;
root.loaded = true;
root.lastError = "";
} catch (error) {
root.lastError = "The keymap could not be read.";
}
}
// "SUPER + SHIFT + K" -- uppercase modifiers in the order keybinds.lua
// writes them, then the raw keysym rather than its display name.
function luaChord(bind: var): string {
const parts = [];
for (const modifier of root.modifierBits) {
if ((bind.modmask & modifier.bit) !== 0)
parts.push(modifier.name.toUpperCase());
}
parts.push(String(bind.key ?? ""));
return parts.join(" + ");
}
function formatChord(bind: var): string {
const parts = [];
for (const modifier of root.modifierBits) {
if ((bind.modmask & modifier.bit) !== 0)
parts.push(modifier.name);
}
const key = String(bind.key ?? "");
parts.push(root.keyNames[key] ?? (key.length === 1 ? key.toUpperCase() : key));
return parts.join(" + ");
}
// Grouping is by what the shortcut does, taken from its own description,
// so adding a bind puts it in the right section without touching this file.
function groupFor(description: string, bind: var): string {
const text = description.toLowerCase();
if (bind.key && String(bind.key).indexOf("XF86") === 0)
return "Media & hardware keys";
if (text.indexOf("workspace") >= 0)
return "Workspaces";
if (text.indexOf("window") >= 0 || text.indexOf("focus") >= 0
|| text.indexOf("swap") >= 0 || text.indexOf("split") >= 0
|| text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0
|| text.indexOf("taller") >= 0 || text.indexOf("shorter") >= 0
|| text.indexOf("shrink") >= 0 || text.indexOf("grow") >= 0
|| text.indexOf("float") >= 0 || text.indexOf("fullscreen") >= 0
|| text.indexOf("close") >= 0 || text.indexOf("scratchpad") >= 0)
return "Windows";
if (text.indexOf("volume") >= 0 || text.indexOf("mute") >= 0
|| text.indexOf("track") >= 0 || text.indexOf("play") >= 0
|| text.indexOf("brightness") >= 0)
return "Media & hardware keys";
return "Applications & shell";
}
// Section order for the page. Anything a future bind invents lands at the
// end rather than being dropped.
readonly property var groupOrder: ["Windows", "Workspaces", "Applications & shell", "Media & hardware keys"]
function grouped(): var {
const buckets = {};
for (const bind of root.binds) {
buckets[bind.group] = buckets[bind.group] ?? [];
buckets[bind.group].push(bind);
}
const names = Object.keys(buckets).sort((a, b) => {
const ia = root.groupOrder.indexOf(a);
const ib = root.groupOrder.indexOf(b);
return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib);
});
return names.map(name => ({ name: name, binds: buckets[name] }));
}
}
@@ -0,0 +1,233 @@
pragma Singleton
// Snapshots of Panama's durable settings stores.
//
// DesktopPreferences and HomePreferences use separate files. The helper owns
// the transactional filesystem boundary; this service owns settling the live
// desktop after those files have changed underneath it.
//
// HomePreferences intentionally keeps its FileView in Quickshell's private
// state directory while snapshots use Panama's canonical state directory. This
// service bridges them through HomePreferences' public mutation API, then soft
// reloads once external consumers have settled.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-settings-backup"
property var snapshots: []
property string lastError: ""
property string lastAction: ""
// Narrow service boundaries keep restore sequencing explicit and make it
// possible to verify the real handler in an isolated shell without ever
// calling the daily-driver compositor or wallpaper services.
property var readHomeState: function() {
return {
initialized: HomePreferences.initialized,
favorites: HomePreferences.favorites
};
}
property var resetHome: function() { HomePreferences.resetHomeDefaults(); }
property var initializeHome: function(ids) { HomePreferences.initialize(ids); }
property var aliasHome: function(id, alias) { HomePreferences.setAlias(id, alias); }
property var reloadDesktop: function() { DesktopPreferences.reload(); }
property var applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); }
property var reloadKeybinds: function() { Keybinds.applyReload(); }
property var keybindsReloading: function() { return Keybinds.reloading; }
property var systemBusy: function() { return SystemSettings.busy; }
property var currentWallpaper: function() {
return String(DesktopPreferences.get("wallpaperPath") ?? "");
}
property var applyWallpaper: function(path) { Wallpaper.set(path); }
property var reloadShell: function() { Quickshell.reload(false); }
readonly property bool busy: listQuery.running || actionRun.running
|| applyRestoredState.running || settleReload.running
Process {
id: listQuery
command: [root.helperPath, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.snapshots = Array.isArray(parsed) ? parsed : [];
if (root.lastError === "Could not read the list of snapshots.")
root.lastError = "";
} catch (error) {
root.lastError = "Could not read the list of snapshots.";
}
}
}
}
Process {
id: actionRun
property bool restoring: false
property string outputText: ""
stdout: StdioCollector {
onStreamFinished: actionRun.outputText = this.text
}
onStarted: actionRun.outputText = ""
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
root.lastError = actionRun.restoring
? "That snapshot could not be restored."
: "The settings could not be backed up.";
return;
}
root.lastAction = actionRun.restoring ? "restored" : "saved";
if (actionRun.restoring) {
const homeReloaded = root.handleRestoreOutput(actionRun.outputText);
root.lastError = homeReloaded
? ""
: "Desktop settings were restored, but Home favourites could not be reloaded.";
} else
root.lastError = "";
root.refresh();
}
}
Timer {
id: applyRestoredState
interval: 80
repeat: false
onTriggered: {
// DesktopPreferences.reload() invalidates reactive shell bindings.
// These services also own state outside QML and need an explicit
// replay: compositor options, Lua-generated binds, and hyprpaper.
root.applyCompositor();
root.reloadKeybinds();
root.applyWallpaper(root.currentWallpaper());
settleReload.attempts = 0;
settleReload.restart();
}
}
Timer {
id: settleReload
property int attempts: 0
interval: 100
repeat: true
onTriggered: {
attempts++;
// Let the current instances finish their external writes before a
// soft reload replaces them. The cap keeps a failed external tool
// from leaving restored Home state stale indefinitely.
if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) {
stop();
root.reloadShell();
}
}
}
Component.onCompleted: root.refresh()
function refresh(): void {
if (!listQuery.running)
listQuery.running = true;
}
function save(): void {
if (actionRun.running)
return;
actionRun.restoring = false;
actionRun.exec([root.helperPath, "save", root.serialiseHomeState()]);
}
function serialiseHomeState(): string {
const current = root.readHomeState();
const favorites = [];
for (const favorite of current.favorites ?? []) {
favorites.push({
id: String(favorite.id ?? ""),
alias: String(favorite.alias ?? "")
});
}
return JSON.stringify({
initialized: current.initialized === true,
favorites: favorites
});
}
function handleRestoreOutput(text: string): bool {
if (!root.reloadHomeState(text))
return false;
root.reloadDesktop();
applyRestoredState.restart();
return true;
}
// Restore output carries the canonical Home state. Reconstructing through
// these methods keeps validation and persistence inside HomePreferences;
// this service never mutates its aliases or private FileView directly.
function reloadHomeState(text: string): bool {
try {
const result = JSON.parse(text);
const restored = result?.home;
if (!restored || restored.preserve === true)
return true;
if (restored.present !== true)
return restored.present === false
? root.resetHomeState()
: false;
const data = restored.data;
if (!data || typeof data.initialized !== "boolean" || !Array.isArray(data.favorites))
return false;
const ids = [];
const aliases = [];
const seen = {};
for (const favorite of data.favorites) {
const id = favorite?.id;
const alias = favorite?.alias;
if (typeof id !== "string" || !/^light\.[a-z0-9_]+$/.test(id)
|| typeof alias !== "string" || seen[id])
return false;
seen[id] = true;
ids.push(id);
aliases.push(alias);
}
if (!data.initialized && ids.length > 0)
return false;
root.resetHome();
if (!data.initialized)
return true;
root.initializeHome(ids);
for (let index = 0; index < ids.length; index++)
root.aliasHome(ids[index], aliases[index]);
return true;
} catch (error) {
return false;
}
}
function resetHomeState(): bool {
root.resetHome();
return true;
}
// The name is matched against the snapshot list rather than trusted, so no
// caller-supplied path reaches the helper even though it validates as well.
function restore(name: string): bool {
if (actionRun.running)
return false;
if (!root.snapshots.some(snapshot => snapshot.name === name)) {
root.lastError = "That snapshot is not in the list.";
return false;
}
actionRun.restoring = true;
actionRun.exec([root.helperPath, "restore", name]);
return true;
}
}
@@ -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: "applications" },
{ 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", "applications", "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;
@@ -216,14 +216,38 @@ Singleton {
} }
if (Object.keys(requested).length === 0) if (Object.keys(requested).length === 0)
return false; return false;
// A write in flight is queued rather than refused. Options are applied
// and verified one batch at a time, but the callers are a settings UI
// and a startup replay of every compositor-backed preference -- they
// overlap routinely, and dropping a change on the floor would leave the
// stored value and the compositor disagreeing. Later values for the
// same key win.
if (configWrite.running || configVerify.running) { if (configWrite.running || configVerify.running) {
root.lastError = "Another change is still being applied."; root.queued = Object.assign({}, root.queued, requested);
return false; return true;
} }
root.startWrite(requested);
return true;
}
// Merged batches waiting for the current write to finish.
property var queued: ({})
function startWrite(requested: var): void {
configWrite.pending = requested; configWrite.pending = requested;
configWrite.exec(["hyprctl", "eval", root.buildConfigPayload(requested)]); configWrite.exec(["hyprctl", "eval", root.buildConfigPayload(requested)]);
return true; }
// Called when a write settles, however it settled. A failed batch must not
// strand whatever queued up behind it.
function drainQueue(): void {
const next = root.queued;
if (Object.keys(next).length === 0)
return;
root.queued = ({});
root.startWrite(next);
} }
// The value as Hyprland stores it. Several options are a toggle in the UI // The value as Hyprland stores it. Several options are a toggle in the UI
@@ -308,6 +332,7 @@ Singleton {
} }
root.lastError = rejected.length === 0 ? "" : `Hyprland did not apply ${rejected.join(" or ")}.`; root.lastError = rejected.length === 0 ? "" : `Hyprland did not apply ${rejected.join(" or ")}.`;
root.drainQueue();
} }
function matchesObserved(entry: var, value: var, answer: var): bool { function matchesObserved(entry: var, value: var, answer: var): bool {
@@ -334,6 +359,54 @@ Singleton {
function reportWriteFailure(requested: var, text: string): void { function reportWriteFailure(requested: var, text: string): void {
const labels = Object.keys(requested).map(key => PreferenceSchema.spec(key).label); const labels = Object.keys(requested).map(key => PreferenceSchema.spec(key).label);
root.lastError = `Hyprland rejected ${labels.join(" and ")}.`; root.lastError = `Hyprland rejected ${labels.join(" and ")}.`;
root.drainQueue();
}
// The one entry point the settings UI uses to change any preference.
//
// A compositor-backed setting must be applied and verified before it is
// stored, so that preferences never claim a value Hyprland refused.
// Everything else is a direct write. Rows bind a schema key and call this;
// they never need to know which kind they are holding.
function commitPreference(key: string, value: var): bool {
const entry = PreferenceSchema.spec(key);
if (!entry) {
root.lastError = "That setting is not part of Panama.";
return false;
}
if (entry.hypr) {
const batch = {};
batch[key] = value;
return root.applyOptions(batch);
}
return DesktopPreferences.set(key, value);
}
// Restores shipped defaults across every store Panama owns, not just the
// schema. Panama keeps user state in more than one file -- the schema store,
// the focus session, and the Home accessory arrangement -- and a reset that
// silently skipped one would be worse than no reset at all.
//
// Compositor-backed values are re-applied afterwards, since resetting the
// stored value does not by itself tell Hyprland anything.
function restoreDefaults(): void {
DesktopPreferences.resetDesktopDefaults();
// Home accessories keep their own store (panama-home.json), so a reset
// that only cleared the schema store would silently leave a customised
// favourites list behind while claiming to restore Panama's defaults.
//
// HomePreferences owns the write-through boundary so the state file is
// rewritten before this reset can be considered complete.
HomePreferences.resetHomeDefaults();
resettleTimer.restart();
}
Timer {
id: resettleTimer
interval: 60
onTriggered: root.applyPersistedDisplayPolicy()
} }
function setAutoHdr(enabled: bool): void { function setAutoHdr(enabled: bool): void {
@@ -359,9 +432,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,76 @@
// Isolated behavioral harness for SettingsBackup's live restore handoff.
// Every external consumer is replaced before restore output is exercised, so
// this file never writes the real compositor, wallpaper, keymap, or shell.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
ShellRoot {
id: root
property var calls: []
property bool homeInitialized: false
property var homeFavorites: []
function record(name: string): void {
const next = root.calls.slice();
next.push(name);
root.calls = next;
}
Component.onCompleted: {
SettingsBackup.readHomeState = function() {
return {
initialized: root.homeInitialized,
favorites: root.homeFavorites
};
};
SettingsBackup.resetHome = function() {
root.record("home.reset");
root.homeInitialized = false;
root.homeFavorites = [];
};
SettingsBackup.initializeHome = function(ids) {
root.record("home.initialize:" + ids.join(","));
root.homeInitialized = true;
root.homeFavorites = ids.map(id => ({ id: id, alias: "" }));
};
SettingsBackup.aliasHome = function(id, alias) {
root.record("home.alias:" + id + "=" + alias);
root.homeFavorites = root.homeFavorites.map(favorite =>
favorite.id === id ? { id: id, alias: alias } : favorite);
};
SettingsBackup.reloadDesktop = function() { root.record("desktop.reload"); };
SettingsBackup.applyCompositor = function() { root.record("system.apply"); };
SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); };
SettingsBackup.keybindsReloading = function() { return false; };
SettingsBackup.systemBusy = function() { return false; };
SettingsBackup.currentWallpaper = function() { return "/tmp/restored-wallpaper.jpg"; };
SettingsBackup.applyWallpaper = function(path) { root.record("wallpaper.set:" + path); };
SettingsBackup.reloadShell = function() { root.record("shell.reload"); };
}
IpcHandler {
target: "settings-backup-behavior"
function reset(): void {
root.calls = [];
root.homeInitialized = false;
root.homeFavorites = [];
}
function apply(output: string): bool {
return SettingsBackup.handleRestoreOutput(output);
}
function status(): string {
return JSON.stringify({
calls: root.calls,
initialized: root.homeInitialized,
favorites: root.homeFavorites
});
}
}
}
@@ -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)
});
}
}
}
@@ -29,6 +29,31 @@ ShellRoot {
return SystemSettings.applyOptions(JSON.parse(payload)); return SystemSettings.applyOptions(JSON.parse(payload));
} }
// The routing entry point the settings rows use: a compositor-backed
// key must be applied and verified before it is stored, a local one is
// written directly. The contract checks both halves.
function commit(key: string, payload: string): bool {
return SystemSettings.commitPreference(key, JSON.parse(payload));
}
function stored(key: string): string {
return JSON.stringify(DesktopPreferences.get(key));
}
function seedHome(): void {
HomePreferences.favorites = [{ id: "light.contract_probe", alias: "Probe" }];
HomePreferences.initialized = true;
}
function homeState(): string {
return JSON.stringify({
count: HomePreferences.favorites.length,
initialized: HomePreferences.initialized
});
}
function restoreDefaults(): void { SystemSettings.restoreDefaults(); }
function panelAllowed(panel: string): bool { function panelAllowed(panel: string): bool {
return SystemSettings.isGnomePanelAllowed(panel); return SystemSettings.isGnomePanelAllowed(panel);
} }
@@ -188,26 +188,57 @@ rewritten.
`SliderRow.qml`, `ChoiceRow.qml`, `ActionRow.qml`, `TextRow.qml`; `SliderRow.qml`, `ChoiceRow.qml`, `ActionRow.qml`, `TextRow.qml`;
Modify all eleven `*Page.qml`; Test `tests/quickshell/settings-rows-contract.sh` Modify all eleven `*Page.qml`; Test `tests/quickshell/settings-rows-contract.sh`
- [ ] **Build static mocks** of the new Appearance page and one rebuilt existing - [x] **Build static mocks** of the new Appearance page and one rebuilt existing
page, serve them over HTTP, report the URL, and **stop for a decision.** page, serve them over HTTP, report the URL, and **stop for a decision.**
- [ ] Write a contract asserting each row type binds a schema key by name, reflects Three directions built; Gabriel chose **B, the live preview**.
external changes, and clamps out-of-range input. - [x] Implement the row components and `SettingsPage` (the scaffold previously
- [ ] Implement the row components and `SettingsPage` (the scaffold currently
copy-pasted eleven times). copy-pasted eleven times).
- [ ] Rewrite the eleven pages on top of them; delete the dead read-only rows that - [x] Give Appearance real content, driven by a live preview.
only existed because a real control was expensive. - [x] Rewrite Appearance, Desktop & Dock, and Input & Shortcuts on the new rows;
- [ ] Promote the hardcoded `Settings.qml` values into real controls: weather delete the dead read-only rows that only existed because a real control was
expensive.
- [x] Make "Restore defaults" span every store Panama owns.
- [x] Run the new contracts and the existing settings contracts to green.
- [ ] Promote the remaining hardcoded `Settings.qml` values: weather
location/unit/interval, vitals interval, night-light schedule, the four location/unit/interval, vitals interval, night-light schedule, the four
notification timing and history limits, capture directories, and recorder notification timing and history limits, capture directories, and recorder
arguments. arguments.
- [ ] Give Appearance real content: accent pair, window rounding, gaps, border
size, blur, animation speed, bar height, font scale, wallpaper.
- [ ] Make the dock pin list editable (reorder, add, remove) instead of a - [ ] Make the dock pin list editable (reorder, add, remove) instead of a
16-entry literal. 16-entry literal.
- [ ] Run the rows contract and the existing settings contracts to green. - [ ] Move the remaining pages (Home, Displays, Connectivity, Sound,
Notifications, Screen Intelligence, Services, About) onto `SettingsPage`.
**Exit criteria:** no shipped behaviour value is reachable only by editing a file. **Exit criteria:** no shipped behaviour value is reachable only by editing a file.
**Landed (first pass).** `SettingsPage` plus `ToggleRow`, `SliderRow`,
`ChoiceRow`, `ActionRow`, and `TextRow`. A row names a schema key and needs
nothing else — `ToggleRow { setting: "blurEnabled" }` pulls its label,
explanation, bounds, and unit from the schema, and writes through
`SystemSettings.commitPreference`, which routes compositor-backed keys through
apply-and-verify and local keys straight to the store. Rows never need to know
which kind they hold.
`DesktopPreview` is the direction-B centrepiece: two tiled windows drawn at the
settings actually in effect, scaled by the ratio between the preview's width and
the real monitor's, so a 10px gap on a 4500px display looks as small as it is.
Four things found by building it:
* `cursor:inactive_timeout` is reported as `float`, not `int`. `readAs` describes
what getoption answers with, not what the setting means, and getting it wrong
does not fail loudly — it makes every write to that key look rejected. The user
saw "Hyprland did not apply Hide pointer after" for a change that worked.
`tests/quickshell/schema-hypr-shape-contract.sh` now asks the compositor for
the real shape of all 23 mapped options.
* The Settings window is a normal tiled window, so `implicitWidth: 1120` is only
a hint and rows must survive ~400px. `SliderRow` stacks its control under the
label below 520px.
* Binding an anchor to `undefined` to switch layouts does not reliably release
it. Both row layouts are positioned explicitly now.
* Refusing concurrent compositor writes was the wrong policy: the startup replay
of 23 preferences routinely overlaps a UI change, and refusing left the store
and the compositor disagreeing. Writes queue and merge, later values winning.
--- ---
## Stage 4 — Shortcuts from the compositor ## Stage 4 — Shortcuts from the compositor
@@ -215,18 +246,34 @@ Modify all eleven `*Page.qml`; Test `tests/quickshell/settings-rows-contract.sh`
**Files:** Create `services/Keybinds.qml`; Modify `modules/settings/ShortcutsPage.qml`, **Files:** Create `services/Keybinds.qml`; Modify `modules/settings/ShortcutsPage.qml`,
`config/dot/hypr/keybinds.lua`; Test `tests/quickshell/keybinds-contract.sh` `config/dot/hypr/keybinds.lua`; Test `tests/quickshell/keybinds-contract.sh`
- [ ] Write a contract asserting the page's bind count matches `hyprctl binds -j` - [x] Write a contract asserting the page's bind count matches `hyprctl binds -j`
exactly, so it can never drift again. exactly, so it can never drift again.
- [ ] Run it; confirm it fails at 19 of 113. - [x] Implement `Keybinds.qml` reading `hyprctl binds -j`, grouped.
- [ ] Implement `Keybinds.qml` reading `hyprctl binds -j`, grouped and searchable. - [x] Backfill `description` in `keybinds.lua` for the 29 binds that lacked one.
- [ ] Backfill `description` in `keybinds.lua` for the 29 binds that lack one. - [x] Rebuild `ShortcutsPage` on the live data; delete the hardcoded array.
- [ ] Rebuild `ShortcutsPage` on the live data; delete the hardcoded array. - [x] Run the contract to green.
- [ ] Add rebinding: overrides in the same JSON, applied by `keybinds.lua` after - [ ] Add rebinding: overrides in the same JSON, applied by `keybinds.lua` after
the defaults and live via `eval`, with conflict detection against existing binds. the defaults and live via `eval`, with conflict detection against existing binds.
- [ ] Run the contract to green. - [ ] Add search over the shortcut list.
**Exit criteria:** the page shows every real bind, always current, and can change them. **Exit criteria:** the page shows every real bind, always current, and can change them.
**Landed (read-only).** The page shows all **113** binds, grouped by what they do,
against the hand-typed **19** it had before. Descriptions come from the binds
themselves, so a new bind appears with no change to the page.
Grouping is derived from each bind's own description rather than a table here, so
adding a bind puts it in the right section automatically. Hyprland reports
Lua-defined binds with dispatcher `__lua` and a bytecode offset as the argument,
so a bind without a description has nothing readable beside its chord; the
service drops those, and `tests/quickshell/keybinds-contract.sh` fails if any
exist so that dropping can never be silent.
Input settings are on the same page and are now real controls: keyboard repeat,
Num Lock, focus-follows-pointer, pointer speed, and the pointer hide timeout.
Rebinding is not done — that is the remaining half of this stage.
--- ---
## Sequencing note ## Sequencing note
@@ -238,3 +285,64 @@ can run in parallel with it.
Nothing here is committed yet; `main` has 20+ uncommitted paths from prior work Nothing here is committed yet; `main` has 20+ uncommitted paths from prior work
that should get a restore point before Stage 1 begins. that should get a restore point before Stage 1 begins.
---
## Stage 5 — Replace GNOME Settings for what Panama owns
Not in the original plan. Added after the settings vocabulary made new pages
cheap enough that the limiting factor stopped being effort and started being
scope. Built jointly with the codex agent, which took default applications, the
page migrations, and the remaining hardcoded values.
- [x] **Wallpaper** — thumbnail grid, applied over hyprpaper IPC.
- [x] **Power & Lock** — screen blank, lock, suspend, lock-before-sleep.
- [x] **Date & Time** — timezone and network time via `timedatectl`.
- [x] **Accessibility** — pointer size, text scale, motion, contrast.
- [x] **Search that indexes settings**, not page names.
- [x] **Editable Dock** — reorder, unpin, add.
- [x] **Settings snapshots** — save, list, restore.
- [x] **Rebindable shortcuts.**
- [ ] Displays: resolution, refresh rate, scale, rotation.
- [ ] Per-application notification rules.
- [ ] Window rules (float/tile/workspace) as a page.
**Landed.** Each of these turned up something the compositor or its tools do
differently than documented, and in every case the failure mode was silence
rather than an error:
* **hyprpaper 0.8 ignores the "all outputs" form.** `<empty>,<path>` is accepted
and does nothing, so a wallpaper set that way appears to succeed and never
changes. Its IPC is also much smaller than older versions suggest — `preload`,
`listloaded`, `unload`, and `reload` all answer "invalid hyprpaper request".
* **`cursor:inactive_timeout` is answered as `float`, not `int`.** A wrong
`readAs` does not fail loudly; it makes every write to that key look rejected,
and the user sees an error for a change that worked.
* **Snapshot filenames collided at one-second resolution.** A save followed
promptly by a restore produced the same name twice, and the restore's own
safety snapshot overwrote the file it was about to read. Found by the
contract, which restores immediately after saving.
* **Keying bind overrides by description moved every bind sharing one.**
Rebinding `SUPER+C` also dragged `XF86Calculator` onto the same chord.
Descriptions are not unique; chords are.
* **The GNOME delegation allow-list named a panel that does not exist.**
`users` is not in `gnome-control-center --list`, so that button opened
nothing.
* **A copy of the Quickshell config shares the live shell's ID.** Quickshell
derives it from content rather than path, so an "isolated" harness built by
copying the config directory can kill or drive the running desktop. This took
the live shell down twice during development. Harnesses pointing at a single
distinct `.qml` file are unaffected.
Two configurations are now generated rather than edited, because `~/.config/hypr`
is a symlink into this repository and writing there at runtime would put machine
state into a tracked file: hypridle's config, into `XDG_STATE_HOME` with a
systemd drop-in pointing at it, and the wallpaper, which is applied over IPC and
re-applied at shell start instead of being written into `hyprpaper.conf`.
The schema gained a `json` type for structured values — the dock's pinned list
and the keybind overrides — so they live in the one settings file and are
covered by the one reset, rather than each growing a preference store of its
own. `restoreDefaults()` spans every store Panama owns, including the Home
accessory arrangement, which it reaches through that service's existing public
aliases rather than an API added for the purpose.
@@ -0,0 +1,119 @@
# Panama Settings Completion Codex Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Complete Codex's portion of Panama Settings with default-application and autostart management, shared page scaffolds, and real controls for every remaining hardcoded shell behavior value.
**Architecture:** `DefaultApps.qml` is the typed system boundary for freedesktop default handlers and user autostart entries; `ApplicationsPage.qml` binds reactively to `DesktopEntries.applications.values` and never snapshots the asynchronous model. Existing pages adopt `SettingsPage`; schema-bound values use `ChoiceRow`, `SliderRow`, and `ToggleRow`, while specialized controls and genuinely read-only facts remain specialized or use `TextRow`.
**Tech Stack:** Quickshell 0.3 QML, QtQuick, `xdg-settings`, `xdg-mime`, freedesktop `.desktop` files, Bash/Python contract tests, Hyprland.
**Spec:** `docs/superpowers/specs/2026-08-17-panama-cohesion-design.md`
**Status:** Implemented and independently reviewed on 2026-08-18. The combined
verification gate is recorded in the integrating commit history.
## Global Constraints
- Claude owns and must be the only editor of `PreferenceSchema.qml`, `SettingsSidebar.qml`, `SettingsShell.qml`, `qmldir`, `ShellState.qml`, `SystemSettings.qml`, `AppearancePage.qml`, `DesktopPage.qml`, `ShortcutsPage.qml`, and `config/dot/hypr/*`.
- Never use `hyprctl keyword`; every `hl.bind` requires a description.
- Use `DesktopEntries.applications.values` in reactive bindings. Do not call `byId()` or `heuristicLookup()` in a one-time initialization path.
- Use `SettingRow.activatable` for whole-row clicks. Nested Repeaters address outer models through explicit ids, never `parent.modelData`.
- Only these verified GNOME panels may be opened: 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.
- No mock phase, new visual direction, color literals, or idle animation. Preserve page copy and behavior unless a dead read-only row is replaced by a real control.
- Automated tests isolate XDG config/state, do not change live defaults or autostart entries, do not launch applications, and do not invoke Home actions.
---
### Task 1: Applications, default handlers, and user autostart
**Files:**
- Create: `config/dot/quickshell/services/DefaultApps.qml`
- Create: `config/dot/quickshell/modules/settings/ApplicationsPage.qml`
- Create only if needed for a safe parser/writer boundary: `config/dot/quickshell/scripts/panama-default-apps`
- Create: `tests/quickshell/default-apps-contract.sh`
- Create: `tests/quickshell/applications-settings-contract.sh`
**Interfaces:**
- Consumes: `DesktopEntries.applications.values`, `SettingsPage`, `SettingsCard`, `SettingRow.activatable`, `ActionRow`, `TextRow`, and Claude-owned routing for page id `applications`.
- Produces: a singleton `DefaultApps` with reactive `handlers`, `autostartEntries`, `luaAutostartEntries`, `busy`, `lastError`, `refresh()`, `setDefault(role, desktopId)`, and `setAutostart(desktopId, enabled)`.
- [x] **Step 1: Write failing helper/service and page contracts**
Cover seven roles: browser (`xdg-settings default-web-browser`), mail (`x-scheme-handler/mailto`), files (`inode/directory`), terminal (`x-scheme-handler/terminal`), music (`audio/mpeg`), images (`image/png`), and video (`video/mp4`). Use temporary XDG directories and fake `xdg-settings`/`xdg-mime` binaries; assert setters pass separate arguments and reject unknown roles or desktop ids. Fixture user autostart entries must expose id/name/enabled and toggle with standard `Hidden=` semantics; `hl.exec_cmd` entries parsed from `config/dot/hypr/autostart.lua` are read-only and identify their source.
The page contract must require `DesktopEntries.applications.values`, page id/object name, all seven role labels, user and compositor autostart sections, `SettingRow.activatable`, and must reject `Component.onCompleted` snapshots plus `byId()`/`heuristicLookup()`.
- [x] **Step 2: Run focused contracts to verify RED**
```bash
tests/quickshell/default-apps-contract.sh
tests/quickshell/applications-settings-contract.sh
```
Expected: fail because the service/page and behavior do not exist.
- [x] **Step 3: Implement the minimal typed boundary and page**
All process commands use argument arrays. Validate roles against a fixed map and desktop ids against the reactive applications model or a strict freedesktop id pattern plus discovered entries. The page filters role choices from category/generic-name data, sorts by display name, keeps the current handler visible even when category metadata is sparse, and shows calm inline errors. Toggling applies only to files under `$XDG_CONFIG_HOME/autostart`; Lua entries remain read-only with explanatory copy.
- [x] **Step 4: Verify and commit**
```bash
tests/quickshell/default-apps-contract.sh
tests/quickshell/applications-settings-contract.sh
tests/quickshell/settings-pages-contract.sh
```
Commit subject: `Add application and autostart settings`.
---
### Task 2: Remaining page scaffolds and hardcoded behavior controls
**Files:**
- Modify: `config/dot/quickshell/config/Settings.qml`
- Modify: `config/dot/quickshell/modules/settings/HomePage.qml`
- Modify: `config/dot/quickshell/modules/settings/DisplaysPage.qml`
- Modify: `config/dot/quickshell/modules/settings/ConnectivityPage.qml`
- Modify: `config/dot/quickshell/modules/settings/SoundPage.qml`
- Modify: `config/dot/quickshell/modules/settings/NotificationsPage.qml`
- Modify: `config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml`
- Modify: `config/dot/quickshell/modules/settings/ServicesPage.qml`
- Modify: `config/dot/quickshell/modules/settings/AboutPage.qml`
- Modify: `tests/quickshell/settings-pages-contract.sh`
- Create: `tests/quickshell/settings-hardcoded-values-contract.sh`
**Interfaces:**
- Consumes: Claude-owned schema keys `temperatureUnit`, `weatherRefreshMinutes`, `vitalsIntervalMs`, `notificationTimeoutMs`, `notificationTimeoutCriticalMs`, `notificationHistoryLimit`, `maxVisibleToasts`, `screenshotDir`, `recordingDir`, and `recorderArgs`; shared Settings rows; existing services and system handoffs.
- Produces: the same public `Settings.qml` properties, now each reading `DesktopPreferences.get("key")`, and controls routed to weather/vitals, notifications, and capture pages.
- [x] **Step 1: Write failing contracts**
Require all ten `Settings.qml` properties to use `DesktopPreferences.get()` exactly. Require every listed page root to be `SettingsPage` and reject its copied root `Flickable` scaffold. Require weather/vitals controls on `HomePage`, notification controls on `NotificationsPage`, and capture directory/encoder controls on `ScreenIntelligencePage`; every schema-bound control uses a shared row and writes only through `SystemSettings.commitPreference` via that row.
- [x] **Step 2: Run contracts to verify RED**
```bash
tests/quickshell/settings-hardcoded-values-contract.sh
tests/quickshell/settings-pages-contract.sh
```
Expected: fail on hardcoded properties and copied page scaffolds.
- [x] **Step 3: Implement controls and migrate scaffolds**
Use `ChoiceRow` for `temperatureUnit`, `screenshotDir`, `recordingDir`, and `recorderArgs`; use `SliderRow` for numeric refresh, timeout, history, and toast limits. Give `notificationTimeoutCriticalMs` `zeroLabel: "Never"`. Preserve all specialized buttons, service status rows, display diagnostics, permission/privacy explanations, and GNOME handoff actions. Convert genuinely read-only rows to `TextRow`; delete only rows superseded by working controls.
- [x] **Step 4: Run focused and full suite, then commit**
```bash
tests/quickshell/settings-hardcoded-values-contract.sh
tests/quickshell/settings-pages-contract.sh
tests/quickshell/settings-preferences-contract.sh
tests/quickshell/settings-search-contract.sh
```
Then run every `tests/quickshell/*.sh`, every `tests/hypr/*.sh`, and all three `tests/quickshell/*_test.py` files sequentially.
Commit subject: `Complete Panama settings controls`.
@@ -0,0 +1,103 @@
# Settings Home & Phone Completion Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Finish the Home & Phone Settings page on Panama's shared Settings vocabulary and give the global reset path a named, durable Home preference API.
**Architecture:** `HomePhonePage` adopts `SettingsPage` for the shared scrolling/title/lede scaffold while retaining its specialized catalog, reorder, alias, and phone controls. `HomePreferences.resetHomeDefaults()` becomes the sole Home-store reset boundary: it returns the store to fresh-install state and writes immediately so `SystemSettings.restoreDefaults()` can call it without mutating aliases.
**Tech Stack:** Quickshell 0.3 QML, QtQuick, Bash contract tests, Hyprland.
**Spec:** `docs/superpowers/specs/2026-08-17-panama-cohesion-design.md`
## Global Constraints
- Do not touch `SystemSettings.qml`, `PreferenceSchema.qml`, keybinds, or Claude's shared row implementations in this branch.
- Preserve ordered Home favorites, aliases, first-four Control Center badges, search, drag and keyboard reorder, retry state, Home Assistant status copy, and BlueBubbles availability behavior.
- Automated tests must not toggle or dim a real light and must not launch BlueBubbles.
- New behavior follows red-green TDD; fixture and state directories remain isolated from the live desktop.
- No mock phase and no new visual direction: this is cohesion work against the already-approved design.
---
### Task 1: Shared Home & Phone page and named reset boundary
**Files:**
- Modify: `config/dot/quickshell/config/HomePreferences.qml`
- Modify: `config/dot/quickshell/home-preferences-harness.qml`
- Modify: `config/dot/quickshell/modules/settings/HomePhonePage.qml`
- Modify only if required for shared vocabulary compatibility: `config/dot/quickshell/modules/settings/HomeFavoriteCard.qml`
- Modify only if required for shared vocabulary compatibility: `config/dot/quickshell/modules/settings/AvailableLightRow.qml`
- Modify: `tests/quickshell/home-preferences-contract.sh`
- Modify: `tests/quickshell/home-phone-settings-contract.sh`
**Interfaces:**
- Consumes: `SettingsPage { title; lede; default content }`, existing `SettingsCard`, `SettingRow`, `ActionRow`, `TextRow`, `HomeAssistant`, `SystemSettings.bluebubblesAvailable`, and writable `HomePreferences` adapter state.
- Produces: `HomePreferences.resetHomeDefaults(): void`, which sets `favorites` to `[]`, sets `initialized` to `false`, clears stale save error state, and invokes `preferencesFile.writeAdapter()` immediately after stopping the debounce timer.
- [ ] **Step 1: Write failing contracts**
Add `reset()` to the isolated `home-pref-test` IPC harness. Extend `home-preferences-contract.sh` to seed aliases/order, invoke reset, and require both IPC state and `panama-home.json` to become exactly `{"initialized":false,"favorites":[]}` without waiting for the 180 ms debounce interval. Extend `home-phone-settings-contract.sh` to require `SettingsPage {`, `title: "Home & Phone"`, and the existing lede through `lede:`, while rejecting the copied root `Flickable` scaffold.
- [ ] **Step 2: Run contracts to verify RED**
```bash
tests/quickshell/home-preferences-contract.sh
tests/quickshell/home-phone-settings-contract.sh
```
Expected: the preference contract fails because `reset` is missing; the page contract fails because the page still owns a copied `Flickable` scaffold.
- [ ] **Step 3: Implement the reset API and shared page scaffold**
Implement this public boundary in `HomePreferences.qml`:
```qml
function resetHomeDefaults(): void {
persistTimer.stop();
values.favorites = [];
values.initialized = false;
root.saveError = "";
preferencesFile.writeAdapter();
}
```
Replace the `HomePhonePage` root `Item` plus nested `Flickable`/title/lede scaffold with:
```qml
SettingsPage {
id: root
objectName: "home-phone-page"
title: "Home & Phone"
lede: "Choose what appears in Control Center and keep phone continuity close at hand."
// Existing SettingsCard content remains in order.
}
```
Use shared `ActionRow` or `TextRow` only where their single-action/read-only contracts preserve all current status and accessibility behavior. Keep specialized rows when the shared primitive would lose information.
- [ ] **Step 4: Run focused contracts to GREEN**
```bash
tests/quickshell/home-preferences-contract.sh
tests/quickshell/home-phone-settings-contract.sh
tests/quickshell/settings-pages-contract.sh
tests/quickshell/settings-rows-contract.sh
tests/quickshell/settings-commit-reset-contract.sh
```
Expected: every command exits 0; no test launches BlueBubbles or changes a real Home Assistant entity.
- [ ] **Step 5: Commit**
```bash
git add config/dot/quickshell/config/HomePreferences.qml \
config/dot/quickshell/home-preferences-harness.qml \
config/dot/quickshell/modules/settings/HomePhonePage.qml \
config/dot/quickshell/modules/settings/HomeFavoriteCard.qml \
config/dot/quickshell/modules/settings/AvailableLightRow.qml \
tests/quickshell/home-preferences-contract.sh \
tests/quickshell/home-phone-settings-contract.sh \
docs/superpowers/plans/2026-08-18-settings-home-phone-completion.md
git commit -m "Finish Home and Phone settings cohesion"
```
+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'
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
set -euo pipefail
fail() {
printf 'applications settings contract: %s\n' "$1" >&2
exit 1
}
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
page="$project_root/config/dot/quickshell/modules/settings/ApplicationsPage.qml"
[[ -f "$page" ]] || fail 'Applications page is missing'
assert_contains() {
rg -F --quiet "$1" "$page" || fail "page is missing: $1"
}
assert_contains 'SettingsPage {'
assert_contains 'objectName: "applications"'
assert_contains 'DesktopEntries.applications.values'
assert_contains 'DefaultApps'
assert_contains 'SettingsCard {'
assert_contains 'SettingRow {'
assert_contains 'activatable:'
assert_contains 'ActionRow {'
assert_contains 'TextRow {'
for label in Browser Mail Files Terminal Music Images Video; do
assert_contains "label: \"$label\""
done
assert_contains 'title: "Default applications"'
assert_contains 'title: "User autostart"'
assert_contains 'title: "Compositor autostart"'
assert_contains 'categories'
assert_contains 'genericName'
assert_contains '.sort('
assert_contains 'currentEntry'
assert_contains 'read-only'
assert_contains 'choices.push(currentEntry)'
assert_contains 'label: "Application settings need attention"'
assert_contains 'DefaultApps.busy ? "Loading…"'
assert_contains 'visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0'
assert_contains 'visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0'
PAGE_PATH="$page" bun -e '
const source = await Bun.file(process.env.PAGE_PATH).text();
const rolesSource = source.match(/readonly property var roles:\s*(\[[\s\S]*?\n \])/);
const matcherSource = source.match(/function matchesRole\(entry: var, role: var\): bool \{([\s\S]*?)\n \}/);
if (!rolesSource || !matcherSource) {
console.error("applications settings contract: role matcher could not be loaded");
process.exit(1);
}
const roles = Function(`return (${rolesSource[1]})`)();
const matchesRole = Function("entry", "role", matcherSource[1]);
const role = key => roles.find(candidate => candidate.key === key);
const fixtures = [
{
name: "AudioVideo does not imply music",
entry: { name: "Kodi", genericName: "Media Center", comment: "Entertainment hub", categories: "AudioVideo;Player;" },
role: "music",
expected: false
},
{
name: "Graphics does not imply image handler",
entry: { name: "Document Scanner", genericName: "Document Scanner", comment: "Scan documents", categories: ["Graphics"] },
role: "images",
expected: false
},
{
name: "Viewer does not imply image handler",
entry: { name: "Papers", genericName: "Document Viewer", comment: "Read documents", categories: "Office;Viewer;" },
role: "images",
expected: false
},
{
name: "comment does not nominate a default handler",
entry: { name: "Settings", genericName: "System Settings", comment: "Configure your video player", categories: ["System"] },
role: "video",
expected: false
},
{
name: "exact audio player categories match music",
entry: { name: "Rhythmbox", genericName: "Music Player", comment: "Play music", categories: "AudioVideo;Audio;Player;" },
role: "music",
expected: true
},
{
name: "exact video category matches video",
entry: { name: "Videos", genericName: "Video Player", comment: "Play movies", categories: ["AudioVideo", "Video", "Player"] },
role: "video",
expected: true
},
{
name: "descriptive metadata matches image handler",
entry: { name: "Loupe", genericName: "Image Viewer", comment: "Browse pictures", categories: "Graphics;Viewer;" },
role: "images",
expected: true
}
];
for (const fixture of fixtures) {
const actual = matchesRole(fixture.entry, role(fixture.role));
if (actual !== fixture.expected) {
console.error(`applications settings contract: ${fixture.name}: expected ${fixture.expected}, got ${actual}`);
process.exit(1);
}
}
'
if rg --quiet 'Component\.onCompleted|DesktopEntries\.(byId|heuristicLookup)' "$page"; then
fail 'page snapshots or performs a one-time desktop-entry lookup'
fi
if rg -F --quiet 'label: "Could not apply the change"' "$page"; then
fail 'error heading incorrectly describes read failures as apply failures'
fi
if rg --quiet '#[0-9A-Fa-f]{3,8}' "$page"; then
fail 'page introduces a color literal instead of the shared visual system'
fi
[[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \
|| fail 'default and autostart rows are not both whole-row activatable'
printf 'applications settings contract: PASS\n'
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env bash
set -euo pipefail
fail() {
printf 'default apps contract: %s\n' "$1" >&2
exit 1
}
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$project_root/config/dot/quickshell/scripts/panama-default-apps"
service="$project_root/config/dot/quickshell/services/DefaultApps.qml"
test_root="$(mktemp -d /tmp/panama-default-apps.XXXXXX)"
trap 'rm -rf "$test_root"' EXIT
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
[[ -f "$service" ]] || fail 'DefaultApps service is missing'
assert_service_contains() {
rg -F --quiet "$1" "$service" || fail "service is missing: $1"
}
assert_service_contains 'pragma Singleton'
assert_service_contains 'property var handlers'
assert_service_contains 'property var autostartEntries'
assert_service_contains 'property var luaAutostartEntries'
assert_service_contains 'readonly property bool busy'
assert_service_contains 'property string lastError'
assert_service_contains 'function refresh(): void'
assert_service_contains 'function setDefault(role: string, desktopId: string): void'
assert_service_contains 'function setAutostart(desktopId: string, enabled: bool): void'
assert_service_contains 'DesktopEntries.applications.values'
if rg --quiet 'command\s*:\s*"' "$service"; then
fail 'Process command must be an argument array'
fi
config_home="$test_root/config"
data_home="$test_root/data"
data_dirs="$test_root/data-dirs"
fake_bin="$test_root/bin"
call_log="$test_root/calls"
lua_fixture="$test_root/autostart.lua"
mkdir -p "$config_home/autostart" "$data_home/applications" "$data_dirs" "$fake_bin"
write_application() {
local desktop_id="$1"
local name="$2"
local generic_name="$3"
local categories="$4"
cat >"$data_home/applications/$desktop_id" <<EOF
[Desktop Entry]
Type=Application
Name=$name
GenericName=$generic_name
Categories=$categories
Exec=/usr/bin/true
EOF
}
write_application org.mozilla.firefox.desktop Firefox 'Web Browser' 'Network;WebBrowser;'
write_application org.gnome.Geary.desktop Geary 'Mail Client' 'Network;Email;'
write_application org.gnome.Nautilus.desktop Files 'File Manager' 'System;FileManager;'
write_application org.gnome.Ptyxis.desktop Ptyxis Terminal 'System;TerminalEmulator;'
write_application org.gnome.Rhythmbox3.desktop Rhythmbox 'Music Player' 'AudioVideo;Audio;Player;'
write_application org.gnome.Loupe.desktop Loupe 'Image Viewer' 'Graphics;Viewer;'
write_application org.gnome.Totem.desktop Videos 'Video Player' 'AudioVideo;Video;Player;'
cat >"$config_home/autostart/nextcloud.desktop" <<'EOF'
[Desktop Entry]
Type=Application
Name=Nextcloud
Exec=nextcloud --background
Hidden=true
EOF
cat >"$lua_fixture" <<'EOF'
hl.on("hyprland.start", function()
hl.exec_cmd("quickshell --daemonize")
hl.exec_cmd("nextcloud --background")
end)
hl.on("hyprland.shutdown", function()
hl.exec_cmd("systemctl --user stop hyprland-session.target")
end)
EOF
cat >"$fake_bin/xdg-settings" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$@" >>"$PANAMA_CALL_LOG"
if [[ "$1" == "get" && "$2" == "default-web-browser" ]]; then
printf '%s\n' 'org.mozilla.firefox.desktop'
exit 0
fi
EOF
chmod +x "$fake_bin/xdg-settings"
cat >"$fake_bin/xdg-mime" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$@" >>"$PANAMA_CALL_LOG"
if [[ "$1" == "query" && "$2" == "default" ]]; then
case "$3" in
x-scheme-handler/mailto) printf '%s\n' 'org.gnome.Geary.desktop' ;;
inode/directory) printf '%s\n' 'org.gnome.Nautilus.desktop' ;;
x-scheme-handler/terminal) printf '%s\n' 'org.gnome.Ptyxis.desktop' ;;
audio/mpeg) printf '%s\n' 'org.gnome.Rhythmbox3.desktop' ;;
image/png) printf '%s\n' 'org.gnome.Loupe.desktop' ;;
video/mp4) printf '%s\n' 'org.gnome.Totem.desktop' ;;
*) exit 91 ;;
esac
exit 0
fi
EOF
chmod +x "$fake_bin/xdg-mime"
export XDG_CONFIG_HOME="$config_home"
export XDG_DATA_HOME="$data_home"
export XDG_DATA_DIRS="$data_dirs"
export PANAMA_HYPR_AUTOSTART="$lua_fixture"
export PANAMA_CALL_LOG="$call_log"
export PATH="$fake_bin:$PATH"
snapshot="$($helper snapshot)" || fail 'snapshot command failed'
[[ "$(rg --count '^get$' "$call_log")" == "1" ]] \
|| fail 'browser handler was queried more than once'
[[ "$(rg --count '^query$' "$call_log")" == "6" ]] \
|| fail 'MIME handlers were queried more than once'
jq -e '
.handlers == {
browser: "org.mozilla.firefox.desktop",
mail: "org.gnome.Geary.desktop",
files: "org.gnome.Nautilus.desktop",
terminal: "org.gnome.Ptyxis.desktop",
music: "org.gnome.Rhythmbox3.desktop",
images: "org.gnome.Loupe.desktop",
video: "org.gnome.Totem.desktop"
} and
.autostartEntries == [{id: "nextcloud.desktop", name: "Nextcloud", enabled: false}] and
(.luaAutostartEntries | length == 2) and
([.luaAutostartEntries[] |
.enabled == true and .readOnly == true and
.source == "config/dot/hypr/autostart.lua" and
(.id | startswith("hyprland:")) and
(.name | length > 0) and (.command | length > 0)
] | all) and
([.luaAutostartEntries[].command] |
index("systemctl --user stop hyprland-session.target") == null)
' <<<"$snapshot" >/dev/null || fail 'snapshot shape, handlers, or autostart parsing is incorrect'
assert_call() {
local expected="$1"
local actual
actual="$(cat "$call_log")"
[[ "$actual" == "$expected" ]] || {
printf 'expected argv:\n%s\nactual argv:\n%s\n' "$expected" "$actual" >&2
fail 'setter did not pass separate arguments'
}
}
: >"$call_log"
$helper set-default browser org.mozilla.firefox.desktop
assert_call $'set\ndefault-web-browser\norg.mozilla.firefox.desktop'
roles=(mail files terminal music images video)
desktop_ids=(
org.gnome.Geary.desktop
org.gnome.Nautilus.desktop
org.gnome.Ptyxis.desktop
org.gnome.Rhythmbox3.desktop
org.gnome.Loupe.desktop
org.gnome.Totem.desktop
)
mime_types=(
x-scheme-handler/mailto
inode/directory
x-scheme-handler/terminal
audio/mpeg
image/png
video/mp4
)
for index in "${!roles[@]}"; do
: >"$call_log"
$helper set-default "${roles[$index]}" "${desktop_ids[$index]}"
assert_call $'default\n'"${desktop_ids[$index]}"$'\n'"${mime_types[$index]}"
done
: >"$call_log"
if $helper set-default unknown org.mozilla.firefox.desktop >/dev/null 2>&1; then
fail 'unknown role was accepted'
fi
[[ ! -s "$call_log" ]] || fail 'unknown role reached an xdg command'
if $helper set-default browser org.example.Missing.desktop >/dev/null 2>&1; then
fail 'undiscovered desktop id was accepted'
fi
if $helper set-default browser ../escape.desktop >/dev/null 2>&1; then
fail 'unsafe desktop id was accepted'
fi
$helper set-autostart nextcloud.desktop true
rg --quiet '^Hidden=false$' "$config_home/autostart/nextcloud.desktop" \
|| fail 'enabling autostart did not set Hidden=false'
[[ "$(rg --count '^Hidden=' "$config_home/autostart/nextcloud.desktop")" == "1" ]] \
|| fail 'enabling autostart duplicated Hidden'
rg --quiet '^Exec=nextcloud --background$' "$config_home/autostart/nextcloud.desktop" \
|| fail 'autostart update damaged another desktop key'
jq -e '.autostartEntries == [{id: "nextcloud.desktop", name: "Nextcloud", enabled: true}]' \
<<<"$($helper snapshot)" >/dev/null || fail 'enabled state did not round-trip'
$helper set-autostart nextcloud.desktop false
rg --quiet '^Hidden=true$' "$config_home/autostart/nextcloud.desktop" \
|| fail 'disabling autostart did not set Hidden=true'
outside_entry="$test_root/outside.desktop"
cp "$config_home/autostart/nextcloud.desktop" "$outside_entry"
ln -s "$outside_entry" "$config_home/autostart/linked.desktop"
if $helper set-autostart linked.desktop true >/dev/null 2>&1; then
fail 'autostart symlink escaping XDG config was accepted'
fi
rg --quiet '^Hidden=true$' "$outside_entry" || fail 'outside autostart file was modified'
if $helper set-autostart missing.desktop true >/dev/null 2>&1; then
fail 'unknown autostart desktop id was accepted'
fi
if $helper set-autostart 'hyprland:1' false >/dev/null 2>&1; then
fail 'read-only compositor entry was accepted for mutation'
fi
printf 'default apps contract: PASS\n'
@@ -80,8 +80,12 @@ system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml"
[[ -f "$home_page" ]] || fail 'HomePhonePage.qml is missing' [[ -f "$home_page" ]] || fail 'HomePhonePage.qml is missing'
[[ -f "$favorite_card" ]] || fail 'HomeFavoriteCard.qml is missing' [[ -f "$favorite_card" ]] || fail 'HomeFavoriteCard.qml is missing'
[[ -f "$available_row" ]] || fail 'AvailableLightRow.qml is missing' [[ -f "$available_row" ]] || fail 'AvailableLightRow.qml is missing'
assert_contains 'text: "Home & Phone"' "$home_page" assert_contains 'SettingsPage {' "$home_page"
assert_contains 'text: "Choose what appears in Control Center and keep phone continuity close at hand."' "$home_page" assert_contains 'title: "Home & Phone"' "$home_page"
assert_contains 'lede: "Choose what appears in Control Center and keep phone continuity close at hand."' "$home_page"
if rg -q '^\s*Flickable \{' "$home_page"; then
fail 'HomePhonePage.qml still owns a copied Flickable scaffold'
fi
assert_contains 'Connected · ' "$home_page" assert_contains 'Connected · ' "$home_page"
assert_contains 'Last update unavailable · showing saved controls' "$home_page" assert_contains 'Last update unavailable · showing saved controls' "$home_page"
assert_contains 'Authentication required' "$home_page" assert_contains 'Authentication required' "$home_page"
+25 -3
View File
@@ -4,6 +4,7 @@ set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/home-preferences-harness.qml" harness="$repo_dir/config/dot/quickshell/home-preferences-harness.qml"
preferences="$repo_dir/config/dot/quickshell/config/HomePreferences.qml"
state_home="$(mktemp -d /tmp/panama-home-preferences-state.XXXXXX)" state_home="$(mktemp -d /tmp/panama-home-preferences-state.XXXXXX)"
fail() { fail() {
@@ -73,9 +74,7 @@ wait_for_file_content() {
for _ in $(seq 1 40); do for _ in $(seq 1 40); do
state_file="$(find "$state_home" -name panama-home.json -print -quit)" state_file="$(find "$state_home" -name panama-home.json -print -quit)"
if [[ -n "$state_file" ]] \ if [[ -n "$state_file" ]] \
&& jq -e --argjson expected "$expected" \ && jq -e --argjson expected "$expected" '. == $expected' "$state_file" >/dev/null; then
'.initialized == $expected.initialized and .favorites == $expected.favorites' \
"$state_file" >/dev/null; then
return return
fi fi
sleep 0.1 sleep 0.1
@@ -83,6 +82,15 @@ wait_for_file_content() {
fail 'preferences file did not contain the complete atomic update' fail 'preferences file did not contain the complete atomic update'
} }
assert_reset_persists_without_debounce() {
local expected=$' function resetHomeDefaults(): void {\n persistTimer.stop();\n values.favorites = [];\n values.initialized = false;\n root.saveError = "";\n preferencesFile.writeAdapter();\n }'
local actual
actual="$(sed -n '/^ function resetHomeDefaults(): void {$/,/^ }$/p' "$preferences")"
[[ "$actual" == "$expected" ]] \
|| fail 'resetHomeDefaults must stop debounce before directly writing the default state'
}
# A leading JSON whitespace prevents qs from expanding the array into IPC # A leading JSON whitespace prevents qs from expanding the array into IPC
# positional arguments; JSON.parse() intentionally accepts that whitespace. # positional arguments; JSON.parse() intentionally accepts that whitespace.
initial_ids=' ["light.kitchen","light.hall","light.desk"]' initial_ids=' ["light.kitchen","light.hall","light.desk"]'
@@ -90,7 +98,10 @@ expected='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":
expected_file='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}]}' expected_file='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}]}'
empty_expected='{"initialized":true,"favorites":[],"saveError":""}' empty_expected='{"initialized":true,"favorites":[],"saveError":""}'
empty_file='{"initialized":true,"favorites":[]}' empty_file='{"initialized":true,"favorites":[]}'
reset_expected='{"initialized":false,"favorites":[],"saveError":""}'
reset_file='{"initialized":false,"favorites":[]}'
assert_reset_persists_without_debounce
start_harness start_harness
qs_for_harness ipc call home-pref-test initialize "$initial_ids" >/dev/null qs_for_harness ipc call home-pref-test initialize "$initial_ids" >/dev/null
qs_for_harness ipc call home-pref-test alias light.kitchen ' Island ' >/dev/null qs_for_harness ipc call home-pref-test alias light.kitchen ' Island ' >/dev/null
@@ -99,9 +110,20 @@ qs_for_harness ipc call home-pref-test remove light.hall >/dev/null
wait_for_status "$expected" wait_for_status "$expected"
wait_for_file_content "$expected_file" wait_for_file_content "$expected_file"
qs_for_harness ipc call home-pref-test reset >/dev/null
wait_for_status "$reset_expected"
wait_for_file_content "$reset_file"
stop_harness stop_harness
start_harness start_harness
wait_for_status "$reset_expected"
qs_for_harness ipc call home-pref-test initialize "$initial_ids" >/dev/null
qs_for_harness ipc call home-pref-test alias light.kitchen ' Island ' >/dev/null
qs_for_harness ipc call home-pref-test move light.desk 0 >/dev/null
qs_for_harness ipc call home-pref-test remove light.hall >/dev/null
wait_for_status "$expected" wait_for_status "$expected"
wait_for_file_content "$expected_file"
qs_for_harness ipc call home-pref-test remove light.desk >/dev/null qs_for_harness ipc call home-pref-test remove light.desk >/dev/null
qs_for_harness ipc call home-pref-test remove light.kitchen >/dev/null qs_for_harness ipc call home-pref-test remove light.kitchen >/dev/null
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Rebinding a keyboard shortcut.
#
# This is the highest-consequence write in the settings app: a mistake here
# costs the user their keymap, and the keymap is how they reach everything else.
# The properties that matter:
#
# * an override moves exactly the bind it names and nothing else -- keying by
# description moved every bind sharing one, which silently cost the
# XF86Calculator hardware key when SUPER+C was rebound;
# * only the chord is ever stored, never the action;
# * a chord already in use is refused rather than shadowing the existing bind;
# * resetting returns the exact shipped keymap.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/keybinds-harness.qml"
service="$repo_dir/config/dot/quickshell/services/Keybinds.qml"
fail() {
printf 'keybind rebind contract: %s\n' "$1" >&2
exit 1
}
rg -Fq 'function overrideOccupantFor(chord: string, exceptShipped: string): string' "$service" \
|| fail 'resetBind has no override collision guard'
rg -Fq 'root.overrideOccupantFor(shipped, shipped)' "$service" \
|| fail 'resetBind does not check whether another override occupies its shipped chord'
if [[ "${PANAMA_KEYBINDS_STATIC_ONLY:-0}" == "1" ]]; then
printf 'keybind rebind contract: PASS (static)\n'
exit 0
fi
config_home="$(mktemp -d /tmp/panama-rebind-config.XXXXXX)"
# The compositor is the live one -- that is the point -- but preferences are
# isolated so this cannot leave an override in the user's real settings.
# hyprctl reload re-reads the real settings file, so the compositor is only
# exercised through the shipped configuration here; the override logic itself is
# what is under test.
qs_for_harness() {
XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"
}
cleanup() {
qs_for_harness ipc call keybinds-test resetAll >/dev/null 2>&1 || true
qs_for_harness kill >/dev/null 2>&1 || true
rm -rf "$config_home"
}
trap cleanup EXIT
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
qs_for_harness ipc show 2>/dev/null | rg -q '^target keybinds-test$' && break
sleep 0.1
done
qs_for_harness ipc show 2>/dev/null | rg -q '^target keybinds-test$' || fail 'test IPC target did not start'
for _ in $(seq 1 40); do
[[ "$(qs_for_harness ipc call keybinds-test status | jq -r .loaded)" == "true" ]] && break
sleep 0.1
done
# ── Nothing is overridden to begin with ──────────────────────────────────────
[[ "$(qs_for_harness ipc call keybinds-test overrideState | jq -r .count)" == "0" ]] \
|| fail 'the isolated store started with overrides'
# ── A chord already in use is refused ────────────────────────────────────────
terminal="$(qs_for_harness ipc call keybinds-test chordFor Terminal)"
[[ -n "$terminal" ]] || fail 'could not find the Terminal bind'
files="$(qs_for_harness ipc call keybinds-test chordFor Files)"
[[ -n "$files" ]] || fail 'could not find the Files bind'
[[ "$(qs_for_harness ipc call keybinds-test rebind "$terminal" "$files")" == "false" ]] \
|| fail 'rebinding onto a chord already in use was accepted'
[[ "$(qs_for_harness ipc call keybinds-test overrideState | jq -r .count)" == "0" ]] \
|| fail 'a refused rebind still stored an override'
# ── A rebind stores only the chord, keyed by the shipped chord ───────────────
[[ "$(qs_for_harness ipc call keybinds-test rebind "$terminal" "SUPER + SHIFT + F9")" == "true" ]] \
|| fail 'a valid rebind was refused'
state="$(qs_for_harness ipc call keybinds-test overrideState)"
jq -e --arg k "$terminal" '.overrides[$k] == "SUPER + SHIFT + F9"' <<<"$state" >/dev/null \
|| fail "the override was not keyed by the shipped chord: $state"
jq -e '.count == 1' <<<"$state" >/dev/null || fail "exactly one override expected: $state"
# Only a chord is stored. Nothing resembling an action or command may appear,
# because that is what keeps a user-editable file from being executable.
jq -e '[.overrides[]] | all(type == "string" and (length < 64))' <<<"$state" >/dev/null \
|| fail 'an override value is not a plain chord'
# ── Reset clears it ──────────────────────────────────────────────────────────
qs_for_harness ipc call keybinds-test resetAll >/dev/null
sleep 0.5
[[ "$(qs_for_harness ipc call keybinds-test overrideState | jq -r .count)" == "0" ]] \
|| fail 'resetAll left overrides behind'
# Terminal moved away from its shipped chord, then Files moved into it.
# Resetting Terminal must refuse instead of producing two binds on one chord.
qs_for_harness ipc call keybinds-test seedResetCollision \
"$terminal" "SUPER + SHIFT + F9" "$files" >/dev/null
sleep 0.2
[[ "$(qs_for_harness ipc call keybinds-test resetBind "SUPER + SHIFT + F9")" == "false" ]] \
|| fail 'resetBind reclaimed a shipped chord occupied by another override'
collision_state="$(qs_for_harness ipc call keybinds-test overrideState)"
jq -e --arg terminal "$terminal" --arg files "$files" \
'.count == 2 and .overrides[$terminal] == "SUPER + SHIFT + F9" and .overrides[$files] == $terminal and (.lastError | length > 0)' \
<<<"$collision_state" >/dev/null \
|| fail "a refused reset changed overrides or gave no explanation: $collision_state"
trap - EXIT
cleanup
printf 'keybind rebind contract: PASS\n'
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# The Shortcuts page is generated from the compositor, so it cannot drift from
# the real keymap. Two things have to hold for that to be true:
#
# * every bind Hyprland reports is presented -- the page count matches
# `hyprctl binds -j` exactly, so adding a bind cannot silently go missing;
# * every bind carries a description. Hyprland reports Lua-defined binds with
# dispatcher "__lua" and a bytecode offset as the argument, so a bind
# without a description has nothing a person could read beside its chord.
# The service drops those rather than showing a mystery row, which means an
# undescribed bind disappears from the page -- this contract is what stops
# that from being a silent loss.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/keybinds-harness.qml"
fail() {
printf 'keybinds 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
# ── Every bind in the running compositor must be describable ─────────────────
total="$(hyprctl -j binds | jq 'length')"
undescribed="$(hyprctl -j binds | jq '[.[] | select((.description // "") == "")] | length')"
[[ "$total" -gt 0 ]] || fail 'the compositor reports no binds at all'
if [[ "$undescribed" -ne 0 ]]; then
printf 'keybinds contract: %s bind(s) have no description and would be dropped from the page:\n' "$undescribed" >&2
hyprctl -j binds | jq -r '.[] | select((.description // "") == "") | " modmask=\(.modmask) key=\(.key)"' >&2
fail 'add a description to each in config/dot/hypr/keybinds.lua'
fi
# ── The page must present all of them ────────────────────────────────────────
qs_for_harness --daemonize >/dev/null
for _ in $(seq 1 40); do
qs_for_harness ipc show 2>/dev/null | rg -q '^target keybinds-test$' && break
sleep 0.1
done
qs_for_harness ipc show 2>/dev/null | rg -q '^target keybinds-test$' || fail 'test IPC target did not start'
state='{}'
for _ in $(seq 1 40); do
state="$(qs_for_harness ipc call keybinds-test status | jq -c .)"
jq -e '.loaded == true' <<<"$state" >/dev/null 2>&1 && break
sleep 0.1
done
jq -e '.loaded == true' <<<"$state" >/dev/null || fail "the keymap never loaded: $state"
presented="$(jq -r .count <<<"$state")"
[[ "$presented" == "$total" ]] \
|| fail "the page presents $presented of $total binds — the two must match exactly"
grouped_total="$(jq -r .groupedCount <<<"$state")"
[[ "$grouped_total" == "$total" ]] \
|| fail "grouping lost binds: $grouped_total grouped from $total"
# ── Chords must be rendered for people, not dumped raw ───────────────────────
jq -e '.sample | test("^Super \\+ ")' <<<"$state" >/dev/null \
|| fail "Super+T did not render as a readable chord: $(jq -r .sample <<<"$state")"
[[ "$(jq -r .emptyDescriptions <<<"$state")" == "0" ]] \
|| fail 'a presented bind has an empty description'
trap - EXIT
cleanup
printf 'keybinds contract: PASS (%s binds, all described)\n' "$total"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Every schema entry with a `hypr` block declares `readAs`: which JSON field
# `hyprctl getoption` answers with for that option. Verification compares
# against that field, so a wrong declaration does not fail loudly -- it makes
# every write to that setting look rejected, and the user sees "Hyprland did not
# apply ..." for a change that actually worked.
#
# cursor:inactive_timeout shipped as "int" and is answered as "float", which is
# exactly that failure. This contract asks the compositor for the real shape of
# every mapped option so the next one cannot reach a release.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
fail() {
printf 'schema hypr shape contract: %s\n' "$1" >&2
exit 1
}
[[ -r "$schema" ]] || fail "cannot read $schema"
mismatches=0
checked=0
while IFS='|' read -r option declared; do
[[ -n "$option" ]] || continue
checked=$((checked + 1))
answer="$(hyprctl -j getoption "$option" 2>/dev/null)" \
|| fail "hyprctl could not read $option"
# An option Hyprland does not know answers without any value field at all.
actual=""
for field in int bool float str css; do
if jq -e --arg f "$field" 'has($f)' <<<"$answer" >/dev/null 2>&1; then
actual="$field"
break
fi
done
[[ -n "$actual" ]] || fail "$option is not a known Hyprland option (answered: $answer)"
if [[ "$actual" != "$declared" ]]; then
printf 'schema hypr shape contract: %s declares readAs "%s" but answers with "%s"\n' \
"$option" "$declared" "$actual" >&2
mismatches=$((mismatches + 1))
fi
done < <(grep -oE 'option: "[^"]+", readAs: "[a-z]+"' "$schema" \
| sed -E 's/option: "([^"]+)", readAs: "([a-z]+)"/\1|\2/')
[[ "$checked" -gt 0 ]] || fail 'no hypr-mapped schema entries were found to check'
[[ "$mismatches" -eq 0 ]] || fail "$mismatches option(s) declare the wrong answer shape"
printf 'schema hypr shape contract: PASS (%d mapped options)\n' "$checked"
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env bash
# Snapshots of the settings store.
#
# The restore path overwrites the file the whole desktop reads, so the
# properties that matter are: a corrupt snapshot is never restored over a
# working configuration, a restore snapshots what it replaces so it is itself
# undoable, and a snapshot name cannot be used to reach a file outside the
# backup directory.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-settings-backup"
work="$(mktemp -d /tmp/panama-backup-contract.XXXXXX)"
fail() {
printf 'settings backup contract: %s\n' "$1" >&2
exit 1
}
cleanup() { rm -rf "$work"; }
trap cleanup EXIT
settings="$work/config/panama/settings.json"
home="$work/state/panama/panama-home.json"
backups="$work/state/panama/backups"
transaction_dir="$work/state/panama/transactions/settings-restore"
mkdir -p "$(dirname "$settings")"
run() { XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" "$helper" "$@"; }
run_with() { XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" env "$@"; }
assert_transaction_clean() {
if [[ -d "$transaction_dir" ]] && find "$transaction_dir" -mindepth 1 -print -quit | rg -q .; then
fail 'restore left staged, rollback, or journal files behind'
fi
if find "$work" -type f \( \
-name '.settings-restore.*' -o -name '.home-restore.*' \
-o -name '*rollback*' -o -name '.journal.json.*' \
-o -name '.settings.json.*' -o -name '.panama-home.json.*' \
\) -print -quit | rg -q .; then
fail 'restore left a temporary target or journal file behind'
fi
}
# ── Nothing to back up ───────────────────────────────────────────────────────
run save >/dev/null 2>&1 && fail 'backing up a missing settings file reported success'
[[ "$(run list)" == "[]" ]] || fail 'an empty backup directory did not list as empty'
# ── A snapshot round-trips ───────────────────────────────────────────────────
printf '{"gapsOut":24,"windowRounding":6}' >"$settings"
mkdir -p "$(dirname "$home")"
printf '{"initialized":true,"favorites":[{"id":"light.desk","alias":"Desk"}]}' >"$home"
run save >/dev/null || fail 'save failed on a valid settings file'
name="$(run list | jq -r '.[0].name')"
[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] || fail "unexpected snapshot name: $name"
[[ "$(run list | jq -r '.[0].keys')" == "2" ]] || fail 'snapshot key count is wrong'
printf '{"gapsOut":99}' >"$settings"
printf '{"initialized":false,"favorites":[]}' >"$home"
restore_result="$(run restore "$name")" || fail 'restore failed'
[[ "$(jq -r .gapsOut "$settings")" == "24" ]] || fail 'restore did not bring back the snapshot contents'
[[ "$(jq -r .windowRounding "$settings")" == "6" ]] || fail 'restore lost a key'
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.desk" ]] || fail 'restore did not bring back Home favourites'
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Desk" ]] || fail 'restore lost a Home alias'
jq -e '.home.present == true and .home.data.favorites[0].id == "light.desk"' <<<"$restore_result" >/dev/null \
|| fail 'restore did not return Home state for the live service to reload'
# ── Absence is part of a snapshot ───────────────────────────────────────────
rm -f "$home"
printf '{"gapsOut":30}' >"$settings"
run save >/dev/null || fail 'save failed when Home state was absent'
absent_name="$(run list | jq -r '.[0].name')"
printf '{"initialized":true,"favorites":[{"id":"light.living_room","alias":"Living room"}]}' >"$home"
absent_result="$(run restore "$absent_name")" || fail 'restore failed for a snapshot without Home state'
[[ ! -e "$home" ]] || fail 'restore did not preserve the snapshot’s absent Home state'
jq -e '.home.present == false and (.home | has("data") | not)' <<<"$absent_result" >/dev/null \
|| fail 'restore did not return absent Home state for the live service to reload'
# Desktop absence is symmetric: a Home-only snapshot removes a desktop file
# created later and restores the Home store.
rm -f "$settings"
printf '{"initialized":true,"favorites":[{"id":"light.porch","alias":"Porch"}]}' >"$home"
run save >/dev/null || fail 'save failed when desktop settings were absent'
desktop_absent_name="$(run list | jq -r '.[0].name')"
printf '{"gapsOut":47}' >"$settings"
printf '{"initialized":false,"favorites":[]}' >"$home"
run restore "$desktop_absent_name" >/dev/null || fail 'Home-only snapshot restore failed'
[[ ! -e "$settings" ]] || fail 'restore did not preserve the snapshot’s absent desktop state'
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.porch" ]] \
|| fail 'Home-only snapshot did not restore Home state'
assert_transaction_clean
printf '{"gapsOut":17}' >"$settings"
# A legacy settings-only snapshot predates presence metadata. Its safest
# interpretation is to restore desktop settings without deleting current Home
# state that the old format knew nothing about.
legacy="settings-20000101-010203004.json"
printf '{"gapsOut":17}' >"$backups/$legacy"
printf '{"initialized":true,"favorites":[{"id":"light.office","alias":"Office"}]}' >"$home"
run restore "$legacy" >/dev/null || fail 'legacy snapshot restore failed'
[[ "$(jq -r .gapsOut "$settings")" == "17" ]] || fail 'legacy snapshot did not restore desktop settings'
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.office" ]] || fail 'legacy snapshot destroyed Home state it did not describe'
# `version` is a valid unknown desktop preference. It is only an envelope when
# the complete v2 shape is present.
legacy_version="settings-20000101-010203005.json"
printf '{"version":77,"gapsOut":19}' >"$backups/$legacy_version"
run restore "$legacy_version" >/dev/null || fail 'a legacy snapshot with an unknown version key was rejected'
[[ "$(jq -r '.version' "$settings")" == "77" ]] || fail 'legacy version key was not restored as desktop data'
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.office" ]] || fail 'legacy version key changed Home state'
# ── A durable journal recovers a process/power-loss split ────────────────────
printf '{"gapsOut":28,"windowRounding":12}' >"$settings"
printf '{"initialized":true,"favorites":[{"id":"light.desk","alias":"Snapshot"}]}' >"$home"
run save >/dev/null || fail 'could not create crash-recovery snapshot'
crash_name="$(run list | jq -r '.[0].name')"
printf '{"gapsOut":91,"windowRounding":3}' >"$settings"
printf '{"initialized":true,"favorites":[{"id":"light.office","alias":"Before crash"}]}' >"$home"
run_with PANAMA_SETTINGS_BACKUP_TEST_CRASH=after-desktop "$helper" restore "$crash_name" >/dev/null 2>&1 \
&& fail 'crash injection completed restore instead of terminating after the first replacement'
[[ "$(jq -r '.gapsOut' "$settings")" == "28" ]] || fail 'crash did not occur after desktop replacement'
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Before crash" ]] || fail 'crash unexpectedly replaced Home state'
[[ -f "$transaction_dir/journal.json" ]] || fail 'crash left no durable recovery journal'
# Every entry point must recover before doing its own work. `list` is the least
# invasive proof and must put both stores back to the pre-restore generation.
run list >/dev/null || fail 'next invocation could not recover the interrupted restore'
[[ "$(jq -r '.gapsOut' "$settings")" == "91" ]] || fail 'recovery did not roll desktop settings back'
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Before crash" ]] || fail 'recovery did not keep Home state in the same generation'
assert_transaction_clean
# Cleanup is installed before staging. A deterministic pre-journal failure
# must leave both destinations untouched and no hidden artifacts behind.
run_with PANAMA_SETTINGS_BACKUP_TEST_FAIL=after-desktop-stage "$helper" restore "$crash_name" >/dev/null 2>&1 \
&& fail 'staging failure injection unexpectedly restored the snapshot'
[[ "$(jq -r '.gapsOut' "$settings")" == "91" ]] || fail 'staging failure changed desktop settings'
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Before crash" ]] || fail 'staging failure changed Home state'
assert_transaction_clean
# ── Restoring snapshots what it replaced, so it is undoable ──────────────────
count="$(run list | jq 'length')"
[[ "$count" -ge 2 ]] || fail "restore did not snapshot the replaced settings (only $count snapshots)"
# ── A corrupt snapshot is refused ────────────────────────────────────────────
bad="settings-19990101-000000000.json"
mkdir -p "$backups"
printf '{ truncated' >"$backups/$bad"
run restore "$bad" >/dev/null 2>&1 && fail 'a corrupt snapshot was restored'
[[ "$(jq -r .gapsOut "$settings")" == "91" ]] || fail 'a refused restore still damaged the settings file'
invalid_home="settings-19990101-000000001.json"
jq -n '{
version: 2,
desktop: {present: true, data: {gapsOut: 88}},
home: {present: true, data: {
initialized: true,
favorites: [
{id: "light.desk", alias: "Desk"},
{id: "light.desk", alias: "Duplicate"}
]
}}
}' >"$backups/$invalid_home"
run restore "$invalid_home" >/dev/null 2>&1 && fail 'a snapshot with duplicate Home favourites was restored'
[[ "$(jq -r .gapsOut "$settings")" == "91" ]] || fail 'an invalid Home snapshot still damaged desktop settings'
printf '{ truncated' >"$home"
run save >/dev/null 2>&1 && fail 'a corrupt Home state file was backed up'
printf '{"initialized":true,"favorites":[]}' >"$home"
# ── The live service can sync its private Home state before save ─────────────
rm -f "$home"
printf '{"gapsOut":21}' >"$settings"
live_home='{"initialized":true,"favorites":[{"id":"light.studio","alias":"Studio"}]}'
run save "$live_home" >/dev/null || fail 'save rejected valid live Home state'
live_name="$(run list | jq -r '.[0].name')"
jq -e '.home.present == true and .home.data.favorites[0].alias == "Studio"' \
"$backups/$live_name" >/dev/null \
|| fail 'live Home state was not written to the canonical snapshot'
# ── A snapshot cannot name a path outside the backup directory ───────────────
printf '{"pwned":true}' >"$work/outside.json"
run restore "../../outside.json" >/dev/null 2>&1 && fail 'a traversing snapshot name was accepted'
run restore "/etc/passwd" >/dev/null 2>&1 && fail 'an absolute snapshot path was accepted'
link_name="settings-20000101-000000001.json"
ln -s "$work/outside.json" "$backups/$link_name"
run restore "$link_name" >/dev/null 2>&1 && fail 'a snapshot symlink escaping the backup directory was accepted'
jq -e 'has("pwned") | not' "$settings" >/dev/null || fail 'a file outside the backup directory was restored'
# ── A snapshot that is not listed is refused ─────────────────────────────────
run restore "settings-20000101-000000000.json" >/dev/null 2>&1 && fail 'a missing snapshot was reported restored'
# ── Snapshots are capped ─────────────────────────────────────────────────────
for _ in $(seq 1 20); do
printf '{"n":%s}' "$RANDOM" >"$settings"
run save >/dev/null
done
kept="$(run list | jq 'length')"
[[ "$kept" -le 15 ]] || fail "snapshots are not capped: $kept kept"
[[ "$kept" -ge 10 ]] || fail "snapshot pruning was too aggressive: only $kept kept"
trap - EXIT
cleanup
printf 'settings backup contract: PASS\n'
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Behavioral coverage for the QML handoff after the helper commits a restore.
# The harness has a unique shell identity, isolated XDG roots, and fake external
# consumers. It records the real SettingsBackup call order without touching the
# daily-driver shell, compositor, keymap, or wallpaper.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
service="$repo_dir/config/dot/quickshell/services/SettingsBackup.qml"
harness="$repo_dir/config/dot/quickshell/settings-backup-harness.qml"
work="$(mktemp -d /tmp/panama-settings-backup-live.XXXXXX)"
fail() {
printf 'settings backup live contract: %s\n' "$1" >&2
exit 1
}
qs_test() {
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" qs -p "$harness" "$@"
}
cleanup() {
qs_test kill >/dev/null 2>&1 || true
rm -rf "$work"
}
trap cleanup EXIT
# The production command boundary must remain argv-only.
rg -Fq 'actionRun.exec([root.helperPath, "save", root.serialiseHomeState()]);' "$service" \
|| fail 'save does not pass live Home state as one argument'
rg -Fq 'actionRun.exec([root.helperPath, "restore", name]);' "$service" \
|| fail 'restore is not executed through an argument array'
if rg -q 'bash.*-c|sh.*-c' "$service"; then
fail 'the restore service constructs a shell command'
fi
# The harness replaces these seams, while these mappings prove the production
# defaults still delegate to Panama's existing public service APIs.
for mapping in \
'HomePreferences.resetHomeDefaults();' \
'HomePreferences.initialize(ids);' \
'HomePreferences.setAlias(id, alias);' \
'DesktopPreferences.reload();' \
'SystemSettings.applyPersistedDisplayPolicy();' \
'Keybinds.applyReload();' \
'Wallpaper.set(path);' \
'Quickshell.reload(false);'; do
rg -Fq "$mapping" "$service" || fail "production restore seam is missing: $mapping"
done
qs_test --daemonize >"$work/quickshell.log" 2>&1
ready=false
for _ in $(seq 1 60); do
if qs_test ipc show 2>/dev/null | rg -q '^target settings-backup-behavior$'; then
ready=true
break
fi
sleep 0.1
done
if [[ "$ready" != true ]]; then
sed -n '1,200p' "$work/quickshell.log" >&2
fail 'isolated SettingsBackup harness did not start'
fi
qs_test ipc call settings-backup-behavior reset >/dev/null
payload='{"restored":"settings-20260818-010203004.json","home":{"present":true,"data":{"initialized":true,"favorites":[{"id":"light.desk","alias":"Desk"},{"id":"light.office","alias":"Office"}]}}}'
[[ "$(qs_test ipc call settings-backup-behavior apply "$payload")" == "true" ]] \
|| fail 'valid restore output was rejected'
status=""
for _ in $(seq 1 50); do
status="$(qs_test ipc call settings-backup-behavior status)"
jq -e '.calls[-1] == "shell.reload"' <<<"$status" >/dev/null 2>&1 && break
sleep 0.1
done
jq -e '
.calls == [
"home.reset",
"home.initialize:light.desk,light.office",
"home.alias:light.desk=Desk",
"home.alias:light.office=Office",
"desktop.reload",
"system.apply",
"keybinds.reload",
"wallpaper.set:/tmp/restored-wallpaper.jpg",
"shell.reload"
]
and .initialized == true
and .favorites == [
{"id":"light.desk","alias":"Desk"},
{"id":"light.office","alias":"Office"}
]
' <<<"$status" >/dev/null || fail "restore handoff order/state was wrong: $status"
# Invalid output is rejected before Home state or external consumers change.
qs_test ipc call settings-backup-behavior reset >/dev/null
invalid='{"home":{"present":true,"data":{"initialized":true,"favorites":[{"id":"light.desk","alias":"One"},{"id":"light.desk","alias":"Two"}]}}}'
[[ "$(qs_test ipc call settings-backup-behavior apply "$invalid")" == "false" ]] \
|| fail 'duplicate Home state was accepted'
status="$(qs_test ipc call settings-backup-behavior status)"
jq -e '.calls == [] and .initialized == false and .favorites == []' <<<"$status" >/dev/null \
|| fail 'invalid restore output caused partial live mutations'
# An absent Home generation uses the same ordered external handoff but leaves
# the live Home service reset rather than manufacturing an initialized store.
qs_test ipc call settings-backup-behavior reset >/dev/null
absent='{"restored":"settings-20260818-010203005.json","home":{"present":false}}'
[[ "$(qs_test ipc call settings-backup-behavior apply "$absent")" == "true" ]] \
|| fail 'absent Home restore output was rejected'
for _ in $(seq 1 50); do
status="$(qs_test ipc call settings-backup-behavior status)"
jq -e '.calls[-1] == "shell.reload"' <<<"$status" >/dev/null 2>&1 && break
sleep 0.1
done
jq -e '
.calls == [
"home.reset",
"desktop.reload",
"system.apply",
"keybinds.reload",
"wallpaper.set:/tmp/restored-wallpaper.jpg",
"shell.reload"
]
and .initialized == false
and .favorites == []
' <<<"$status" >/dev/null || fail "absent Home handoff was wrong: $status"
trap - EXIT
cleanup
printf 'settings backup live contract: PASS\n'
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Two behaviours the settings rows depend on:
#
# commitPreference(key, value)
# One entry point for every row. A compositor-backed key must reach Hyprland
# and be confirmed before it is stored; a local key is written directly.
# Rows bind a schema key and call this, so they never need to know which
# kind they hold -- and a row must not be able to store a value the
# compositor rejected.
#
# restoreDefaults()
# Panama keeps user state in more than one file. Resetting only the schema
# store would leave a customised Home accessory arrangement in place while
# claiming to have restored Panama's defaults. That is worse than having no
# reset at all, because it is silent.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/settings-system-harness.qml"
system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml"
# Preferences are committed to $XDG_CONFIG_HOME, and the Home store lives under
# $XDG_STATE_HOME. Both are isolated so this contract cannot touch the real
# desktop's settings; the compositor is the live one and is restored below.
config_home="$(mktemp -d /tmp/panama-commit-config.XXXXXX)"
state_home="$(mktemp -d /tmp/panama-commit-state.XXXXXX)"
fail() {
printf 'settings commit/reset contract: %s\n' "$1" >&2
exit 1
}
rg -Fq 'HomePreferences.resetHomeDefaults();' "$system_settings" \
|| fail 'restoreDefaults does not use the durable Home reset boundary'
if rg -q 'HomePreferences\.(favorites|initialized)\s*=' "$system_settings"; then
fail 'restoreDefaults mutates Home aliases instead of using resetHomeDefaults'
fi
qs_for_harness() {
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
}
original_rounding="$(hyprctl -j getoption decoration:rounding | jq -r .int)"
original_gaps="$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')"
restore() {
hyprctl eval "hl.config({ decoration = { rounding = $original_rounding }, general = { gaps_out = $original_gaps } })" >/dev/null 2>&1 || true
qs_for_harness kill >/dev/null 2>&1 || true
rm -rf "$config_home" "$state_home"
}
trap restore EXIT
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$' && break
sleep 0.1
done
qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$' || fail 'test IPC target did not start'
# ── A local key is stored directly ───────────────────────────────────────────
[[ "$(qs_for_harness ipc call settings-system-test commit showSeconds false)" == "true" ]] \
|| fail 'commitPreference refused a local key'
[[ "$(qs_for_harness ipc call settings-system-test stored showSeconds)" == "false" ]] \
|| fail 'a local key was not stored'
# ── A compositor key reaches Hyprland, then is stored ────────────────────────
target_rounding=$(( original_rounding == 11 ? 13 : 11 ))
[[ "$(qs_for_harness ipc call settings-system-test commit windowRounding "$target_rounding")" == "true" ]] \
|| fail 'commitPreference refused a compositor key'
for _ in $(seq 1 40); do
[[ "$(hyprctl -j getoption decoration:rounding | jq -r .int)" == "$target_rounding" ]] && break
sleep 0.1
done
[[ "$(hyprctl -j getoption decoration:rounding | jq -r .int)" == "$target_rounding" ]] \
|| fail "a compositor-backed commit did not reach Hyprland (rounding=$(hyprctl -j getoption decoration:rounding | jq -r .int))"
[[ "$(qs_for_harness ipc call settings-system-test stored windowRounding)" == "$target_rounding" ]] \
|| fail 'a verified compositor commit was not stored'
# ── A value the schema rejects is never stored ───────────────────────────────
before="$(qs_for_harness ipc call settings-system-test stored windowRounding)"
[[ "$(qs_for_harness ipc call settings-system-test commit windowRounding 9999)" == "true" ]] \
|| fail 'an out-of-range value should be clamped by the schema, not refused outright'
[[ "$(qs_for_harness ipc call settings-system-test stored windowRounding)" != "9999" ]] \
|| fail 'an out-of-range value was stored unclamped'
[[ "$(qs_for_harness ipc call settings-system-test commit __not_a_setting__ 1)" == "false" ]] \
|| fail 'commitPreference accepted a key outside the schema'
# ── Reset spans every store, not just the schema one ─────────────────────────
qs_for_harness ipc call settings-system-test seedHome >/dev/null
qs_for_harness ipc call settings-system-test commit dockHideDelayMs 900 >/dev/null
sleep 0.4
home_before="$(qs_for_harness ipc call settings-system-test homeState)"
jq -e '.count == 1 and .initialized == true' <<<"$home_before" >/dev/null \
|| fail "the Home fixture did not apply: $home_before"
[[ "$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)" == "900" ]] \
|| fail 'the dock fixture did not apply'
qs_for_harness ipc call settings-system-test restoreDefaults >/dev/null
sleep 0.6
[[ "$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)" == "250" ]] \
|| fail 'reset did not restore a schema default'
home_after="$(qs_for_harness ipc call settings-system-test homeState)"
jq -e '.count == 0 and .initialized == false' <<<"$home_after" >/dev/null \
|| fail "reset left the Home accessory store customised: $home_after"
# Resetting a stored value does not by itself tell Hyprland anything, so the
# reset must re-apply compositor-backed defaults too.
for _ in $(seq 1 40); do
[[ "$(hyprctl -j getoption decoration:rounding | jq -r .int)" == "18" ]] && break
sleep 0.1
done
[[ "$(hyprctl -j getoption decoration:rounding | jq -r .int)" == "18" ]] \
|| fail "reset did not re-apply the compositor default (rounding=$(hyprctl -j getoption decoration:rounding | jq -r .int))"
trap - EXIT
restore
printf 'settings commit/reset contract: PASS\n'
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Public Settings.qml values are the compatibility surface consumed throughout
# the shell. Once a value becomes user-configurable, this file must read it from
# DesktopPreferences rather than keeping a second hardcoded source of truth.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
settings="$repo_dir/config/dot/quickshell/config/Settings.qml"
fail() {
printf 'settings hardcoded values contract: %s\n' "$1" >&2
exit 1
}
properties=(
temperatureUnit
weatherRefreshMinutes
vitalsIntervalMs
notificationTimeoutMs
notificationTimeoutCriticalMs
notificationHistoryLimit
maxVisibleToasts
screenshotDir
recordingDir
recorderArgs
)
for property in "${properties[@]}"; do
count="$(rg -c \
"^[[:space:]]*readonly property [A-Za-z]+ ${property}: DesktopPreferences\\.get\\(\"${property}\"\\)[[:space:]]*(//.*)?$" \
"$settings" || true)"
[[ "$count" == "1" ]] \
|| fail "$property must use DesktopPreferences.get(\"$property\") exactly once"
done
# dockPinned was already migrated on the shared branch. Pinning it here keeps a
# later bulk edit from accidentally restoring the old hardcoded app list.
rg -q '^[[:space:]]*readonly property var dockPinned: DesktopPreferences\.get\("dockPinned"\)[[:space:]]*$' "$settings" \
|| fail 'dockPinned no longer reads DesktopPreferences exactly'
printf 'settings hardcoded values contract: PASS\n'
+79 -5
View File
@@ -3,6 +3,85 @@
set -euo pipefail set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
fail() {
printf 'settings pages contract: %s\n' "$1" >&2
exit 1
}
pages=(Home Displays Connectivity Sound Notifications ScreenIntelligence Services About)
for page in "${pages[@]}"; do
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
root_type="$(awk '
/^import / { next }
/^[[:space:]]*\/\// { next }
/^[[:space:]]*$/ { next }
match($0, /^[[:space:]]*([A-Za-z][A-Za-z0-9]*)[[:space:]]*\{/, found) {
print found[1]
exit
}
' "$page_file")"
[[ "$root_type" == "SettingsPage" ]] \
|| fail "${page}Page.qml root is ${root_type:-unknown}, expected SettingsPage"
! rg -q '^[[:space:]]*Flickable[[:space:]]*\{' "$page_file" \
|| fail "${page}Page.qml still copies the page Flickable scaffold"
done
require_row() {
local file="$1"
local row_type="$2"
local setting="$3"
python3 - "$file" "$row_type" "$setting" <<'PY' || \
fail "$(basename "$file") is missing $row_type for $setting"
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
row_type = re.escape(sys.argv[2])
setting = re.escape(sys.argv[3])
pattern = rf"{row_type}\s*\{{(?:(?!\n\s*[A-Z][A-Za-z0-9]*\s*\{{).)*?setting\s*:\s*\"{setting}\""
raise SystemExit(0 if re.search(pattern, text, re.S) else 1)
PY
}
home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePage.qml"
require_row "$home_page" ChoiceRow temperatureUnit
require_row "$home_page" SliderRow weatherRefreshMinutes
require_row "$home_page" SliderRow vitalsIntervalMs
notifications_page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml"
for setting in notificationTimeoutMs notificationTimeoutCriticalMs notificationHistoryLimit maxVisibleToasts; do
require_row "$notifications_page" SliderRow "$setting"
done
python3 - "$notifications_page" <<'PY' || fail 'critical notification timeout does not render zero as Never'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
block = re.search(
r'SliderRow\s*\{(?:(?!\n\s*[A-Z][A-Za-z0-9]*\s*\{).)*?'
r'setting\s*:\s*"notificationTimeoutCriticalMs"(?P<tail>.*?)\n\s*\}',
text,
re.S,
)
raise SystemExit(0 if block and re.search(r'zeroLabel\s*:\s*"Never"', block.group(0)) else 1)
PY
intelligence_page="$repo_dir/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml"
require_row "$intelligence_page" ChoiceRow screenshotDir
require_row "$intelligence_page" ChoiceRow recordingDir
require_row "$intelligence_page" ChoiceRow recorderArgs
# The source-only contract is safe during a shared Quickshell quiet window.
# The existing compositor integration checks remain available explicitly.
if [[ "${PANAMA_SETTINGS_STATIC_ONLY:-0}" == "1" ]]; then
printf 'settings pages contract: PASS (static)\n'
exit 0
fi
state_home="$(mktemp -d /tmp/panama-settings-pages-state.XXXXXX)" state_home="$(mktemp -d /tmp/panama-settings-pages-state.XXXXXX)"
source_config_path="$repo_dir/config/dot/quickshell" source_config_path="$repo_dir/config/dot/quickshell"
config_path="$state_home/quickshell" config_path="$state_home/quickshell"
@@ -58,11 +137,6 @@ exit 97
EOF EOF
chmod +x "$test_bin/flatpak" chmod +x "$test_bin/flatpak"
fail() {
printf 'settings pages contract: %s\n' "$1" >&2
exit 1
}
qs_for_test() { qs_for_test() {
PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \ PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
qs -p "$config_path" "$@" qs -p "$config_path" "$@"
+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'
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
sidebar="$repo_dir/config/dot/quickshell/modules/settings/SettingsSidebar.qml"
fail() {
printf 'settings sidebar layout contract: %s\n' "$1" >&2
exit 1
}
python3 - "$sidebar" <<'PY' || fail 'sidebar does not keep its header and footer pinned around one vertical scroll surface'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
def object_block(type_name: str, object_id: str) -> tuple[int, int, str]:
pattern = re.compile(
rf"\b{re.escape(type_name)}\s*\{{(?:(?!\n\s*[A-Za-z][A-Za-z0-9.]*\s*\{{).)*?"
rf"\bid\s*:\s*{re.escape(object_id)}\b",
re.S,
)
match = pattern.search(text)
if not match:
raise AssertionError(f"missing {type_name} id {object_id}")
start = match.start()
opening = text.index("{", start)
depth = 0
in_string = False
escaped = False
index = opening
while index < len(text):
character = text[index]
if in_string:
if escaped:
escaped = False
elif character == "\\":
escaped = True
elif character == '"':
in_string = False
elif character == '"':
in_string = True
elif character == "{":
depth += 1
elif character == "}":
depth -= 1
if depth == 0:
return start, index + 1, text[start:index + 1]
index += 1
raise AssertionError(f"unterminated {type_name} id {object_id}")
try:
header_start, header_end, header = object_block("Column", "sidebarHeader")
scroll_start, scroll_end, scroll = object_block("Flickable", "sidebarScroll")
footer_start, _, footer = object_block("Rectangle", "healthFooter")
assert header_start < scroll_start < scroll_end < footer_start
assert re.search(r"anchors\.top\s*:\s*parent\.top", header)
assert re.search(r"\bid\s*:\s*searchInput\b", header)
assert re.search(r"anchors\.top\s*:\s*sidebarHeader\.bottom", scroll)
assert re.search(r"anchors\.bottom\s*:\s*healthFooter\.top", scroll)
assert re.search(r"contentWidth\s*:\s*width", scroll)
assert re.search(r"contentHeight\s*:\s*scrollContent\.implicitHeight", scroll)
assert re.search(r"flickableDirection\s*:\s*Flickable\.VerticalFlick", scroll)
assert re.search(r"boundsBehavior\s*:\s*Flickable\.StopAtBounds", scroll)
assert re.search(r"clip\s*:\s*true", scroll)
assert scroll.count("Flickable {") == 1
assert re.search(r"\bid\s*:\s*scrollContent\b", scroll)
assert re.search(r"\bid\s*:\s*searchResults\b", scroll)
assert re.search(r"\bid\s*:\s*navigationList\b", scroll)
assert re.search(r"visible\s*:\s*root\.query\s*!==\s*\"\"", scroll)
assert re.search(r"visible\s*:\s*root\.query\s*===\s*\"\"", scroll)
assert re.search(r"anchors\.bottom\s*:\s*parent\.bottom", footer)
except AssertionError as error:
print(error, file=sys.stderr)
raise SystemExit(1)
PY
printf 'settings sidebar layout contract: PASS\n'