Compare commits
36
Commits
8a04e4f9d1
...
3a2295ff53
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a2295ff53 | ||
|
|
de1b8a7673 | ||
|
|
39a26adcc2 | ||
|
|
eff0bc44ff | ||
|
|
c038c57278 | ||
|
|
420b7aa1a6 | ||
|
|
3cfa592db9 | ||
|
|
81096e2d95 | ||
|
|
86742da63d | ||
|
|
3c521cf5fa | ||
|
|
b9800bfbab | ||
|
|
7b8270fdd6 | ||
|
|
6d3f888784 | ||
|
|
c2cd79b547 | ||
|
|
0a18290137 | ||
|
|
efc53435a2 | ||
|
|
67674d297e | ||
|
|
2d4b126f14 | ||
|
|
5671324eb2 | ||
|
|
83391e0453 | ||
|
|
172b099a04 | ||
|
|
cae72b5179 | ||
|
|
b3b8e0d66d | ||
|
|
787d2b121a | ||
|
|
1b323c2fa5 | ||
|
|
1ca571458c | ||
|
|
768801dbe4 | ||
|
|
375ecfcd95 | ||
|
|
ce95b34d19 | ||
|
|
9d430a3079 | ||
|
|
bac68d2bfb | ||
|
|
634a9ebe07 | ||
|
|
150f3cdb09 | ||
|
|
6377bfb8fd | ||
|
|
2bc12e6022 | ||
|
|
fe7c85e471 |
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -17,11 +17,88 @@
|
|||||||
-- overrides.lua) and read the notes in that file first.
|
-- overrides.lua) and read the notes in that file first.
|
||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local prefs = require("prefs")
|
||||||
|
|
||||||
|
-- Per-output overrides written by Panama Settings, keyed by output name:
|
||||||
|
-- { ["DP-2"] = { mode = "3840x2160@60", scale = 2, transform = 0 } }
|
||||||
|
--
|
||||||
|
-- Only mode, scale, and transform are read. Colour management and bit depth
|
||||||
|
-- stay here, because those are the settings with a documented reason attached
|
||||||
|
-- (see the header) rather than preferences, and a settings page has no way to
|
||||||
|
-- explain the screencopy tradeoff at the moment you would be changing it.
|
||||||
|
local displays = prefs.get("displays", {})
|
||||||
|
if type(displays) ~= "table" then
|
||||||
|
displays = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mode_dimensions(mode)
|
||||||
|
if type(mode) ~= "string" then
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
local width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+%.%d+)$")
|
||||||
|
if width == nil then
|
||||||
|
width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+)$")
|
||||||
|
end
|
||||||
|
width, height, refresh = tonumber(width), tonumber(height), tonumber(refresh)
|
||||||
|
if width == nil or height == nil or refresh == nil
|
||||||
|
or width <= 0 or height <= 0 or refresh <= 0 then
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
return width, height
|
||||||
|
end
|
||||||
|
|
||||||
|
local function valid_mode(mode)
|
||||||
|
local width = mode_dimensions(mode)
|
||||||
|
return width ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function valid_scale(mode, scale)
|
||||||
|
local width, height = mode_dimensions(mode)
|
||||||
|
if width == nil or type(scale) ~= "number" or scale ~= scale
|
||||||
|
or scale <= 0 or scale > 4 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local logical_width = width / scale
|
||||||
|
local logical_height = height / scale
|
||||||
|
return math.abs(logical_width - math.floor(logical_width + 0.5)) < 0.0001
|
||||||
|
and math.abs(logical_height - math.floor(logical_height + 0.5)) < 0.0001
|
||||||
|
end
|
||||||
|
|
||||||
|
local function valid_transform(transform)
|
||||||
|
return type(transform) == "number"
|
||||||
|
and transform == math.floor(transform)
|
||||||
|
and transform >= 0
|
||||||
|
and transform <= 3
|
||||||
|
end
|
||||||
|
|
||||||
|
local function display_entry(output)
|
||||||
|
if type(output) ~= "string" or output == ""
|
||||||
|
or output:match("^[%w_.-]+$") == nil then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local entry = displays[output]
|
||||||
|
if type(entry) ~= "table" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if not valid_mode(entry.mode)
|
||||||
|
or not valid_scale(entry.mode, entry.scale)
|
||||||
|
or not valid_transform(entry.transform) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return entry
|
||||||
|
end
|
||||||
|
|
||||||
|
local shipped_mode = "4500x3000@60"
|
||||||
|
local shipped_scale = 1.5
|
||||||
|
local shipped_transform = 0
|
||||||
|
local dp2 = display_entry("DP-2")
|
||||||
|
|
||||||
hl.monitor({
|
hl.monitor({
|
||||||
output = "DP-2",
|
output = "DP-2",
|
||||||
mode = "4500x3000@60",
|
mode = dp2 and dp2.mode or shipped_mode,
|
||||||
position = "0x0",
|
position = "0x0",
|
||||||
scale = 1.5,
|
scale = dp2 and dp2.scale or shipped_scale,
|
||||||
|
transform = dp2 and dp2.transform or shipped_transform,
|
||||||
|
|
||||||
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of
|
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of
|
||||||
-- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or
|
-- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or
|
||||||
@@ -32,6 +109,24 @@ hl.monitor({
|
|||||||
cm = "auto",
|
cm = "auto",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
-- Other connected outputs use the same validated per-output store. They keep
|
||||||
|
-- automatic placement and the compositor's normal colour policy; DP-2 alone
|
||||||
|
-- carries the panel-specific 10-bit policy documented above.
|
||||||
|
for output, _ in pairs(displays) do
|
||||||
|
if output ~= "DP-2" then
|
||||||
|
local entry = display_entry(output)
|
||||||
|
if entry ~= nil then
|
||||||
|
hl.monitor({
|
||||||
|
output = output,
|
||||||
|
mode = entry.mode,
|
||||||
|
position = "auto",
|
||||||
|
scale = entry.scale,
|
||||||
|
transform = entry.transform,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Any monitor not named above: sane defaults rather than nothing.
|
-- Any monitor not named above: sane defaults rather than nothing.
|
||||||
hl.monitor({
|
hl.monitor({
|
||||||
output = "",
|
output = "",
|
||||||
|
|||||||
@@ -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,219 @@ 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"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Display configuration ───────────────────────────────────────────
|
||||||
|
// { "<output>": { mode, scale, transform } }, applied by
|
||||||
|
// hypr/monitors.lua on top of the shipped values. Colour management and
|
||||||
|
// bit depth are deliberately not here: those carry a documented
|
||||||
|
// screencopy tradeoff that a settings page cannot explain at the moment
|
||||||
|
// you would be changing it.
|
||||||
|
{
|
||||||
|
key: "displays", type: "json", def: ({}), group: "display",
|
||||||
|
internal: true,
|
||||||
|
label: "Display configuration",
|
||||||
|
detail: "Resolution, scale, and rotation per connected display"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Per-application notification rules ──────────────────────────────
|
||||||
|
// { "<appId>": { enabled, showOnLockScreen, showContentOnLockScreen } }
|
||||||
|
//
|
||||||
|
// Absent means "no rule", which is not the same as a rule that allows
|
||||||
|
// everything: a new application must be able to notify without needing
|
||||||
|
// an entry written for it first. services/Notifs.qml treats a missing
|
||||||
|
// entry as permissive and Do Not Disturb remains an override on top,
|
||||||
|
// rather than being duplicated per application.
|
||||||
|
{
|
||||||
|
key: "notificationAppRules", type: "json", def: ({}), group: "notifications",
|
||||||
|
internal: true,
|
||||||
|
label: "Application notification rules",
|
||||||
|
detail: "Per-application notification and lock-screen visibility preferences"
|
||||||
|
},
|
||||||
|
|
||||||
// ── Internal ────────────────────────────────────────────────────────
|
// ── Internal ────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
key: "lastPage", type: "string", def: "home", group: "internal",
|
key: "lastPage", type: "string", def: "home", group: "internal",
|
||||||
@@ -382,6 +613,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
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
IpcHandler {
|
||||||
|
target: "connectivity-test"
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
wifiDevice: Connectivity.wifiDevice ? Connectivity.wifiDevice.name : "",
|
||||||
|
wiredDevice: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : "",
|
||||||
|
wiredConnected: !!(Connectivity.wiredDevice && Connectivity.wiredDevice.connected),
|
||||||
|
networks: Connectivity.networks.length,
|
||||||
|
activeSsid: Connectivity.activeNetwork ? Connectivity.activeNetwork.name : "",
|
||||||
|
activeStrength: Connectivity.activeNetwork ? Connectivity.activeNetwork.signalStrength : -1,
|
||||||
|
activeLabel: Connectivity.activeNetwork ? Connectivity.signalLabel(Connectivity.activeNetwork.signalStrength) : "",
|
||||||
|
adapter: Connectivity.adapter ? true : false,
|
||||||
|
btDevices: Connectivity.bluetoothDevices.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFor(strength: real): string { return Connectivity.signalLabel(strength); }
|
||||||
|
function setActive(on: bool): void { Connectivity.active = on; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
IpcHandler {
|
||||||
|
target: "displays-test"
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
const monitor = Displays.monitors.length > 0 ? Displays.monitors[0] : null;
|
||||||
|
return JSON.stringify({
|
||||||
|
count: Displays.monitors.length,
|
||||||
|
name: monitor ? monitor.name : "",
|
||||||
|
width: monitor ? monitor.width : 0,
|
||||||
|
height: monitor ? monitor.height : 0,
|
||||||
|
refresh: monitor ? monitor.refreshRate : 0,
|
||||||
|
mode: monitor ? monitor.mode : "",
|
||||||
|
scale: monitor ? monitor.scale : 0,
|
||||||
|
transform: monitor ? monitor.transform : -1,
|
||||||
|
modes: monitor ? monitor.modes.length : 0,
|
||||||
|
awaiting: Displays.awaitingConfirmation,
|
||||||
|
canConfirm: Displays.canConfirm,
|
||||||
|
secondsLeft: Displays.secondsLeft,
|
||||||
|
lastError: Displays.lastError,
|
||||||
|
overridden: monitor ? Displays.isOverridden(monitor.name) : false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyScale(scale: real): bool {
|
||||||
|
const monitor = Displays.monitors[0];
|
||||||
|
if (!monitor) return false;
|
||||||
|
const mode = monitor.mode;
|
||||||
|
return Displays.apply(monitor.name, mode, scale, monitor.transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshIdentityFixture(): string {
|
||||||
|
const modes = Displays.normaliseModes([
|
||||||
|
"[email protected]",
|
||||||
|
"[email protected]"
|
||||||
|
]);
|
||||||
|
const monitor = { width: 1920, height: 1080, refreshRate: 59.94 };
|
||||||
|
return JSON.stringify({
|
||||||
|
count: modes.length,
|
||||||
|
modes: modes.map(mode => mode.mode),
|
||||||
|
selected: modes.filter(mode => Displays.modeIsCurrent(monitor, mode)).map(mode => mode.mode)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBad(kind: string): bool {
|
||||||
|
const monitor = Displays.monitors[0];
|
||||||
|
if (!monitor) return false;
|
||||||
|
const mode = monitor.mode;
|
||||||
|
if (kind === "mode") return Displays.apply(monitor.name, "9999x9999@240", monitor.scale, monitor.transform);
|
||||||
|
if (kind === "scale") return Displays.apply(monitor.name, mode, 1.37, monitor.transform);
|
||||||
|
if (kind === "dirtyScale") {
|
||||||
|
const dirty = Displays.scales.find(scale => !Displays.isScaleClean(mode, scale));
|
||||||
|
return dirty === undefined ? false : Displays.apply(monitor.name, mode, dirty, monitor.transform);
|
||||||
|
}
|
||||||
|
if (kind === "transform") return Displays.apply(monitor.name, mode, monitor.scale, 9);
|
||||||
|
if (kind === "output") return Displays.apply("NOPE-1", mode, monitor.scale, monitor.transform);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmChange(): bool { return Displays.confirm(); }
|
||||||
|
function revertChange(): void { Displays.revert(); }
|
||||||
|
function forget(): void {
|
||||||
|
const monitor = Displays.monitors[0];
|
||||||
|
if (monitor) Displays.forget(monitor.name);
|
||||||
|
}
|
||||||
|
function refresh(): void { Displays.refresh(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import QtQuick
|
|||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Services.Pipewire
|
import Quickshell.Services.Pipewire
|
||||||
import qs.config
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
@@ -16,23 +17,12 @@ Item {
|
|||||||
|
|
||||||
implicitHeight: list.implicitHeight
|
implicitHeight: list.implicitHeight
|
||||||
|
|
||||||
readonly property var nodes: {
|
readonly property var nodes: root.output ? AudioDevices.outputs : AudioDevices.inputs
|
||||||
return Pipewire.nodes.values.filter(n => {
|
|
||||||
if (n.isStream)
|
|
||||||
return false;
|
|
||||||
// Sources have to be filtered on the type flags: !isSink also
|
|
||||||
// matches video nodes (webcams show up here otherwise).
|
|
||||||
return root.output ? n.isSink : (n.type & PwNodeType.AudioSource) === PwNodeType.AudioSource;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly property var current: root.output ? Pipewire.defaultAudioSink : Pipewire.defaultAudioSource
|
readonly property var current: AudioDevices.current(root.output)
|
||||||
|
|
||||||
function select(node): void {
|
function select(node): void {
|
||||||
if (root.output)
|
AudioDevices.select(root.output, node)
|
||||||
Pipewire.preferredDefaultAudioSink = node;
|
|
||||||
else
|
|
||||||
Pipewire.preferredDefaultAudioSource = node;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ScrollColumn {
|
ScrollColumn {
|
||||||
@@ -52,7 +42,7 @@ Item {
|
|||||||
implicitHeight: 38
|
implicitHeight: 38
|
||||||
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
|
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
|
||||||
iconFallback: "audio-card-symbolic"
|
iconFallback: "audio-card-symbolic"
|
||||||
label: nodeRow.modelData.description || nodeRow.modelData.nickname || nodeRow.modelData.name
|
label: AudioDevices.label(nodeRow.modelData)
|
||||||
selected: nodeRow.modelData === root.current
|
selected: nodeRow.modelData === root.current
|
||||||
onClicked: root.select(nodeRow.modelData)
|
onClicked: root.select(nodeRow.modelData)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
|
||||||
id: content
|
|
||||||
width: parent.width - 68
|
|
||||||
x: 34
|
|
||||||
y: 30
|
|
||||||
spacing: 16
|
|
||||||
|
|
||||||
Text { text: "About Panama"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
|
||||||
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 }
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Panama Desktop"
|
title: "Panama Desktop"
|
||||||
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
|
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
|
||||||
SettingRow { label: "Hyprland"; value: SystemSettings.hyprlandVersion || "Detecting…" }
|
|
||||||
SettingRow { label: "Quickshell"; value: SystemSettings.quickshellVersion }
|
TextRow {
|
||||||
SettingRow { label: "Display"; value: SystemSettings.monitorName || "Detecting…" }
|
label: "Hyprland"
|
||||||
SettingRow { label: "Configuration"; detail: Quickshell.shellDir; value: "Local"; divider: false }
|
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: "Design principles"
|
title: "Design principles"
|
||||||
SettingRow { label: "Curated by default"; detail: "Strong choices instead of an incoherent matrix of switches" }
|
|
||||||
SettingRow { label: "Quiet while idle"; detail: "No continuous decorative repaint loops" }
|
TextRow {
|
||||||
SettingRow { label: "Real system boundaries"; detail: "Every control either works or clearly hands off to its owner"; divider: false }
|
label: "Curated by default"
|
||||||
|
detail: "Strong choices instead of an incoherent matrix of switches"
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
text: SystemSettings.lastError !== ""
|
||||||
|
? SystemSettings.lastError
|
||||||
|
: "Live preview — the focused window wears the prism border"
|
||||||
|
color: SystemSettings.lastError !== "" ? Theme.warn : Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Theme"
|
title: "Background"
|
||||||
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes."
|
subtitle: Wallpaper.lastError !== ""
|
||||||
SettingRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
|
? Wallpaper.lastError
|
||||||
SettingRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
|
: "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 {
|
SettingsCard {
|
||||||
title: "Clock"
|
title: "Clock"
|
||||||
SettingRow {
|
|
||||||
label: "24-hour time"
|
ToggleRow { setting: "use24Hour" }
|
||||||
detail: "Use 18:30 instead of 6:30 PM"
|
ToggleRow { setting: "showSeconds" }
|
||||||
controlWidth: 48
|
ToggleRow { setting: "showWeekday"; divider: false }
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("use24Hour"); onToggled: value => DesktopPreferences.set("use24Hour", value) }
|
|
||||||
}
|
|
||||||
SettingRow {
|
|
||||||
label: "Show seconds"
|
|
||||||
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 {
|
SettingsCard {
|
||||||
title: "System vitals"
|
title: "System vitals"
|
||||||
subtitle: "Choose what appears beside the workspace indicator."
|
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) } }
|
ToggleRow { setting: "showCpu" }
|
||||||
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) } }
|
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,72 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Services.Pipewire
|
||||||
|
import qs.config
|
||||||
|
import qs.widgets
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var node: null
|
||||||
|
|
||||||
|
label: "Balance"
|
||||||
|
detail: "Adjust the left and right channels"
|
||||||
|
controlWidth: 270
|
||||||
|
visible: root.available
|
||||||
|
divider: false
|
||||||
|
|
||||||
|
PwObjectTracker {
|
||||||
|
objects: root.node ? [root.node] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelIndex(channel): int {
|
||||||
|
if (!root.node?.audio)
|
||||||
|
return -1;
|
||||||
|
const channels = root.node.audio.channels;
|
||||||
|
for (let index = 0; index < channels.length; index++) {
|
||||||
|
if (channels[index] === channel)
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property int leftIndex: root.channelIndex(PwAudioChannel.FrontLeft)
|
||||||
|
readonly property int rightIndex: root.channelIndex(PwAudioChannel.FrontRight)
|
||||||
|
readonly property bool available: root.node?.audio
|
||||||
|
&& root.leftIndex >= 0 && root.rightIndex >= 0
|
||||||
|
&& root.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex)
|
||||||
|
|
||||||
|
readonly property real position: {
|
||||||
|
if (!root.available)
|
||||||
|
return 0.5;
|
||||||
|
const left = root.node.audio.volumes[root.leftIndex];
|
||||||
|
const right = root.node.audio.volumes[root.rightIndex];
|
||||||
|
const level = Math.max(left, right);
|
||||||
|
if (level <= 0.001)
|
||||||
|
return 0.5;
|
||||||
|
return right >= left ? 0.5 + (1 - left / level) * 0.5
|
||||||
|
: 0.5 - (1 - right / level) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBalance(value: real): void {
|
||||||
|
if (!root.available)
|
||||||
|
return;
|
||||||
|
const next = Array.from(root.node.audio.volumes);
|
||||||
|
const level = Math.max(next[root.leftIndex], next[root.rightIndex], 0.001);
|
||||||
|
if (value < 0.5) {
|
||||||
|
next[root.leftIndex] = level;
|
||||||
|
next[root.rightIndex] = level * value * 2;
|
||||||
|
} else {
|
||||||
|
next[root.leftIndex] = level * (1 - value) * 2;
|
||||||
|
next[root.rightIndex] = level;
|
||||||
|
}
|
||||||
|
root.node.audio.volumes = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
ValueSlider {
|
||||||
|
anchors.fill: parent
|
||||||
|
value: root.position
|
||||||
|
icon: "audio-speakers-symbolic"
|
||||||
|
onMoved: value => root.setBalance(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Bluetooth, at page size.
|
||||||
|
//
|
||||||
|
// Paired devices first, because reconnecting to something you already own is
|
||||||
|
// what you are here for nine times out of ten; discovered devices follow.
|
||||||
|
// Battery is shown where BlueZ reports it, which is the one thing people
|
||||||
|
// routinely open a terminal for.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Bluetooth
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
function primaryAction(device: var): void {
|
||||||
|
if (device.connected) {
|
||||||
|
device.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (device.paired) {
|
||||||
|
device.connect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
device.pair();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateLabel(device: var): string {
|
||||||
|
if (device.pairing)
|
||||||
|
return "Pairing…";
|
||||||
|
if (device.connected)
|
||||||
|
return device.batteryAvailable
|
||||||
|
? `Connected · ${Math.round(device.battery * 100)}% battery`
|
||||||
|
: "Connected";
|
||||||
|
if (device.paired)
|
||||||
|
return "Paired";
|
||||||
|
return device.address || "Not paired";
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: Connectivity.bluetoothDevices
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: entry
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
label: entry.modelData.name || entry.modelData.address || "Unknown device"
|
||||||
|
detail: root.stateLabel(entry.modelData)
|
||||||
|
divider: entry.index < Connectivity.bluetoothDevices.length - 1
|
||||||
|
controlWidth: 200
|
||||||
|
activatable: !entry.modelData.pairing
|
||||||
|
onActivated: root.primaryAction(entry.modelData)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 7
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
enabled: !entry.modelData.pairing
|
||||||
|
text: entry.modelData.connected
|
||||||
|
? "Disconnect"
|
||||||
|
: (entry.modelData.paired ? "Connect" : "Pair")
|
||||||
|
onClicked: root.primaryAction(entry.modelData)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: entry.modelData.paired
|
||||||
|
text: "Forget"
|
||||||
|
onClicked: entry.modelData.forget()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
width: parent.width
|
||||||
|
visible: Connectivity.bluetoothDevices.length === 0
|
||||||
|
label: !Connectivity.adapter
|
||||||
|
? "No Bluetooth adapter"
|
||||||
|
: (Connectivity.adapter.enabled ? "Looking for devices…" : "Bluetooth is off")
|
||||||
|
detail: Connectivity.adapter && !Connectivity.adapter.enabled
|
||||||
|
? "Turn it on above to discover devices"
|
||||||
|
: "Put the device into pairing mode to make it appear"
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// A row of choices that wraps, for options that do not fit a segmented control.
|
||||||
|
//
|
||||||
|
// ChoiceRow puts two or three options on one line. Scales and rotations are
|
||||||
|
// more numerous and their labels are wider, so they wrap into a grid rather
|
||||||
|
// than shrinking to illegibility on a narrow, tiled window.
|
||||||
|
//
|
||||||
|
// Unlike ChoiceRow this is not schema-bound: it reports a value and lets the
|
||||||
|
// caller decide what to do with it, because a display change has to go through
|
||||||
|
// an apply-then-confirm cycle rather than straight into the store.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string label: ""
|
||||||
|
property string detail: ""
|
||||||
|
property var options: []
|
||||||
|
property var current: null
|
||||||
|
property bool enabled: true
|
||||||
|
property bool divider: true
|
||||||
|
|
||||||
|
signal picked(var value)
|
||||||
|
|
||||||
|
spacing: 9
|
||||||
|
bottomPadding: 12
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.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
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.detail !== ""
|
||||||
|
text: root.detail
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Flow {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 7
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.options
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: option
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool selected: root.current === option.modelData.value
|
||||||
|
|
||||||
|
implicitWidth: Math.max(78, caption.implicitWidth + 26)
|
||||||
|
implicitHeight: 32
|
||||||
|
radius: 9
|
||||||
|
opacity: root.enabled ? 1 : 0.45
|
||||||
|
color: option.selected ? "transparent" : Theme.alpha(Theme.fg, hover.hovered && root.enabled ? 0.11 : 0.06)
|
||||||
|
border.width: option.selected ? 1 : 0
|
||||||
|
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||||
|
|
||||||
|
// The prism marks the selection here as everywhere else.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: parent.radius
|
||||||
|
visible: option.selected
|
||||||
|
border.width: 0
|
||||||
|
gradient: Gradient {
|
||||||
|
orientation: Gradient.Horizontal
|
||||||
|
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) }
|
||||||
|
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: caption
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: option.modelData.label
|
||||||
|
color: option.selected ? Theme.fg : Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: option.selected ? Font.DemiBold : Font.Normal
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: hover
|
||||||
|
enabled: root.enabled
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
enabled: root.enabled && !option.selected
|
||||||
|
onTapped: root.picked(option.modelData.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
visible: root.divider
|
||||||
|
color: Theme.alpha(Theme.fg, 0.065)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,88 +1,124 @@
|
|||||||
|
// Network & Devices.
|
||||||
|
//
|
||||||
|
// Wi-Fi and Bluetooth are handled here rather than delegated. Everything goes
|
||||||
|
// through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and
|
||||||
|
// BlueZ over DBus -- and nothing shells out to nmcli or bluetoothctl. That was
|
||||||
|
// the founding requirement for this desktop: never having to drop to a terminal
|
||||||
|
// to join a network.
|
||||||
|
//
|
||||||
|
// Scanning follows this page being on screen. Wi-Fi scanning and especially
|
||||||
|
// Bluetooth discovery hold the radio, and doing either for a list nobody is
|
||||||
|
// looking at is battery and airtime spent on nothing.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
import Quickshell.Networking
|
import Quickshell.Networking
|
||||||
import Quickshell.Bluetooth
|
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
import qs.modules.quicksettings
|
|
||||||
|
|
||||||
Item {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
readonly property var wifiDevice: {
|
title: "Network & Devices"
|
||||||
for (const device of Networking.devices.values) {
|
lede: Connectivity.activeNetwork
|
||||||
if (device.type === DeviceType.Wifi)
|
? "Connected to " + Connectivity.activeNetwork.name
|
||||||
return device;
|
: "Wi-Fi, Bluetooth, and the things Fedora owns."
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
readonly property var bluetoothAdapter: Bluetooth.defaultAdapter
|
|
||||||
|
|
||||||
Flickable {
|
// Drive the scanners only while this page is the one being shown.
|
||||||
anchors.fill: parent
|
Component.onCompleted: Connectivity.active = true
|
||||||
clip: true
|
Component.onDestruction: Connectivity.active = false
|
||||||
contentWidth: width
|
|
||||||
contentHeight: content.implicitHeight + 64
|
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
|
||||||
|
|
||||||
Column {
|
|
||||||
id: content
|
|
||||||
width: parent.width - 68
|
|
||||||
x: 34
|
|
||||||
y: 30
|
|
||||||
spacing: 16
|
|
||||||
|
|
||||||
Text { text: "Network & Devices"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
|
||||||
Text { text: "Connect graphically—no terminal workflow required."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Wi‑Fi"
|
title: "Wired"
|
||||||
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off"
|
visible: Connectivity.wiredDevice !== null
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Ethernet"
|
||||||
|
detail: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : ""
|
||||||
|
value: Connectivity.wiredDevice && Connectivity.wiredDevice.connected ? "Connected" : "Not connected"
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Wi-Fi"
|
||||||
|
// A Wi-Fi switch reading "On" above the words "No Wi-Fi adapter" is a
|
||||||
|
// contradiction; with no radio the card simply does not belong.
|
||||||
|
visible: Connectivity.wifiDevice !== null
|
||||||
|
subtitle: "Networks are re-scanned while this page is open."
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: "Wi‑Fi"
|
label: "Wi-Fi"
|
||||||
detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found"
|
detail: Connectivity.wifiEnabled ? "On" : "Off"
|
||||||
controlWidth: 48
|
controlWidth: 48
|
||||||
|
divider: Connectivity.wifiEnabled
|
||||||
|
|
||||||
SettingsToggle {
|
SettingsToggle {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
checked: Networking.wifiEnabled
|
checked: Connectivity.wifiEnabled
|
||||||
enabled: Networking.wifiHardwareEnabled
|
enabled: Connectivity.wifiAvailable
|
||||||
onToggled: value => Networking.wifiEnabled = value
|
onToggled: value => Networking.wifiEnabled = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WifiList { width: parent.width; device: root.wifiDevice; active: true; maxHeight: 240 }
|
|
||||||
SettingRow {
|
WifiPanel {
|
||||||
label: "Advanced network settings"
|
width: parent.width
|
||||||
detail: "VPN, wired profiles, DNS, and connection details"
|
visible: Connectivity.wifiEnabled
|
||||||
divider: false
|
|
||||||
controlWidth: 104
|
|
||||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("network") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Bluetooth"
|
title: "Bluetooth"
|
||||||
subtitle: root.bluetoothAdapter?.enabled ? "Nearby and paired devices" : "Bluetooth is off"
|
visible: Connectivity.adapter !== null
|
||||||
|
subtitle: "Discovery runs while this page is open."
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: "Bluetooth"
|
label: "Bluetooth"
|
||||||
detail: root.bluetoothAdapter ? "Pair and reconnect without leaving Settings" : "No Bluetooth adapter found"
|
detail: Connectivity.adapter
|
||||||
|
? (Connectivity.adapter.enabled ? "On" : "Off")
|
||||||
|
: "Unavailable"
|
||||||
controlWidth: 48
|
controlWidth: 48
|
||||||
|
divider: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||||
|
|
||||||
SettingsToggle {
|
SettingsToggle {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
checked: root.bluetoothAdapter?.enabled ?? false
|
checked: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||||
enabled: root.bluetoothAdapter !== null
|
enabled: Connectivity.adapter !== null
|
||||||
onToggled: value => { if (root.bluetoothAdapter) root.bluetoothAdapter.enabled = value; }
|
onToggled: value => {
|
||||||
|
if (Connectivity.adapter)
|
||||||
|
Connectivity.adapter.enabled = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BluetoothList { width: parent.width; active: true; maxHeight: 220 }
|
}
|
||||||
SettingRow {
|
|
||||||
label: "Advanced Bluetooth settings"
|
BluetoothPanel {
|
||||||
detail: "Device details and system-level options"
|
width: parent.width
|
||||||
|
visible: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Owned by Fedora"
|
||||||
|
subtitle: "VPNs, per-connection routing, printers, and online accounts are configured by GNOME's panels, which are installed and searchable."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Network connections"
|
||||||
|
detail: "VPN, proxies, and per-connection settings"
|
||||||
|
action: "Open"
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("network")
|
||||||
|
}
|
||||||
|
ActionRow {
|
||||||
|
label: "Printers"
|
||||||
|
action: "Open"
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("printers")
|
||||||
|
}
|
||||||
|
ActionRow {
|
||||||
|
label: "Online accounts"
|
||||||
|
action: "Open"
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 104
|
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: 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 }
|
|
||||||
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 }
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Dock"
|
title: "Dock"
|
||||||
SettingRow {
|
|
||||||
label: "Automatically hide the Dock"
|
ToggleRow { setting: "dockAutohide" }
|
||||||
detail: "Reveal it at the bottom edge when a workspace is occupied"
|
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
|
||||||
controlWidth: 48
|
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant"; divider: false }
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("dockAutohide"); onToggled: value => DesktopPreferences.set("dockAutohide", value) }
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Pinned applications"
|
||||||
|
subtitle: "What sits in the Dock whether or not it is running. Order here is the order on screen."
|
||||||
|
|
||||||
|
DockPinsEditor {
|
||||||
|
id: pins
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Pin another application"
|
||||||
|
|
||||||
|
DockAppPicker {
|
||||||
|
width: parent.width
|
||||||
|
pinned: pins.pinned
|
||||||
|
onPicked: id => pins.add(id)
|
||||||
}
|
}
|
||||||
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: "Window layout"
|
||||||
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
|
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
|
||||||
SettingRow { label: "Layout"; detail: "Dwindle with preserved split direction"; value: "Tiling" }
|
|
||||||
SettingRow { label: "Window corners"; detail: "Shared with Panama glass surfaces"; value: "18 px" }
|
TextRow {
|
||||||
SettingRow { label: "Workspace movement"; detail: "Alt+H / Alt+L, add Shift to move a window"; value: "Dynamic"; divider: false }
|
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 {
|
SettingsCard {
|
||||||
title: "Reset"
|
title: "Reset"
|
||||||
subtitle: "Restore Panama's curated clock, vitals, dock, focus, and display-policy defaults."
|
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."
|
||||||
SettingRow {
|
|
||||||
label: "Desktop preferences"
|
ActionRow {
|
||||||
detail: "Your pinned applications and files are not changed"
|
label: "Restore Panama defaults"
|
||||||
|
detail: "Applies immediately, including to the compositor"
|
||||||
|
action: "Restore defaults"
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 122
|
onTriggered: SystemSettings.restoreDefaults()
|
||||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Restore defaults"; onClicked: DesktopPreferences.resetDesktopDefaults() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// The resolution list for one display.
|
||||||
|
//
|
||||||
|
// Grouped by resolution with refresh rates beside it, rather than a flat list
|
||||||
|
// of "[email protected]" strings: this panel reports 35 modes, many of which
|
||||||
|
// differ only in refresh-rate rounding, and a flat list of those is a wall of
|
||||||
|
// near-identical text rather than a choice.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var monitor: null
|
||||||
|
property bool enabled: true
|
||||||
|
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
readonly property var grouped: {
|
||||||
|
if (!root.monitor)
|
||||||
|
return [];
|
||||||
|
const buckets = {};
|
||||||
|
const order = [];
|
||||||
|
for (const mode of root.monitor.modes) {
|
||||||
|
const key = mode.label;
|
||||||
|
if (!buckets[key]) {
|
||||||
|
buckets[key] = { label: key, width: mode.width, height: mode.height, rates: [] };
|
||||||
|
order.push(key);
|
||||||
|
}
|
||||||
|
buckets[key].rates.push(mode);
|
||||||
|
}
|
||||||
|
return order.map(key => buckets[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.grouped
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: resolution
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
readonly property bool isCurrent: root.monitor
|
||||||
|
&& root.monitor.width === resolution.modelData.width
|
||||||
|
&& root.monitor.height === resolution.modelData.height
|
||||||
|
|
||||||
|
label: resolution.modelData.label
|
||||||
|
detail: resolution.isCurrent ? "Current resolution" : ""
|
||||||
|
controlWidth: Math.max(120, resolution.modelData.rates.length * 84)
|
||||||
|
divider: resolution.index < root.grouped.length - 1
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: resolution.modelData.rates
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: rate
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool selected: resolution.isCurrent
|
||||||
|
&& Displays.modeIsCurrent(root.monitor, rate.modelData)
|
||||||
|
|
||||||
|
implicitWidth: Math.max(74, rateCaption.implicitWidth + 22)
|
||||||
|
implicitHeight: 30
|
||||||
|
radius: 9
|
||||||
|
opacity: root.enabled ? 1 : 0.45
|
||||||
|
color: rate.selected ? "transparent" : Theme.alpha(Theme.fg, rateHover.hovered && root.enabled ? 0.11 : 0.06)
|
||||||
|
border.width: rate.selected ? 1 : 0
|
||||||
|
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: parent.radius
|
||||||
|
visible: rate.selected
|
||||||
|
border.width: 0
|
||||||
|
gradient: Gradient {
|
||||||
|
orientation: Gradient.Horizontal
|
||||||
|
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) }
|
||||||
|
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: rateCaption
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: rate.modelData.refreshLabel
|
||||||
|
color: rate.selected ? Theme.fg : Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: rate.selected ? Font.DemiBold : Font.Normal
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: rateHover
|
||||||
|
enabled: root.enabled
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
enabled: root.enabled && !rate.selected
|
||||||
|
onTapped: Displays.apply(
|
||||||
|
root.monitor.name,
|
||||||
|
rate.modelData.mode,
|
||||||
|
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
|
||||||
|
root.monitor.transform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,97 +1,219 @@
|
|||||||
|
// Displays.
|
||||||
|
//
|
||||||
|
// Resolution, refresh rate, scale, and rotation, plus the gaming display
|
||||||
|
// policy that was already here.
|
||||||
|
//
|
||||||
|
// Every geometry change goes through an apply-then-confirm countdown. This is
|
||||||
|
// the one page where a wrong value can leave the screen unreadable or blank,
|
||||||
|
// and no other control in the app can undo it once that happens. Confirming is
|
||||||
|
// what writes the choice to the settings store; letting the countdown run
|
||||||
|
// leaves nothing behind.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
|
|
||||||
Item {
|
SettingsPage {
|
||||||
Flickable {
|
id: root
|
||||||
anchors.fill: parent
|
|
||||||
clip: true
|
title: "Displays"
|
||||||
contentWidth: width
|
lede: SystemSettings.monitorDescription || "Reading the active display…"
|
||||||
contentHeight: content.implicitHeight + 64
|
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
property string selectedOutput: ""
|
||||||
|
readonly property var monitor: Displays.monitorNamed(root.selectedOutput)
|
||||||
|
?? (Displays.monitors.length > 0 ? Displays.monitors[0] : null)
|
||||||
|
readonly property string currentMode: root.monitor
|
||||||
|
? root.monitor.mode
|
||||||
|
: ""
|
||||||
|
|
||||||
|
function syncSelectedOutput(): void {
|
||||||
|
if (!Displays.monitorNamed(root.selectedOutput))
|
||||||
|
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.syncSelectedOutput()
|
||||||
|
Connections {
|
||||||
|
target: Displays
|
||||||
|
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The confirmation sits above everything, because while it is counting down
|
||||||
|
// it is the only thing that matters on this page.
|
||||||
|
header: Component {
|
||||||
|
Rectangle {
|
||||||
|
visible: Displays.awaitingConfirmation
|
||||||
|
implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0
|
||||||
|
radius: Theme.cardRadius
|
||||||
|
color: Theme.mix(Theme.bgPanel, Theme.warn, 0.12)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.warn, 0.4)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: confirmRow
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: 16
|
||||||
|
spacing: 14
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
id: content
|
width: parent.width - keepButton.width - revertButton.width - 28
|
||||||
width: parent.width - 68
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
x: 34
|
spacing: 3
|
||||||
y: 30
|
|
||||||
spacing: 16
|
|
||||||
|
|
||||||
Text { text: "Displays"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
|
||||||
Text { text: SystemSettings.monitorDescription || "Reading the active display…"; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
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"
|
|
||||||
detail: "Enter HDR only for fullscreen content that requests it"
|
|
||||||
controlWidth: 48
|
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.autoHdr; onToggled: value => SystemSettings.setAutoHdr(value) }
|
|
||||||
}
|
|
||||||
SettingRow {
|
|
||||||
label: "Content-aware VRR"
|
|
||||||
detail: "Enable variable refresh only for game and video content"
|
|
||||||
controlWidth: 48
|
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.vrrPolicy === 3; onToggled: value => SystemSettings.setVrrPolicy(value ? 3 : 0) }
|
|
||||||
}
|
|
||||||
SettingRow {
|
|
||||||
label: "Direct scanout for games"
|
|
||||||
detail: "Bypass compositing only for windows classified as games"
|
|
||||||
divider: false
|
|
||||||
controlWidth: 48
|
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.directScanoutPolicy === 2; onToggled: value => SystemSettings.setDirectScanoutPolicy(value ? 2 : 0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "Night Light"
|
|
||||||
SettingRow {
|
|
||||||
label: "Warm display colors"
|
|
||||||
detail: NightLight.automatic ? "Following the evening schedule" : "Manual control"
|
|
||||||
controlWidth: 48
|
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: NightLight.active; onToggled: NightLight.toggle() }
|
|
||||||
}
|
|
||||||
SettingRow {
|
|
||||||
label: "Color temperature"
|
|
||||||
detail: `${NightLight.temperature} K`
|
|
||||||
divider: false
|
|
||||||
controlWidth: 230
|
|
||||||
ValueSlider {
|
|
||||||
anchors.fill: parent
|
|
||||||
value: (6500 - NightLight.temperature) / 4000
|
|
||||||
icon: "weather-clear-night-symbolic"
|
|
||||||
onMoved: value => NightLight.temperature = Math.round((6500 - value * 4000) / 50) * 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
width: parent.width
|
|
||||||
height: warningText.implicitHeight + 30
|
|
||||||
radius: Theme.cardRadius
|
|
||||||
color: Theme.alpha(Theme.warn, 0.085)
|
|
||||||
border.width: 1
|
|
||||||
border.color: Theme.alpha(Theme.warn, 0.22)
|
|
||||||
Text {
|
Text {
|
||||||
id: warningText
|
width: parent.width
|
||||||
anchors.fill: parent
|
text: "Keep this display setting?"
|
||||||
anchors.margins: 15
|
color: Theme.fg
|
||||||
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)
|
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "Reverting in " + Displays.secondsLeft + (Displays.secondsLeft === 1 ? " second" : " seconds")
|
||||||
|
+ " if you do nothing. If you cannot read this, just wait."
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
wrapMode: Text.WordWrap
|
wrapMode: Text.WordWrap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: revertButton
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Revert now"
|
||||||
|
onClicked: Displays.revert()
|
||||||
}
|
}
|
||||||
|
SettingsButton {
|
||||||
|
id: keepButton
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Keep"
|
||||||
|
enabled: Displays.canConfirm
|
||||||
|
onClicked: Displays.confirm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: Displays.monitors.length > 1
|
||||||
|
title: "Connected display"
|
||||||
|
subtitle: "Choose the output whose resolution, scale, and rotation you want to adjust."
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Display"
|
||||||
|
options: Displays.monitors.map(monitor => ({
|
||||||
|
value: monitor.name,
|
||||||
|
label: monitor.description || monitor.name
|
||||||
|
}))
|
||||||
|
current: root.monitor ? root.monitor.name : ""
|
||||||
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
|
divider: false
|
||||||
|
onPicked: value => root.selectedOutput = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: root.monitor ? root.monitor.name : (SystemSettings.monitorName || "Active display")
|
||||||
|
subtitle: root.monitor
|
||||||
|
? `${root.monitor.description} · ${root.monitor.width} × ${root.monitor.height} at ${Math.round(root.monitor.refreshRate)} Hz · ${root.monitor.scale.toFixed(2)}× scale`
|
||||||
|
: "Reading the active display…"
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Color mode"
|
||||||
|
detail: "Wide-gamut SDR at 10-bit. Full-time HDR is left to the Hyprland config: it currently breaks screenshots, OBS, and the lock screen's blurred background."
|
||||||
|
value: root.monitor
|
||||||
|
? `${root.monitor.colorPreset || "standard"} · ${root.monitor.currentFormat || "detecting format"}`
|
||||||
|
: "Detecting"
|
||||||
|
}
|
||||||
|
TextRow {
|
||||||
|
label: "Variable refresh"
|
||||||
|
detail: root.monitor && root.monitor.vrr
|
||||||
|
? "Active on this output for current fullscreen content"
|
||||||
|
: "This output is ready when game or video content requests it"
|
||||||
|
value: root.monitor && root.monitor.vrr ? "Active" : "Standby"
|
||||||
|
divider: Displays.isOverridden(root.monitor ? root.monitor.name : "")
|
||||||
|
}
|
||||||
|
ActionRow {
|
||||||
|
visible: Displays.isOverridden(root.monitor ? root.monitor.name : "")
|
||||||
|
label: "Using a custom display setting"
|
||||||
|
detail: "Forget it to go back to the resolution and scale Panama ships"
|
||||||
|
action: "Forget"
|
||||||
|
divider: false
|
||||||
|
onTriggered: Displays.forget(root.monitor.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: root.monitor !== null
|
||||||
|
title: "Resolution"
|
||||||
|
subtitle: "Applied straight away, then reverted automatically unless you confirm."
|
||||||
|
|
||||||
|
DisplayModePicker {
|
||||||
|
width: parent.width
|
||||||
|
monitor: root.monitor
|
||||||
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: root.monitor !== null
|
||||||
|
title: "Scale and rotation"
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Scale"
|
||||||
|
detail: "Fractional scales that do not divide the resolution into whole pixels are rejected by the compositor, so only clean ones are offered."
|
||||||
|
options: Displays.scalesForMode(root.currentMode)
|
||||||
|
.map(scale => ({ value: scale, label: scale.toFixed(2) + "×" }))
|
||||||
|
current: root.monitor ? root.monitor.scale : 1
|
||||||
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
|
onPicked: value => root.applyWith({ scale: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Rotation"
|
||||||
|
options: Displays.transforms
|
||||||
|
current: root.monitor ? root.monitor.transform : 0
|
||||||
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
|
divider: false
|
||||||
|
onPicked: value => root.applyWith({ transform: value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Gaming display policy"
|
||||||
|
subtitle: "Applied immediately and restored when Panama starts."
|
||||||
|
|
||||||
|
ToggleRow { setting: "autoHdr" }
|
||||||
|
ChoiceRow { setting: "vrrPolicy" }
|
||||||
|
ChoiceRow { setting: "directScanoutPolicy"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: Displays.lastError !== ""
|
||||||
|
title: "Display problem"
|
||||||
|
subtitle: Displays.lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies a change to one field, keeping the others at what is in effect.
|
||||||
|
function applyWith(change: var): void {
|
||||||
|
if (!root.monitor)
|
||||||
|
return;
|
||||||
|
const mode = change.mode ?? root.currentMode;
|
||||||
|
const requestedScale = change.scale ?? root.monitor.scale;
|
||||||
|
Displays.apply(
|
||||||
|
root.monitor.name,
|
||||||
|
mode,
|
||||||
|
Displays.isScaleClean(mode, requestedScale)
|
||||||
|
? requestedScale
|
||||||
|
: Displays.nearestCleanScale(mode, requestedScale),
|
||||||
|
change.transform ?? root.monitor.transform);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,93 +9,61 @@ Rectangle {
|
|||||||
required property string sourceName
|
required property string sourceName
|
||||||
required property int index
|
required property int index
|
||||||
required property bool featured
|
required property bool featured
|
||||||
|
required property bool canMoveEarlier
|
||||||
|
required property bool canMoveLater
|
||||||
|
|
||||||
signal aliasCommitted(string id, string alias)
|
signal aliasCommitted(string id, string alias)
|
||||||
signal removeRequested(string id)
|
signal removeRequested(string id)
|
||||||
signal moveRequested(string id, int targetIndex)
|
signal moveRequested(string id, int targetIndex)
|
||||||
|
|
||||||
readonly property bool dragging: dragHandler.active
|
|
||||||
|
|
||||||
implicitHeight: 108
|
implicitHeight: 108
|
||||||
radius: Theme.cardRadius
|
radius: Theme.cardRadius
|
||||||
color: root.dragging
|
color: Theme.alpha(Theme.bgDark, 0.7)
|
||||||
? Theme.mix(Theme.bgDark, Theme.accent, 0.09)
|
border.width: 1
|
||||||
: Theme.alpha(Theme.bgDark, 0.7)
|
border.color: Theme.alpha(Theme.fg, 0.07)
|
||||||
border.width: root.dragging ? 2 : 1
|
|
||||||
border.color: root.dragging
|
|
||||||
? Theme.alpha(Theme.accent, 0.82)
|
|
||||||
: Theme.alpha(Theme.fg, 0.07)
|
|
||||||
z: root.dragging ? 10 : 0
|
|
||||||
|
|
||||||
transform: Translate {
|
|
||||||
x: root.dragging ? dragHandler.translation.x : 0
|
|
||||||
y: root.dragging ? dragHandler.translation.y : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
PrismEdge {
|
PrismEdge {
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
inset: root.radius
|
inset: root.radius
|
||||||
opacity: root.dragging ? 0.82 : 0.2
|
opacity: 0.2
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Column {
|
||||||
id: dragHandle
|
id: reorderControls
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 11
|
anchors.leftMargin: 8
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
width: 30
|
width: 30
|
||||||
height: 42
|
spacing: 4
|
||||||
radius: 9
|
|
||||||
activeFocusOnTab: true
|
|
||||||
color: root.dragging || activeFocus
|
|
||||||
? Theme.alpha(Theme.accent, 0.14)
|
|
||||||
: (handleMouse.containsMouse ? Theme.alpha(Theme.fg, 0.09) : Theme.alpha(Theme.fg, 0.045))
|
|
||||||
border.width: activeFocus ? 2 : 1
|
|
||||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.06)
|
|
||||||
|
|
||||||
Text {
|
SettingsButton {
|
||||||
anchors.centerIn: parent
|
width: 30
|
||||||
text: "⠿"
|
height: 29
|
||||||
color: root.dragging ? Theme.accent : Theme.fgDim
|
text: "↑"
|
||||||
font.family: Theme.fontFamily
|
enabled: root.canMoveEarlier
|
||||||
font.pixelSize: 16
|
activeFocusOnTab: enabled
|
||||||
|
onClicked: root.moveRequested(root.favorite.id, root.index - 1)
|
||||||
|
Keys.onReturnPressed: if (enabled) root.moveRequested(root.favorite.id, root.index - 1)
|
||||||
|
Keys.onSpacePressed: if (enabled) root.moveRequested(root.favorite.id, root.index - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
SettingsButton {
|
||||||
id: handleMouse
|
width: 30
|
||||||
anchors.fill: parent
|
height: 29
|
||||||
hoverEnabled: true
|
text: "↓"
|
||||||
acceptedButtons: Qt.NoButton
|
enabled: root.canMoveLater
|
||||||
cursorShape: Qt.SizeAllCursor
|
activeFocusOnTab: enabled
|
||||||
}
|
onClicked: root.moveRequested(root.favorite.id, root.index + 1)
|
||||||
|
Keys.onReturnPressed: if (enabled) root.moveRequested(root.favorite.id, root.index + 1)
|
||||||
DragHandler {
|
Keys.onSpacePressed: if (enabled) root.moveRequested(root.favorite.id, root.index + 1)
|
||||||
id: dragHandler
|
|
||||||
target: null
|
|
||||||
onActiveChanged: {
|
|
||||||
if (!active)
|
|
||||||
root.commitDrag();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Keys.onPressed: event => {
|
|
||||||
if (event.key === Qt.Key_Left || event.key === Qt.Key_Up) {
|
|
||||||
root.moveRequested(root.favorite.id, Math.max(0, root.index - 1));
|
|
||||||
event.accepted = true;
|
|
||||||
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_Down) {
|
|
||||||
const grid = root.GridView.view;
|
|
||||||
const lastIndex = grid ? grid.count - 1 : root.index;
|
|
||||||
root.moveRequested(root.favorite.id, Math.min(lastIndex, root.index + 1));
|
|
||||||
event.accepted = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: aliasFrame
|
id: aliasFrame
|
||||||
anchors.left: dragHandle.right
|
anchors.left: reorderControls.right
|
||||||
anchors.leftMargin: 10
|
anchors.leftMargin: 10
|
||||||
anchors.right: removeButton.left
|
anchors.right: removeButton.left
|
||||||
anchors.rightMargin: 12
|
anchors.rightMargin: 12
|
||||||
@@ -188,16 +156,4 @@ Rectangle {
|
|||||||
Keys.onReturnPressed: root.removeRequested(root.favorite.id)
|
Keys.onReturnPressed: root.removeRequested(root.favorite.id)
|
||||||
Keys.onSpacePressed: root.removeRequested(root.favorite.id)
|
Keys.onSpacePressed: root.removeRequested(root.favorite.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitDrag(): void {
|
|
||||||
const grid = root.GridView.view;
|
|
||||||
if (!grid || grid.count <= 0)
|
|
||||||
return;
|
|
||||||
const centerX = root.x + dragHandler.translation.x + root.width / 2;
|
|
||||||
const centerY = root.y + dragHandler.translation.y + root.height / 2;
|
|
||||||
const modelCount = grid.count;
|
|
||||||
const column = Math.max(0, Math.min(1, Math.floor(centerX / grid.cellWidth)));
|
|
||||||
const row = Math.max(0, Math.floor(centerY / grid.cellHeight));
|
|
||||||
root.moveRequested(root.favorite.id, Math.min(modelCount - 1, row * 2 + column));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,54 +2,32 @@ 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 {
|
|
||||||
id: content
|
|
||||||
width: parent.width - 68
|
|
||||||
x: 34
|
|
||||||
y: 30
|
|
||||||
spacing: 16
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: `${root.greeting}, Gabriel`
|
|
||||||
color: Theme.fg
|
|
||||||
font.family: Theme.fontFamily
|
|
||||||
font.pixelSize: 27
|
|
||||||
font.weight: Font.DemiBold
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "Your Panama desktop is configured and ready."
|
|
||||||
color: Theme.fgDim
|
|
||||||
font.family: Theme.fontFamily
|
|
||||||
font.pixelSize: Theme.fontSize
|
|
||||||
bottomPadding: 6
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: SystemSettings.monitorDescription || "Active display"
|
title: SystemSettings.monitorDescription || "Active display"
|
||||||
subtitle: SystemSettings.monitorName || "Detecting your display…"
|
subtitle: SystemSettings.monitorName || "Detecting your display…"
|
||||||
|
|
||||||
Row {
|
Grid {
|
||||||
|
id: monitorLayout
|
||||||
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: 164
|
columns: width >= 620 ? 2 : 1
|
||||||
spacing: 28
|
columnSpacing: 28
|
||||||
|
rowSpacing: 16
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
width: parent.width * 0.47
|
width: monitorLayout.columns === 2
|
||||||
height: parent.height
|
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
|
||||||
|
: monitorLayout.width
|
||||||
|
height: 164
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: Math.min(parent.width - 24, 260)
|
width: Math.min(parent.width - 24, 260)
|
||||||
@@ -85,8 +63,10 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
width: parent.width * 0.47
|
width: monitorLayout.columns === 2
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
|
||||||
|
: monitorLayout.width
|
||||||
|
height: monitorLayout.columns === 2 ? 164 : implicitHeight
|
||||||
spacing: 13
|
spacing: 13
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
@@ -120,12 +100,31 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row {
|
SettingsCard {
|
||||||
width: parent.width
|
title: "Weather"
|
||||||
spacing: 16
|
subtitle: "Local conditions in the date menu"
|
||||||
|
ChoiceRow { setting: "temperatureUnit" }
|
||||||
|
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
width: (parent.width - parent.spacing) / 2
|
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"
|
title: "Quiet focus"
|
||||||
subtitle: "Notifications and focused work"
|
subtitle: "Notifications and focused work"
|
||||||
|
|
||||||
@@ -144,11 +143,13 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
width: (parent.width - parent.spacing) / 2
|
width: summaryCards.columns === 2
|
||||||
|
? (summaryCards.width - summaryCards.columnSpacing) / 2
|
||||||
|
: summaryCards.width
|
||||||
title: "Desktop services"
|
title: "Desktop services"
|
||||||
subtitle: "The essentials are running"
|
subtitle: "The essentials are running"
|
||||||
|
|
||||||
SettingRow {
|
TextRow {
|
||||||
label: "Sync & remote access"
|
label: "Sync & remote access"
|
||||||
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}`
|
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}`
|
||||||
divider: false
|
divider: false
|
||||||
@@ -156,6 +157,4 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,40 +43,200 @@ Item {
|
|||||||
return "Home Assistant is unavailable";
|
return "Home Assistant is unavailable";
|
||||||
}
|
}
|
||||||
|
|
||||||
Flickable {
|
function saveHomeAssistantConfig(): void {
|
||||||
anchors.fill: parent
|
HomeAssistantConfig.save(
|
||||||
clip: true
|
homeUrlInput.text,
|
||||||
contentWidth: width
|
homeEntitiesInput.text,
|
||||||
contentHeight: content.implicitHeight + 64
|
homeTokenInput.text
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
);
|
||||||
|
|
||||||
Column {
|
|
||||||
id: content
|
|
||||||
width: parent.width - 68
|
|
||||||
x: 34
|
|
||||||
y: 30
|
|
||||||
spacing: 16
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "Home & Phone"
|
|
||||||
color: Theme.fg
|
|
||||||
font.family: Theme.fontFamily
|
|
||||||
font.pixelSize: 27
|
|
||||||
font.weight: Font.DemiBold
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Connections {
|
||||||
text: "Choose what appears in Control Center and keep phone continuity close at hand."
|
target: HomeAssistantConfig
|
||||||
color: Theme.fgDim
|
|
||||||
font.family: Theme.fontFamily
|
function onConfigurationSaved(): void {
|
||||||
font.pixelSize: Theme.fontSize
|
homeTokenInput.clear();
|
||||||
bottomPadding: 6
|
homeUrlInput.text = HomeAssistantConfig.url;
|
||||||
|
homeEntitiesInput.text = HomeAssistantConfig.entities.join(", ");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Home Assistant"
|
title: "Home Assistant"
|
||||||
subtitle: root.homeStatus()
|
subtitle: root.homeStatus()
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: "Connection"
|
||||||
|
detail: HomeAssistantConfig.tokenConfigured
|
||||||
|
? "A long-lived access token is stored privately"
|
||||||
|
: "Paste a long-lived access token to connect"
|
||||||
|
value: HomeAssistantConfig.configured ? "Configured" : "Not configured"
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: "Server URL"
|
||||||
|
detail: "The local or remote address of Home Assistant"
|
||||||
|
controlWidth: 330
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.pillRadius
|
||||||
|
color: Theme.alpha(Theme.fg, 0.07)
|
||||||
|
border.width: homeUrlInput.activeFocus ? 2 : 1
|
||||||
|
border.color: homeUrlInput.activeFocus
|
||||||
|
? Theme.alpha(Theme.accent, 0.55) : "transparent"
|
||||||
|
|
||||||
|
TextInput {
|
||||||
|
id: homeUrlInput
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: 12
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
activeFocusOnTab: true
|
||||||
|
text: HomeAssistantConfig.url
|
||||||
|
color: Theme.fg
|
||||||
|
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||||
|
selectedTextColor: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
verticalAlignment: TextInput.AlignVCenter
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: homeUrlInput.text === ""
|
||||||
|
text: "https://homeassistant.local:8123"
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font: homeUrlInput.font
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: "Access token"
|
||||||
|
detail: HomeAssistantConfig.tokenConfigured
|
||||||
|
? "Stored · leave blank to keep it"
|
||||||
|
: "Create one in your Home Assistant profile"
|
||||||
|
controlWidth: 330
|
||||||
|
|
||||||
|
PasswordField {
|
||||||
|
id: homeTokenInput
|
||||||
|
anchors.fill: parent
|
||||||
|
placeholder: HomeAssistantConfig.tokenConfigured
|
||||||
|
? "Stored token" : "Long-lived access token"
|
||||||
|
onAccepted: root.saveHomeAssistantConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 7
|
||||||
|
topPadding: 10
|
||||||
|
bottomPadding: 12
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "Light entities"
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "Comma-separated entity IDs. These define the discoverable light catalog."
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 72
|
||||||
|
radius: 10
|
||||||
|
color: Theme.alpha(Theme.fg, 0.055)
|
||||||
|
border.width: homeEntitiesInput.activeFocus ? 2 : 1
|
||||||
|
border.color: homeEntitiesInput.activeFocus
|
||||||
|
? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.06)
|
||||||
|
|
||||||
|
TextEdit {
|
||||||
|
id: homeEntitiesInput
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 10
|
||||||
|
activeFocusOnTab: true
|
||||||
|
text: HomeAssistantConfig.entities.join(", ")
|
||||||
|
color: Theme.fg
|
||||||
|
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||||
|
selectedTextColor: Theme.fg
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: TextEdit.Wrap
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: homeEntitiesInput.text === ""
|
||||||
|
text: "light.living_room, light.kitchen"
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font: homeEntitiesInput.font
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: HomeAssistantConfig.lastError !== ""
|
||||||
|
text: HomeAssistantConfig.lastError
|
||||||
|
color: Theme.danger
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
bottomPadding: 9
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: "Private configuration"
|
||||||
|
detail: "Saved with owner-only permissions in Panama's private environment file"
|
||||||
|
controlWidth: 216
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: clearTokenButton
|
||||||
|
text: "Clear token"
|
||||||
|
enabled: HomeAssistantConfig.tokenConfigured && !HomeAssistantConfig.busy
|
||||||
|
activeFocusOnTab: enabled
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: HomeAssistantConfig.clearToken()
|
||||||
|
Keys.onReturnPressed: if (enabled) HomeAssistantConfig.clearToken()
|
||||||
|
Keys.onSpacePressed: if (enabled) HomeAssistantConfig.clearToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: saveHomeConfigButton
|
||||||
|
text: HomeAssistantConfig.busy ? "Saving…" : "Save"
|
||||||
|
tone: "accent"
|
||||||
|
enabled: !HomeAssistantConfig.busy
|
||||||
|
activeFocusOnTab: enabled
|
||||||
|
border.width: activeFocus ? 2 : 0
|
||||||
|
border.color: activeFocus ? Theme.fg : "transparent"
|
||||||
|
onClicked: root.saveHomeAssistantConfig()
|
||||||
|
Keys.onReturnPressed: if (enabled) root.saveHomeAssistantConfig()
|
||||||
|
Keys.onSpacePressed: if (enabled) root.saveHomeAssistantConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: "Light catalog"
|
label: "Light catalog"
|
||||||
detail: "Panama reads light state through the Home Assistant helper."
|
detail: "Panama reads light state through the Home Assistant helper."
|
||||||
@@ -150,6 +312,8 @@ Item {
|
|||||||
})
|
})
|
||||||
sourceName: modelData.sourceName
|
sourceName: modelData.sourceName
|
||||||
featured: index < 4
|
featured: index < 4
|
||||||
|
canMoveEarlier: index > 0
|
||||||
|
canMoveLater: index < favoritesGrid.count - 1
|
||||||
onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias)
|
onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias)
|
||||||
onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex)
|
onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex)
|
||||||
onRemoveRequested: id => HomePreferences.remove(id)
|
onRemoveRequested: id => HomePreferences.remove(id)
|
||||||
@@ -311,6 +475,4 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,69 +2,154 @@ 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 {
|
|
||||||
id: content
|
|
||||||
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 }
|
|
||||||
Text { text: "Control interruptions without losing useful history."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Notifications"
|
title: "Notifications"
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: "Do Not Disturb"
|
label: "Do Not Disturb"
|
||||||
detail: "Keep notifications in the center but suppress banners"
|
detail: "Keep notifications in the center but suppress banners"
|
||||||
controlWidth: 48
|
controlWidth: 48
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: Notifs.doNotDisturb; onToggled: value => Notifs.doNotDisturb = value }
|
|
||||||
|
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 {
|
|
||||||
|
TextRow {
|
||||||
|
label: "Notification history"
|
||||||
|
detail: "Live notifications retained by Panama"
|
||||||
|
value: `${Notifs.history.length} items`
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
label: "Clear notification history"
|
label: "Clear notification history"
|
||||||
detail: "Dismiss every item currently in the notification center"
|
detail: "Dismiss every item currently in the notification center"
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 94
|
action: "Clear all"
|
||||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Clear all"; enabled: Notifs.history.length > 0; onClicked: Notifs.dismissAll() }
|
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: "Application rules"
|
||||||
|
subtitle: "Apps appear here after they send a notification."
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
visible: Notifs.applications.length === 0
|
||||||
|
label: "No applications remembered yet"
|
||||||
|
detail: "Application controls will appear after the first notification arrives."
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: Notifs.applications
|
||||||
|
|
||||||
|
Column {
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property var app: modelData
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: app.name
|
||||||
|
detail: app.id
|
||||||
|
controlWidth: 48
|
||||||
|
|
||||||
|
SettingsToggle {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
checked: Notifs.appRule(app.id).enabled
|
||||||
|
onToggled: value => Notifs.setAppRule(app.id, { enabled: value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: "Show on lock screen"
|
||||||
|
detail: "Allow this app's notifications on the lock screen"
|
||||||
|
controlWidth: 48
|
||||||
|
|
||||||
|
SettingsToggle {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
checked: Notifs.appRule(app.id).showOnLockScreen
|
||||||
|
onToggled: value => Notifs.setAppRule(app.id, { showOnLockScreen: value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: "Show content on lock screen"
|
||||||
|
detail: "Show message details when this app is visible there"
|
||||||
|
divider: false
|
||||||
|
controlWidth: 48
|
||||||
|
|
||||||
|
SettingsToggle {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
checked: Notifs.appRule(app.id).showContentOnLockScreen
|
||||||
|
onToggled: value => Notifs.setAppRule(app.id, { showContentOnLockScreen: value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Focus sessions"
|
title: "Focus sessions"
|
||||||
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
|
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
|
id: durationRow
|
||||||
|
|
||||||
label: "Default duration"
|
label: "Default duration"
|
||||||
detail: "Used by Super+Shift+F and Quick Settings"
|
detail: "Used by Super+Shift+F and Quick Settings"
|
||||||
controlWidth: 264
|
controlWidth: 264
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 6
|
spacing: 6
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: [25, 45, 60, 90]
|
model: [25, 45, 60, 90]
|
||||||
|
|
||||||
SettingsButton {
|
SettingsButton {
|
||||||
required property int modelData
|
required property int modelData
|
||||||
|
|
||||||
text: `${modelData}m`
|
text: `${modelData}m`
|
||||||
tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal"
|
tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal"
|
||||||
onClicked: DesktopPreferences.set("focusDurationMinutes", modelData)
|
onClicked: SystemSettings.commitPreference("focusDurationMinutes", modelData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: FocusSession.active ? `Active on ${FocusSession.workspaceLabel}` : "No active focus session"
|
label: FocusSession.active ? `Active on ${FocusSession.workspaceLabel}` : "No active focus session"
|
||||||
detail: FocusSession.active ? `${FocusSession.remainingText} remaining` : "Start one without leaving Settings"
|
detail: FocusSession.active ? `${FocusSession.remainingText} remaining` : "Start one without leaving Settings"
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 120
|
controlWidth: 120
|
||||||
|
|
||||||
SettingsButton {
|
SettingsButton {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
@@ -74,6 +159,4 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// A password entry with a reveal toggle.
|
||||||
|
//
|
||||||
|
// Deliberately not the clipboard popover's SearchField with different text: a
|
||||||
|
// Wi-Fi key typed into a field that echoes it is readable by anyone behind you,
|
||||||
|
// and a search glyph in front of a password prompt is simply wrong. Masked by
|
||||||
|
// default, revealable while held, because the reason people want to see it is
|
||||||
|
// to check a character they just typed.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property alias text: input.text
|
||||||
|
property string placeholder: "Password"
|
||||||
|
property bool revealed: false
|
||||||
|
|
||||||
|
signal accepted
|
||||||
|
|
||||||
|
implicitHeight: 32
|
||||||
|
radius: Theme.pillRadius
|
||||||
|
color: Theme.alpha(Theme.fg, 0.07)
|
||||||
|
// Not left at 0 so the focus ring has something to animate. See the note in
|
||||||
|
// modules/clipboard/SearchField.qml.
|
||||||
|
border.width: 1
|
||||||
|
border.color: input.activeFocus ? Theme.alpha(Theme.accent, 0.55) : "transparent"
|
||||||
|
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durFast }
|
||||||
|
}
|
||||||
|
|
||||||
|
function grab(): void {
|
||||||
|
input.forceActiveFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(): void {
|
||||||
|
input.text = "";
|
||||||
|
root.revealed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
TextInput {
|
||||||
|
id: input
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 13
|
||||||
|
anchors.right: revealButton.left
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
||||||
|
activeFocusOnTab: true
|
||||||
|
color: Theme.fg
|
||||||
|
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||||
|
selectedTextColor: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
echoMode: root.revealed ? TextInput.Normal : TextInput.Password
|
||||||
|
passwordCharacter: "•"
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
onAccepted: root.accepted()
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.fill: parent
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
visible: input.text === ""
|
||||||
|
text: root.placeholder
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: revealButton
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 5
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 26
|
||||||
|
height: 24
|
||||||
|
radius: 7
|
||||||
|
color: revealHover.hovered ? Theme.alpha(Theme.fg, 0.12) : "transparent"
|
||||||
|
border.width: 0
|
||||||
|
visible: input.text !== ""
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
// Nerd Font eye / eye-slash. fontMono is used for icon glyphs only.
|
||||||
|
text: root.revealed ? "\u{F070}" : "\u{F06E}"
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.pixelSize: 12
|
||||||
|
color: root.revealed ? Theme.accent : Theme.fgMuted
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: revealHover
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
onTapped: root.revealed = !root.revealed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,35 +16,6 @@ Item {
|
|||||||
onTriggered: Capture.openIntelligence()
|
onTriggered: Capture.openIntelligence()
|
||||||
}
|
}
|
||||||
|
|
||||||
Flickable {
|
|
||||||
anchors.fill: parent
|
|
||||||
clip: true
|
|
||||||
contentWidth: width
|
|
||||||
contentHeight: content.implicitHeight + 64
|
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
|
||||||
|
|
||||||
Column {
|
|
||||||
id: content
|
|
||||||
width: parent.width - 68
|
|
||||||
x: 34
|
|
||||||
y: 30
|
|
||||||
spacing: 16
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "Screen Intelligence"
|
|
||||||
color: Theme.fg
|
|
||||||
font.family: Theme.fontFamily
|
|
||||||
font.pixelSize: 27
|
|
||||||
font.weight: Font.DemiBold
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: "Turn text and codes on screen into content you can use."
|
|
||||||
color: Theme.fgDim
|
|
||||||
font.family: Theme.fontFamily
|
|
||||||
font.pixelSize: Theme.fontSize
|
|
||||||
bottomPadding: 6
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Read anything on screen"
|
title: "Read anything on screen"
|
||||||
subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions."
|
subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions."
|
||||||
@@ -52,6 +26,7 @@ Item {
|
|||||||
detail: "Copy, search, translate, or open detected links"
|
detail: "Copy, search, translate, or open detected links"
|
||||||
controlWidth: 160
|
controlWidth: 160
|
||||||
divider: false
|
divider: false
|
||||||
|
|
||||||
SettingsButton {
|
SettingsButton {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
@@ -66,21 +41,30 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
SettingsCard {
|
||||||
title: "Local recognition"
|
title: "Local recognition"
|
||||||
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result."
|
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result."
|
||||||
|
|
||||||
SettingRow {
|
TextRow {
|
||||||
label: "Text recognition"
|
label: "Text recognition"
|
||||||
detail: "Tesseract with the English language model"
|
detail: "Tesseract with the English language model"
|
||||||
value: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady ? "Ready" : "Needs install"
|
value: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady ? "Ready" : "Needs install"
|
||||||
}
|
}
|
||||||
SettingRow {
|
TextRow {
|
||||||
label: "QR & barcodes"
|
label: "QR & barcodes"
|
||||||
detail: "ZBar recognizes codes alongside ordinary text"
|
detail: "ZBar recognizes codes alongside ordinary text"
|
||||||
value: ScreenIntelligence.codeReady ? "Ready" : "Needs install"
|
value: ScreenIntelligence.codeReady ? "Ready" : "Needs install"
|
||||||
}
|
}
|
||||||
SettingRow {
|
TextRow {
|
||||||
label: "Privacy"
|
label: "Privacy"
|
||||||
detail: "Only Search, Translate, and Open send the selected result to another application or service"
|
detail: "Only Search, Translate, and Open send the selected result to another application or service"
|
||||||
value: "Local first"
|
value: "Local first"
|
||||||
@@ -90,14 +74,15 @@ Item {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Shortcut"
|
title: "Shortcut"
|
||||||
SettingRow {
|
|
||||||
|
TextRow {
|
||||||
label: "Read a screen selection"
|
label: "Read a screen selection"
|
||||||
detail: "Also available as Read in the Print-screen picker"
|
detail: "Also available as Read in the Print-screen picker"
|
||||||
value: "Super + Shift + S"
|
value: "Super + Shift + S"
|
||||||
controlWidth: 190
|
controlWidth: 190
|
||||||
divider: ScreenIntelligence.ocrReady && ScreenIntelligence.codeReady && ScreenIntelligence.englishReady
|
divider: ScreenIntelligence.ocrReady && ScreenIntelligence.codeReady && ScreenIntelligence.englishReady
|
||||||
}
|
}
|
||||||
SettingRow {
|
TextRow {
|
||||||
visible: !ScreenIntelligence.ocrReady || !ScreenIntelligence.codeReady || !ScreenIntelligence.englishReady
|
visible: !ScreenIntelligence.ocrReady || !ScreenIntelligence.codeReady || !ScreenIntelligence.englishReady
|
||||||
label: "Install recognition engines"
|
label: "Install recognition engines"
|
||||||
detail: "sudo dnf install -y tesseract zbar"
|
detail: "sudo dnf install -y tesseract zbar"
|
||||||
@@ -105,6 +90,4 @@ Item {
|
|||||||
divider: false
|
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
|
|
||||||
spacing: 16
|
|
||||||
|
|
||||||
Row {
|
SettingsButton {
|
||||||
width: parent.width
|
id: refresh
|
||||||
Text { width: parent.width - refresh.width; text: "Startup & Services"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
anchors.right: parent.right
|
||||||
SettingsButton { id: refresh; text: SystemSettings.busy ? "Refreshing…" : "Refresh"; enabled: !SystemSettings.busy; onClicked: SystemSettings.refresh() }
|
text: SystemSettings.busy ? "Refreshing…" : "Refresh"
|
||||||
|
enabled: !SystemSettings.busy
|
||||||
|
onClicked: SystemSettings.refresh()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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 }
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Your services"
|
title: "Your services"
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: "Nextcloud"
|
label: "Nextcloud"
|
||||||
detail: "File synchronization and tray status"
|
detail: "File synchronization and tray status"
|
||||||
controlWidth: 190
|
controlWidth: 190
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 10
|
spacing: 10
|
||||||
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.nextcloudActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
|
Text {
|
||||||
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("nextcloud") }
|
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 {
|
SettingRow {
|
||||||
label: "RustDesk"
|
label: "RustDesk"
|
||||||
detail: "Remote access through the enabled system service"
|
detail: "Remote access through the enabled system service"
|
||||||
controlWidth: 190
|
controlWidth: 190
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 10
|
spacing: 10
|
||||||
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.rustdeskActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
|
Text {
|
||||||
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("rustdesk") }
|
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 {
|
SettingRow {
|
||||||
label: "KDE Connect"
|
label: "KDE Connect"
|
||||||
detail: "Phone pairing, clipboard, files, and remote controls"
|
detail: "Phone pairing, clipboard, files, and remote controls"
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 190
|
controlWidth: 190
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 10
|
spacing: 10
|
||||||
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.kdeconnectActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
|
Text {
|
||||||
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("kdeconnect") }
|
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 {
|
SettingsCard {
|
||||||
title: "Desktop foundation"
|
title: "Desktop foundation"
|
||||||
SettingRow { label: "Hyprpaper"; detail: "Wallpaper service"; value: status(SystemSettings.hyprpaperActive) }
|
|
||||||
SettingRow { label: "Hypridle"; detail: "Idle and lock policy"; value: status(SystemSettings.hypridleActive) }
|
TextRow {
|
||||||
SettingRow { label: "Vicinae"; detail: "Spotlight-style launcher daemon"; value: status(SystemSettings.vicinaeActive); divider: false }
|
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 {
|
SettingsCard {
|
||||||
title: "Fedora system settings"
|
title: "Fedora system settings"
|
||||||
subtitle: "These remain owned by trusted system services and GNOME's mature panels."
|
subtitle: "These remain owned by trusted system services and GNOME's mature panels."
|
||||||
SettingRow {
|
|
||||||
|
ActionRow {
|
||||||
label: "Network, Bluetooth, printers, users, and accounts"
|
label: "Network, Bluetooth, printers, users, and accounts"
|
||||||
detail: "GNOME Settings remains searchable from the launcher too"
|
detail: "GNOME Settings remains searchable from the launcher too"
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 122
|
action: "Open network"
|
||||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open network"; onClicked: SystemSettings.openGnomePanel("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,94 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
id: pinnedHeader
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.leftMargin: 34
|
||||||
|
anchors.rightMargin: 34
|
||||||
|
anchors.topMargin: 30
|
||||||
|
active: root.header !== null
|
||||||
|
sourceComponent: root.header
|
||||||
|
z: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
id: pageScroll
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: pinnedHeader.implicitHeight > 0 ? pinnedHeader.bottom : parent.top
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.topMargin: pinnedHeader.implicitHeight > 0 ? 16 : 0
|
||||||
|
clip: true
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: layout.implicitHeight + (pinnedHeader.implicitHeight > 0 ? 34 : 64)
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: layout
|
||||||
|
|
||||||
|
width: parent.width - 68
|
||||||
|
x: 34
|
||||||
|
y: pinnedHeader.implicitHeight > 0 ? 0 : 30
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
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,13 +115,117 @@ 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 {
|
||||||
|
id: scrollContent
|
||||||
|
|
||||||
|
width: sidebarScroll.width
|
||||||
|
|
||||||
|
// ── Search results ──────────────────────────────────────────────
|
||||||
|
// Typing searches the settings themselves, not page names.
|
||||||
|
Column {
|
||||||
|
id: searchResults
|
||||||
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
spacing: 4
|
spacing: 3
|
||||||
|
visible: root.query !== ""
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
leftPadding: 4
|
||||||
|
bottomPadding: 4
|
||||||
|
text: root.results.length === 0
|
||||||
|
? "Nothing matches"
|
||||||
|
: root.results.length + (root.results.length === 1 ? " result" : " results")
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.destinations.filter(item => root.query === "" || item.label.toLowerCase().includes(root.query))
|
model: root.results
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: hit
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
height: 44
|
||||||
|
radius: 10
|
||||||
|
color: hitMouse.containsMouse ? Theme.alpha(Theme.fg, 0.08) : "transparent"
|
||||||
|
border.width: 0
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.leftMargin: 12
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: hit.modelData.label
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: hit.modelData.kind === "shortcut"
|
||||||
|
? hit.modelData.detail
|
||||||
|
: root.pageLabel(hit.modelData.page)
|
||||||
|
color: hit.modelData.kind === "shortcut" ? Theme.accent : Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: hitMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
root.pageRequested(hit.modelData.page);
|
||||||
|
searchInput.text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: navigationList
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
spacing: 4
|
||||||
|
visible: root.query === ""
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.destinations
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: navItem
|
id: navItem
|
||||||
@@ -160,8 +288,11 @@ 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 }
|
|
||||||
Text { text: "The Forge mental model, carried forward into native tiling."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Panama shortcuts"
|
title: "Keyboard"
|
||||||
Repeater {
|
|
||||||
model: root.shortcuts
|
SliderRow { setting: "keyRepeatDelay" }
|
||||||
SettingRow {
|
SliderRow { setting: "keyRepeatRate" }
|
||||||
required property var modelData
|
ToggleRow { setting: "numlockByDefault"; divider: false }
|
||||||
required property int index
|
|
||||||
label: modelData.action
|
|
||||||
value: modelData.key
|
|
||||||
controlWidth: 210
|
|
||||||
divider: index < root.shortcuts.length - 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Pointer"
|
||||||
|
|
||||||
|
ChoiceRow { setting: "followMouse" }
|
||||||
|
SliderRow { setting: "pointerSensitivity" }
|
||||||
|
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Hardware input"
|
title: "Hardware input"
|
||||||
SettingRow {
|
|
||||||
|
ActionRow {
|
||||||
label: "Mouse, touchpad, and keyboard devices"
|
label: "Mouse, touchpad, and keyboard devices"
|
||||||
detail: "Use Fedora's hardware-backed input panels"
|
detail: "Device-specific settings stay with Fedora's hardware-backed panels"
|
||||||
|
action: "Open keyboard"
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 126
|
onTriggered: SystemSettings.openGnomePanel("keyboard")
|
||||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open keyboard"; onClicked: 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
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: bindRow
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell.Services.Pipewire
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property bool output: true
|
||||||
|
readonly property var nodes: AudioDevices.nodes(root.output)
|
||||||
|
readonly property var current: AudioDevices.current(root.output)
|
||||||
|
|
||||||
|
width: parent ? parent.width : 620
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.nodes
|
||||||
|
|
||||||
|
SoundDeviceRow {
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
width: root.width
|
||||||
|
node: modelData
|
||||||
|
output: root.output
|
||||||
|
selected: modelData === root.current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.nodes.length === 0
|
||||||
|
text: Pipewire.ready ? "No audio devices found" : "Discovering audio devices…"
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
topPadding: 18
|
||||||
|
bottomPadding: 18
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Services.Pipewire
|
||||||
|
import qs.config
|
||||||
|
import qs.widgets
|
||||||
|
import qs.services
|
||||||
|
import qs.modules.quicksettings
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property var node
|
||||||
|
property bool output: true
|
||||||
|
property bool selected: false
|
||||||
|
|
||||||
|
implicitHeight: root.output || !root.selected ? 88 : 105
|
||||||
|
radius: Theme.cardRadius
|
||||||
|
color: root.selected ? Theme.alpha(Theme.accent, 0.09) : Theme.alpha(Theme.fg, 0.025)
|
||||||
|
border.width: 1
|
||||||
|
border.color: root.selected ? Theme.alpha(Theme.accent, 0.34) : Theme.alpha(Theme.fg, 0.07)
|
||||||
|
|
||||||
|
PwObjectTracker {
|
||||||
|
objects: root.node ? [root.node] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
PwNodePeakMonitor {
|
||||||
|
id: inputPeak
|
||||||
|
node: root.node
|
||||||
|
enabled: !root.output && root.selected
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property real volume: root.node?.audio?.volume ?? 0
|
||||||
|
readonly property bool muted: root.node?.audio?.muted ?? false
|
||||||
|
|
||||||
|
function iconName(): string {
|
||||||
|
if (!root.output)
|
||||||
|
return root.muted ? "microphone-sensitivity-muted-symbolic" : "audio-input-microphone-symbolic";
|
||||||
|
if (root.muted || root.volume <= 0.001)
|
||||||
|
return "audio-volume-muted-symbolic";
|
||||||
|
if (root.volume < 0.34)
|
||||||
|
return "audio-volume-low-symbolic";
|
||||||
|
if (root.volume < 0.67)
|
||||||
|
return "audio-volume-medium-symbolic";
|
||||||
|
return "audio-volume-high-symbolic";
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: heading
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.leftMargin: 12
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
anchors.topMargin: 8
|
||||||
|
height: 28
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
ThemedIcon {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
size: 20
|
||||||
|
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
|
||||||
|
iconFallback: "audio-card-symbolic"
|
||||||
|
tint: root.selected ? Theme.accent : Theme.fg
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: Math.max(0, parent.width - 20 - useButton.width - parent.spacing * 2)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: AudioDevices.label(root.node)
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: root.selected ? Font.DemiBold : Font.Medium
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.node.nickname && root.node.nickname !== AudioDevices.label(root.node)
|
||||||
|
text: root.node.nickname ?? ""
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: useButton
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: root.selected ? 78 : 64
|
||||||
|
text: root.selected ? "Default" : "Use"
|
||||||
|
enabled: !root.selected
|
||||||
|
onClicked: AudioDevices.select(root.output, root.node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
id: muteButton
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 8
|
||||||
|
anchors.top: heading.bottom
|
||||||
|
anchors.topMargin: 7
|
||||||
|
size: 30
|
||||||
|
iconSize: 17
|
||||||
|
icon: root.iconName()
|
||||||
|
iconFallback: root.output ? "audio-volume-high-symbolic" : "audio-input-microphone-symbolic"
|
||||||
|
onClicked: {
|
||||||
|
if (root.node?.audio)
|
||||||
|
root.node.audio.muted = !root.node.audio.muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ValueSlider {
|
||||||
|
id: volumeSlider
|
||||||
|
anchors.left: muteButton.right
|
||||||
|
anchors.leftMargin: 7
|
||||||
|
anchors.right: volumeText.left
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
anchors.verticalCenter: muteButton.verticalCenter
|
||||||
|
value: root.muted ? 0 : root.volume
|
||||||
|
onMoved: value => {
|
||||||
|
if (!root.node?.audio)
|
||||||
|
return;
|
||||||
|
root.node.audio.muted = false;
|
||||||
|
root.node.audio.volume = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: volumeText
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
anchors.verticalCenter: muteButton.verticalCenter
|
||||||
|
width: 38
|
||||||
|
text: Math.round(root.volume * 100) + "%"
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.left: volumeSlider.left
|
||||||
|
anchors.right: volumeText.right
|
||||||
|
anchors.top: muteButton.bottom
|
||||||
|
anchors.topMargin: 5
|
||||||
|
height: 5
|
||||||
|
radius: height / 2
|
||||||
|
visible: !root.output && root.selected
|
||||||
|
color: Theme.alpha(Theme.fg, 0.10)
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: parent.width * Math.max(0, Math.min(1, inputPeak.peak))
|
||||||
|
radius: parent.radius
|
||||||
|
color: inputPeak.peak > 0.88 ? Theme.danger : Theme.accentSecondary
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,53 +1,77 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell.Services.Pipewire
|
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
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 {
|
|
||||||
id: content
|
|
||||||
width: parent.width - 68
|
|
||||||
x: 34
|
|
||||||
y: 30
|
|
||||||
spacing: 16
|
|
||||||
|
|
||||||
Text { text: "Sound"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
|
||||||
Text { text: "Live PipeWire output, input, and device selection."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Output"
|
title: "Output"
|
||||||
subtitle: Pipewire.defaultAudioSink?.description ?? "No output device"
|
subtitle: AudioDevices.current(true)?.description ?? "No output device"
|
||||||
AudioSlider { width: parent.width; node: Pipewire.defaultAudioSink; output: true }
|
|
||||||
Rectangle { width: parent.width; height: 1; color: Theme.alpha(Theme.fg, 0.06) }
|
SoundDeviceList {
|
||||||
AudioDeviceList { width: parent.width; output: true; maxHeight: 190 }
|
width: parent.width
|
||||||
|
output: true
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioBalance {
|
||||||
|
width: parent.width
|
||||||
|
node: AudioDevices.current(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Input"
|
title: "Input"
|
||||||
subtitle: Pipewire.defaultAudioSource?.description ?? "No input device"
|
subtitle: AudioDevices.current(false)?.description ?? "No input device"
|
||||||
AudioSlider { width: parent.width; node: Pipewire.defaultAudioSource; output: false }
|
|
||||||
Rectangle { width: parent.width; height: 1; color: Theme.alpha(Theme.fg, 0.06) }
|
SoundDeviceList {
|
||||||
AudioDeviceList { width: parent.width; output: false; maxHeight: 160 }
|
width: parent.width
|
||||||
|
output: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Sound feedback"
|
||||||
|
subtitle: "Use the same event preferences as GTK and GNOME applications."
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: "Event sounds"
|
||||||
|
detail: "Play alerts and interface event sounds"
|
||||||
|
controlWidth: 42
|
||||||
|
|
||||||
|
SettingsToggle {
|
||||||
|
anchors.fill: parent
|
||||||
|
checked: SoundFeedback.eventSounds
|
||||||
|
enabled: !SoundFeedback.busy
|
||||||
|
onToggled: checked => SoundFeedback.setEventSounds(checked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: "Input feedback"
|
||||||
|
detail: "Play sounds for supported typing and input events"
|
||||||
|
controlWidth: 42
|
||||||
|
divider: false
|
||||||
|
|
||||||
|
SettingsToggle {
|
||||||
|
anchors.fill: parent
|
||||||
|
checked: SoundFeedback.inputFeedback
|
||||||
|
enabled: !SoundFeedback.busy
|
||||||
|
onToggled: checked => SoundFeedback.setInputFeedback(checked)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Advanced sound"
|
title: "Advanced sound"
|
||||||
SettingRow {
|
|
||||||
|
ActionRow {
|
||||||
label: "Application volumes and profiles"
|
label: "Application volumes and profiles"
|
||||||
detail: "Open Fedora's complete sound panel"
|
detail: "Open Fedora's complete sound panel"
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 104
|
action: "Open panel"
|
||||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("sound") }
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// Wi-Fi, at page size.
|
||||||
|
//
|
||||||
|
// The quick settings version is a popover: a compact list you glance at. This
|
||||||
|
// is the one you sit in front of when a network is not behaving, so each row
|
||||||
|
// carries what you would otherwise open a terminal to find out — signal,
|
||||||
|
// security, and whether it is a network this machine already knows.
|
||||||
|
//
|
||||||
|
// Joining a secured network reveals an inline password field rather than
|
||||||
|
// failing silently, which is the one interaction the popover already got right
|
||||||
|
// and is worth keeping identical.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Networking
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
import qs.widgets
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
// SSID whose password field is open, and the last failure.
|
||||||
|
property string passwordFor: ""
|
||||||
|
property string failedSsid: ""
|
||||||
|
property string failedText: ""
|
||||||
|
|
||||||
|
function activate(network: var): void {
|
||||||
|
root.failedSsid = "";
|
||||||
|
if (network.connected)
|
||||||
|
return;
|
||||||
|
if (network.known || !Connectivity.isSecured(network)) {
|
||||||
|
root.passwordFor = "";
|
||||||
|
network.connect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.passwordFor = root.passwordFor === network.name ? "" : network.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: Connectivity.networks
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: entry
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
width: parent.width
|
||||||
|
|
||||||
|
label: entry.modelData.name || "Hidden network"
|
||||||
|
detail: {
|
||||||
|
const bits = [];
|
||||||
|
if (entry.modelData.connected)
|
||||||
|
bits.push("Connected");
|
||||||
|
else if (entry.modelData.known)
|
||||||
|
bits.push("Saved");
|
||||||
|
bits.push(Connectivity.signalLabel(entry.modelData.signalStrength));
|
||||||
|
bits.push(Connectivity.securityLabel(entry.modelData));
|
||||||
|
return bits.join(" · ");
|
||||||
|
}
|
||||||
|
divider: entry.index < Connectivity.networks.length - 1 || root.passwordFor === entry.modelData.name
|
||||||
|
controlWidth: 190
|
||||||
|
activatable: !entry.modelData.connected
|
||||||
|
onActivated: root.activate(entry.modelData)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 7
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: entry.modelData.connected
|
||||||
|
text: "Connected"
|
||||||
|
color: Theme.accent
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: entry.modelData.connected
|
||||||
|
text: "Disconnect"
|
||||||
|
onClicked: entry.modelData.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: !entry.modelData.connected
|
||||||
|
text: entry.modelData.known ? "Connect" : "Join"
|
||||||
|
onClicked: root.activate(entry.modelData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The password field for this network, when it is the one being
|
||||||
|
// joined. Inline rather than a dialog: a dialog over a tiled window
|
||||||
|
// is a worse place to type than the row you just clicked.
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
height: root.passwordFor === entry.modelData.name ? 54 : 0
|
||||||
|
visible: height > 0
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
onVisibleChanged: {
|
||||||
|
if (visible)
|
||||||
|
password.grab();
|
||||||
|
else
|
||||||
|
password.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordField {
|
||||||
|
id: password
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: joinButton.left
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
placeholder: "Password for " + (entry.modelData.name || "network")
|
||||||
|
onAccepted: joinButton.join()
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: joinButton
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Join"
|
||||||
|
|
||||||
|
function join(): void {
|
||||||
|
entry.modelData.connect(password.text);
|
||||||
|
root.passwordFor = "";
|
||||||
|
password.text = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: joinButton.join()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.failedSsid === entry.modelData.name
|
||||||
|
leftPadding: 2
|
||||||
|
bottomPadding: 8
|
||||||
|
text: root.failedText
|
||||||
|
color: Theme.warn
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: entry.modelData
|
||||||
|
function onConnectionFailed(reason): void {
|
||||||
|
root.failedSsid = entry.modelData.name;
|
||||||
|
root.failedText = Connectivity.connectionFailureText(reason);
|
||||||
|
root.passwordFor = entry.modelData.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
width: parent.width
|
||||||
|
visible: Connectivity.networks.length === 0
|
||||||
|
label: !Connectivity.wifiDevice
|
||||||
|
? "No Wi-Fi adapter"
|
||||||
|
: (Connectivity.wifiEnabled ? "Looking for networks…" : "Wi-Fi is off")
|
||||||
|
detail: Connectivity.wifiDevice && !Connectivity.wifiEnabled
|
||||||
|
? "Turn it on above to see what is nearby"
|
||||||
|
: ""
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,3 +20,26 @@ 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
|
||||||
|
ChoiceGrid 1.0 ChoiceGrid.qml
|
||||||
|
DisplayModePicker 1.0 DisplayModePicker.qml
|
||||||
|
WifiPanel 1.0 WifiPanel.qml
|
||||||
|
BluetoothPanel 1.0 BluetoothPanel.qml
|
||||||
|
PasswordField 1.0 PasswordField.qml
|
||||||
|
AudioBalance 1.0 AudioBalance.qml
|
||||||
|
SoundDeviceList 1.0 SoundDeviceList.qml
|
||||||
|
SoundDeviceRow 1.0 SoundDeviceRow.qml
|
||||||
|
|||||||
+275
@@ -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:]))
|
||||||
@@ -192,8 +192,17 @@ def resolve_config(
|
|||||||
env: Mapping[str, str] | None = None,
|
env: Mapping[str, str] | None = None,
|
||||||
legacy: Callable[[], Config] = load_legacy_config,
|
legacy: Callable[[], Config] = load_legacy_config,
|
||||||
) -> Config:
|
) -> Config:
|
||||||
|
# An explicitly supplied environment is the WHOLE environment. Reading the
|
||||||
|
# user's private env file underneath it makes callers -- tests especially --
|
||||||
|
# depend on whatever happens to be in that file: adding a real
|
||||||
|
# PANAMA_HOME_ASSISTANT_ENTITIES to it silently overrode a fixture that was
|
||||||
|
# asserting the legacy fallback. Production passes env=None and still gets
|
||||||
|
# the file.
|
||||||
|
if env is None:
|
||||||
private_env = read_panama_env()
|
private_env = read_panama_env()
|
||||||
private_env.update(dict(os.environ if env is None else env))
|
private_env.update(dict(os.environ))
|
||||||
|
else:
|
||||||
|
private_env = dict(env)
|
||||||
|
|
||||||
url_value = private_env.get("PANAMA_HOME_ASSISTANT_URL", "").strip()
|
url_value = private_env.get("PANAMA_HOME_ASSISTANT_URL", "").strip()
|
||||||
token_value = private_env.get("PANAMA_HOME_ASSISTANT_TOKEN", "").strip()
|
token_value = private_env.get("PANAMA_HOME_ASSISTANT_TOKEN", "").strip()
|
||||||
|
|||||||
+255
@@ -0,0 +1,255 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Read and atomically update Panama's private Home Assistant settings.
|
||||||
|
|
||||||
|
Secret values are accepted only as a single JSON object on stdin and are never
|
||||||
|
returned. The command line therefore remains safe to inspect with ps(1).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import urllib.parse
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
KEY_URL = "PANAMA_HOME_ASSISTANT_URL"
|
||||||
|
KEY_TOKEN = "PANAMA_HOME_ASSISTANT_TOKEN"
|
||||||
|
KEY_ENTITIES = "PANAMA_HOME_ASSISTANT_ENTITIES"
|
||||||
|
TARGET_KEYS = (KEY_URL, KEY_TOKEN, KEY_ENTITIES)
|
||||||
|
ASSIGNMENT = re.compile(
|
||||||
|
r"^(?P<prefix>\s*(?:export\s+)?)(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=(?P<value>.*)$"
|
||||||
|
)
|
||||||
|
ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")
|
||||||
|
DEFAULT_ENV = pathlib.Path(__file__).resolve().parents[3] / "bash/env"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(RuntimeError):
|
||||||
|
"""An error code safe to display without including submitted values."""
|
||||||
|
|
||||||
|
|
||||||
|
def env_path() -> pathlib.Path:
|
||||||
|
override = os.environ.get("PANAMA_HOME_ASSISTANT_ENV_FILE", "")
|
||||||
|
return pathlib.Path(override) if override else DEFAULT_ENV
|
||||||
|
|
||||||
|
|
||||||
|
def parse_assignment(raw: str) -> str | None:
|
||||||
|
try:
|
||||||
|
parsed = shlex.split(raw, comments=True, posix=True)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return parsed[0] if len(parsed) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def read_values(path: pathlib.Path) -> dict[str, str]:
|
||||||
|
try:
|
||||||
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
except OSError as error:
|
||||||
|
raise ConfigError("read-failed") from error
|
||||||
|
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
for line in lines:
|
||||||
|
match = ASSIGNMENT.match(line)
|
||||||
|
if not match or match.group("key") not in TARGET_KEYS:
|
||||||
|
continue
|
||||||
|
value = parse_assignment(match.group("value"))
|
||||||
|
if value is not None:
|
||||||
|
values[match.group("key")] = value
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_url(value: Any) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ConfigError("invalid-url")
|
||||||
|
normalized = value.strip().rstrip("/")
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
parsed = urllib.parse.urlsplit(normalized)
|
||||||
|
if (
|
||||||
|
parsed.scheme not in {"http", "https"}
|
||||||
|
or not parsed.hostname
|
||||||
|
or parsed.username
|
||||||
|
or parsed.password
|
||||||
|
):
|
||||||
|
raise ConfigError("invalid-url")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_token(value: Any) -> str:
|
||||||
|
if not isinstance(value, str) or "\x00" in value or "\n" in value or "\r" in value:
|
||||||
|
raise ConfigError("invalid-token")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_entities(value: Any) -> tuple[str, ...]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
candidates: Sequence[Any] = re.split(r"[,\n]", value)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
candidates = value
|
||||||
|
else:
|
||||||
|
raise ConfigError("invalid-entities")
|
||||||
|
|
||||||
|
entities: list[str] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
if not isinstance(candidate, str):
|
||||||
|
raise ConfigError("invalid-entities")
|
||||||
|
entity_id = candidate.strip()
|
||||||
|
if not entity_id:
|
||||||
|
continue
|
||||||
|
if not ENTITY_ID.fullmatch(entity_id):
|
||||||
|
raise ConfigError("invalid-entities")
|
||||||
|
if entity_id not in entities:
|
||||||
|
entities.append(entity_id)
|
||||||
|
return tuple(entities)
|
||||||
|
|
||||||
|
|
||||||
|
def public_state(values: Mapping[str, str]) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
url = normalize_url(values.get(KEY_URL, ""))
|
||||||
|
entities = list(normalize_entities(values.get(KEY_ENTITIES, "")))
|
||||||
|
error = ""
|
||||||
|
except ConfigError as config_error:
|
||||||
|
url = ""
|
||||||
|
entities = []
|
||||||
|
error = str(config_error)
|
||||||
|
token_configured = bool(values.get(KEY_TOKEN, "").strip())
|
||||||
|
return {
|
||||||
|
"ok": error == "",
|
||||||
|
"configured": bool(url and token_configured),
|
||||||
|
"tokenConfigured": token_configured,
|
||||||
|
"url": url,
|
||||||
|
"entities": entities,
|
||||||
|
"error": error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_updated(original: str, updates: Mapping[str, str]) -> str:
|
||||||
|
lines = original.splitlines(keepends=True)
|
||||||
|
rendered: list[str] = []
|
||||||
|
replaced: set[str] = set()
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
content = line.rstrip("\r\n")
|
||||||
|
ending = line[len(content) :]
|
||||||
|
match = ASSIGNMENT.match(content)
|
||||||
|
key = match.group("key") if match else ""
|
||||||
|
if key not in updates:
|
||||||
|
rendered.append(line)
|
||||||
|
continue
|
||||||
|
if key in replaced:
|
||||||
|
continue
|
||||||
|
rendered.append(f"export {key}={shlex.quote(updates[key])}{ending or os.linesep}")
|
||||||
|
replaced.add(key)
|
||||||
|
|
||||||
|
if rendered and not rendered[-1].endswith(("\n", "\r")):
|
||||||
|
rendered[-1] += os.linesep
|
||||||
|
for key in TARGET_KEYS:
|
||||||
|
if key in updates and key not in replaced:
|
||||||
|
rendered.append(f"export {key}={shlex.quote(updates[key])}{os.linesep}")
|
||||||
|
return "".join(rendered)
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write(path: pathlib.Path, text: str) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
descriptor, temporary_name = tempfile.mkstemp(prefix=".env.", dir=path.parent)
|
||||||
|
temporary = pathlib.Path(temporary_name)
|
||||||
|
try:
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
||||||
|
stream.write(text)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, path)
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
os.fsync(directory_fd)
|
||||||
|
finally:
|
||||||
|
os.close(directory_fd)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
temporary.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def read_payload() -> dict[str, Any]:
|
||||||
|
line = sys.stdin.buffer.readline(1_048_577)
|
||||||
|
if not line or len(line) > 1_048_576:
|
||||||
|
raise ConfigError("invalid-payload")
|
||||||
|
try:
|
||||||
|
payload = json.loads(line)
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||||
|
raise ConfigError("invalid-payload") from error
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ConfigError("invalid-payload")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def write_payload(path: pathlib.Path, payload: Mapping[str, Any]) -> dict[str, object]:
|
||||||
|
allowed = {"url", "token", "entities"}
|
||||||
|
if not set(payload).issubset(allowed) or not payload:
|
||||||
|
raise ConfigError("invalid-payload")
|
||||||
|
|
||||||
|
updates: dict[str, str] = {}
|
||||||
|
if "url" in payload:
|
||||||
|
updates[KEY_URL] = normalize_url(payload["url"])
|
||||||
|
if "token" in payload:
|
||||||
|
updates[KEY_TOKEN] = normalize_token(payload["token"])
|
||||||
|
if "entities" in payload:
|
||||||
|
updates[KEY_ENTITIES] = ",".join(normalize_entities(payload["entities"]))
|
||||||
|
|
||||||
|
lock_path = path.with_name("." + path.name + ".lock")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
||||||
|
try:
|
||||||
|
os.fchmod(lock_fd, 0o600)
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
original = path.read_text(encoding="utf-8")
|
||||||
|
except FileNotFoundError:
|
||||||
|
original = ""
|
||||||
|
except OSError as error:
|
||||||
|
raise ConfigError("read-failed") from error
|
||||||
|
atomic_write(path, render_updated(original, updates))
|
||||||
|
finally:
|
||||||
|
os.close(lock_fd)
|
||||||
|
|
||||||
|
result = public_state(read_values(path))
|
||||||
|
result["ok"] = True
|
||||||
|
result["error"] = ""
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def compact_json(value: Mapping[str, object]) -> str:
|
||||||
|
return json.dumps(value, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
command = sys.argv[1] if len(sys.argv) > 1 else "status"
|
||||||
|
if len(sys.argv) > 2 or command not in {"status", "write"}:
|
||||||
|
raise ConfigError("usage")
|
||||||
|
path = env_path()
|
||||||
|
result = public_state(read_values(path)) if command == "status" else write_payload(path, read_payload())
|
||||||
|
print(compact_json(result))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except ConfigError as error:
|
||||||
|
print(compact_json({"ok": False, "error": str(error)}))
|
||||||
|
raise SystemExit(1) from error
|
||||||
|
except OSError:
|
||||||
|
print(compact_json({"ok": False, "error": "write-failed"}))
|
||||||
|
raise SystemExit(1)
|
||||||
Executable
+142
@@ -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
|
||||||
+647
@@ -0,0 +1,647 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
# Display geometry is never restored from a snapshot. Applying it requires
|
||||||
|
# the visible confirmation/recovery flow in Displays.qml; a settings-file
|
||||||
|
# restore followed by `hyprctl reload` must not bypass that safety boundary.
|
||||||
|
# Preserve the currently confirmed generation when it is readable, and
|
||||||
|
# otherwise remove the snapshot's geometry so startup uses shipped policy.
|
||||||
|
try:
|
||||||
|
current_desktop = read_json(SETTINGS, "The current settings file") \
|
||||||
|
if is_present(SETTINGS) else {}
|
||||||
|
except BackupError:
|
||||||
|
current_desktop = {}
|
||||||
|
|
||||||
|
if isinstance(current_desktop, dict) and "displays" in current_desktop:
|
||||||
|
# Even a Home-only snapshot must retain the confirmed monitor layout.
|
||||||
|
# In that case the restored desktop file contains only the protected
|
||||||
|
# geometry; every ordinary desktop preference remains absent/default.
|
||||||
|
desktop_present = True
|
||||||
|
desktop_data = dict(desktop_data) if desktop_data is not None else {}
|
||||||
|
desktop_data["displays"] = current_desktop["displays"]
|
||||||
|
elif desktop_present and desktop_data is not None:
|
||||||
|
desktop_data = dict(desktop_data)
|
||||||
|
desktop_data.pop("displays", 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Shared PipeWire device discovery and default selection. Quick Settings and
|
||||||
|
// Panama Settings intentionally use this same boundary so they cannot disagree
|
||||||
|
// about what counts as an input or which node should become the default.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Services.Pipewire
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property var outputs: Pipewire.nodes.values.filter(node =>
|
||||||
|
!node.isStream && node.isSink)
|
||||||
|
|
||||||
|
readonly property var inputs: Pipewire.nodes.values.filter(node =>
|
||||||
|
!node.isStream
|
||||||
|
&& (node.type & PwNodeType.AudioSource) === PwNodeType.AudioSource)
|
||||||
|
|
||||||
|
function nodes(output: bool): var {
|
||||||
|
return output ? root.outputs : root.inputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function current(output: bool): var {
|
||||||
|
return output ? Pipewire.defaultAudioSink : Pipewire.defaultAudioSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(output: bool, node: var): void {
|
||||||
|
if (!node)
|
||||||
|
return;
|
||||||
|
if (output)
|
||||||
|
Pipewire.preferredDefaultAudioSink = node;
|
||||||
|
else
|
||||||
|
Pipewire.preferredDefaultAudioSource = node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function label(node: var): string {
|
||||||
|
if (!node)
|
||||||
|
return "Unknown device";
|
||||||
|
return node.description || node.nickname || node.name || "Unknown device";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Network and Bluetooth state for the settings page.
|
||||||
|
//
|
||||||
|
// The hard parts -- scanning, joining, pairing -- already work in the quick
|
||||||
|
// settings panel through Quickshell.Networking and Quickshell.Bluetooth, which
|
||||||
|
// speak to NetworkManager and BlueZ over DBus. Nothing here shells out to nmcli
|
||||||
|
// or bluetoothctl, and nothing should: the founding requirement for this
|
||||||
|
// desktop was never having to drop to a terminal to join a network.
|
||||||
|
//
|
||||||
|
// This exists so the page does not have to reach into those modules for the
|
||||||
|
// same derived values the panel already computes, and so scanning is driven by
|
||||||
|
// whether the page is actually on screen. Scanning while nobody is looking is
|
||||||
|
// radio time and battery spent on a list that is not being read.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Networking
|
||||||
|
import Quickshell.Bluetooth
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// Set by the page while it is visible; drives both scanners.
|
||||||
|
property bool active: false
|
||||||
|
|
||||||
|
readonly property var wifiDevice: {
|
||||||
|
for (const device of Networking.devices.values) {
|
||||||
|
if (device.type === DeviceType.Wifi)
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var wiredDevice: {
|
||||||
|
for (const device of Networking.devices.values) {
|
||||||
|
if (device.type === DeviceType.Wired)
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var adapter: Bluetooth.defaultAdapter
|
||||||
|
|
||||||
|
readonly property bool wifiEnabled: Networking.wifiEnabled
|
||||||
|
readonly property bool wifiAvailable: Networking.wifiHardwareEnabled
|
||||||
|
|
||||||
|
// Current network, then saved, then by signal -- the order GNOME uses,
|
||||||
|
// which is the order you actually look for things in.
|
||||||
|
readonly property var networks: {
|
||||||
|
if (!root.wifiDevice || !root.wifiDevice.networks)
|
||||||
|
return [];
|
||||||
|
const list = root.wifiDevice.networks.values.slice();
|
||||||
|
list.sort((a, b) => {
|
||||||
|
if (a.connected !== b.connected)
|
||||||
|
return a.connected ? -1 : 1;
|
||||||
|
if (a.known !== b.known)
|
||||||
|
return a.known ? -1 : 1;
|
||||||
|
return b.signalStrength - a.signalStrength;
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var savedNetworks: root.networks.filter(network => network.known)
|
||||||
|
|
||||||
|
readonly property var bluetoothDevices: {
|
||||||
|
if (!Bluetooth.devices)
|
||||||
|
return [];
|
||||||
|
const list = Bluetooth.devices.values.slice();
|
||||||
|
list.sort((a, b) => {
|
||||||
|
if (a.connected !== b.connected)
|
||||||
|
return a.connected ? -1 : 1;
|
||||||
|
if (a.paired !== b.paired)
|
||||||
|
return a.paired ? -1 : 1;
|
||||||
|
return String(a.name || "").localeCompare(String(b.name || ""));
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var activeNetwork: root.networks.find(network => network.connected) ?? null
|
||||||
|
|
||||||
|
function isSecured(network: var): bool {
|
||||||
|
return network.security !== WifiSecurityType.Open
|
||||||
|
&& network.security !== WifiSecurityType.Owe
|
||||||
|
&& network.security !== WifiSecurityType.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function securityLabel(network: var): string {
|
||||||
|
if (!root.isSecured(network))
|
||||||
|
return "Open";
|
||||||
|
switch (network.security) {
|
||||||
|
case WifiSecurityType.Wep: return "WEP";
|
||||||
|
case WifiSecurityType.Wpa: return "WPA";
|
||||||
|
case WifiSecurityType.Wpa2: return "WPA2";
|
||||||
|
case WifiSecurityType.Wpa3: return "WPA3";
|
||||||
|
case WifiSecurityType.Enterprise: return "Enterprise";
|
||||||
|
}
|
||||||
|
return "Secured";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Four bars is what people read signal as, so bucket rather than showing a
|
||||||
|
// percentage that changes every scan and means nothing to anyone.
|
||||||
|
//
|
||||||
|
// signalStrength is 0.0-1.0, NOT a percentage. Treating it as 0-100 puts
|
||||||
|
// every network including the connected one in the bottom bucket, which is
|
||||||
|
// exactly as useless as showing nothing. Thresholds match the icon buckets
|
||||||
|
// in modules/quicksettings/WifiList.qml so the two never disagree.
|
||||||
|
function signalLabel(strength: real): string {
|
||||||
|
if (strength >= 0.8) return "Excellent";
|
||||||
|
if (strength >= 0.55) return "Good";
|
||||||
|
if (strength >= 0.3) return "Fair";
|
||||||
|
if (strength > 0.05) return "Weak";
|
||||||
|
return "No signal";
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectionFailureText(reason: var): string {
|
||||||
|
switch (reason) {
|
||||||
|
case ConnectionFailReason.WifiAuthTimeout:
|
||||||
|
case ConnectionFailReason.Authentication:
|
||||||
|
return "Wrong password";
|
||||||
|
case ConnectionFailReason.WifiNetworkLost:
|
||||||
|
return "Network out of range";
|
||||||
|
}
|
||||||
|
return "Could not connect";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scanning follows visibility. NetworkManager keeps scanning as long as it
|
||||||
|
// is asked to, and Bluetooth discovery is worse -- it holds the radio.
|
||||||
|
function syncScanners(): void {
|
||||||
|
if (root.wifiDevice)
|
||||||
|
root.wifiDevice.scannerEnabled = root.active && root.wifiEnabled;
|
||||||
|
|
||||||
|
if (root.adapter && root.adapter.enabled) {
|
||||||
|
const shouldDiscover = root.active;
|
||||||
|
if (root.adapter.discovering !== shouldDiscover)
|
||||||
|
root.adapter.discovering = shouldDiscover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onActiveChanged: root.syncScanners()
|
||||||
|
onWifiDeviceChanged: root.syncScanners()
|
||||||
|
onWifiEnabledChanged: root.syncScanners()
|
||||||
|
onAdapterChanged: root.syncScanners()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Display configuration: resolution, refresh rate, scale, and rotation.
|
||||||
|
//
|
||||||
|
// This is the only page in Panama Settings where a wrong value can leave you
|
||||||
|
// unable to SEE the screen well enough to undo it. A mode the display cannot
|
||||||
|
// show, or a scale that makes everything unreadable, is not recoverable through
|
||||||
|
// the same UI that caused it.
|
||||||
|
//
|
||||||
|
// So a change is never applied irreversibly. It is applied, then reverted
|
||||||
|
// automatically after a countdown unless confirmed -- the same contract every
|
||||||
|
// desktop uses for this one setting, and for the same reason. Confirming is
|
||||||
|
// what writes it to the settings store; letting the countdown run leaves
|
||||||
|
// nothing behind.
|
||||||
|
//
|
||||||
|
// Applied with `hyprctl eval` and hl.monitor{}. As everywhere else in Panama,
|
||||||
|
// success means the value was read back from the compositor and matched, never
|
||||||
|
// that a command exited zero.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// [{ name, description, width, height, refreshRate, scale, transform,
|
||||||
|
// modes: [{ label, mode, width, height, refresh }] }]
|
||||||
|
property var monitors: []
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
// Set while a change is applied but not yet confirmed.
|
||||||
|
property string pendingOutput: ""
|
||||||
|
property var pendingPrevious: null
|
||||||
|
property var pendingRequested: null
|
||||||
|
property bool pendingVerified: false
|
||||||
|
property bool revertQueued: false
|
||||||
|
property var revertExpected: null
|
||||||
|
property string revertReason: ""
|
||||||
|
property bool revertVerificationActive: false
|
||||||
|
property int operationGeneration: 0
|
||||||
|
property int revertGeneration: -1
|
||||||
|
property bool externalChangeBlocked: false
|
||||||
|
property int secondsLeft: 0
|
||||||
|
|
||||||
|
readonly property bool awaitingConfirmation: root.pendingOutput !== ""
|
||||||
|
readonly property bool canConfirm: root.awaitingConfirmation
|
||||||
|
&& root.pendingVerified
|
||||||
|
&& !root.busy
|
||||||
|
readonly property bool busy: query.running || applyRun.running || revertRun.running
|
||||||
|
|| root.revertExpected !== null
|
||||||
|
|
||||||
|
readonly property int confirmSeconds: 15
|
||||||
|
|
||||||
|
readonly property var transforms: [
|
||||||
|
{ value: 0, label: "Landscape" },
|
||||||
|
{ value: 1, label: "Portrait" },
|
||||||
|
{ value: 2, label: "Landscape (flipped)" },
|
||||||
|
{ value: 3, label: "Portrait (flipped)" }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Scales that divide this desktop's common resolutions into whole pixels.
|
||||||
|
// Hyprland rejects a fractional scale that does not, and the message it
|
||||||
|
// gives is not something to put in front of a user.
|
||||||
|
readonly property var scales: [1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: query
|
||||||
|
property int generation: 0
|
||||||
|
command: ["hyprctl", "-j", "monitors"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.parse(this.text, query.generation)
|
||||||
|
}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0)
|
||||||
|
root.lastError = "Could not read the connected displays.";
|
||||||
|
if (root.revertQueued && !applyRun.running && root.awaitingConfirmation)
|
||||||
|
root.performRevert();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: applyRun
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (!root.awaitingConfirmation)
|
||||||
|
return;
|
||||||
|
if (root.revertQueued) {
|
||||||
|
if (!query.running)
|
||||||
|
root.performRevert();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.revertWithMessage("The display rejected that change and Panama restored the previous setting.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
verifyTimer.attempts = 0;
|
||||||
|
verifyTimer.ticks = 0;
|
||||||
|
verifyTimer.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: revertRun
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
// Exit status is advisory only. Hyprland's Lua bridge can report
|
||||||
|
// success without applying a value, so exact readback decides.
|
||||||
|
root.revertVerificationActive = true;
|
||||||
|
revertVerifyTimer.attempts = 0;
|
||||||
|
revertVerifyTimer.ticks = 0;
|
||||||
|
revertVerifyTimer.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.refresh()
|
||||||
|
|
||||||
|
function refresh(): bool {
|
||||||
|
if (!query.running) {
|
||||||
|
query.generation = root.operationGeneration;
|
||||||
|
query.running = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(text: string, generation: int): void {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(text);
|
||||||
|
root.monitors = raw.map(monitor => {
|
||||||
|
const modes = root.normaliseModes(monitor.availableModes ?? []);
|
||||||
|
const width = monitor.width ?? 0;
|
||||||
|
const height = monitor.height ?? 0;
|
||||||
|
const refreshRate = monitor.refreshRate ?? 0;
|
||||||
|
const current = modes
|
||||||
|
.filter(mode => mode.width === width && mode.height === height)
|
||||||
|
.sort((left, right) =>
|
||||||
|
Math.abs(left.refresh - refreshRate) - Math.abs(right.refresh - refreshRate))[0];
|
||||||
|
return {
|
||||||
|
name: monitor.name ?? "",
|
||||||
|
description: monitor.description ?? monitor.model ?? "Display",
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
refreshRate: refreshRate,
|
||||||
|
mode: current?.mode ?? `${width}x${height}@${refreshRate}`,
|
||||||
|
scale: monitor.scale ?? 1,
|
||||||
|
transform: monitor.transform ?? 0,
|
||||||
|
currentFormat: monitor.currentFormat ?? "",
|
||||||
|
colorPreset: monitor.colorManagementPreset ?? "",
|
||||||
|
vrr: monitor.vrr === true,
|
||||||
|
modes: modes
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (root.awaitingConfirmation && root.pendingRequested
|
||||||
|
&& root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
||||||
|
root.pendingVerified = true;
|
||||||
|
verifyTimer.stop();
|
||||||
|
root.lastError = "";
|
||||||
|
} else if (root.revertVerificationActive
|
||||||
|
&& generation === root.revertGeneration
|
||||||
|
&& root.revertExpected
|
||||||
|
&& root.matchesRequest(root.monitorNamed(root.revertExpected.output), root.revertExpected)) {
|
||||||
|
revertVerifyTimer.stop();
|
||||||
|
root.revertVerificationActive = false;
|
||||||
|
root.revertGeneration = -1;
|
||||||
|
root.revertExpected = null;
|
||||||
|
if (root.revertReason === "")
|
||||||
|
root.lastError = "";
|
||||||
|
else
|
||||||
|
root.lastError = root.revertReason;
|
||||||
|
root.revertReason = "";
|
||||||
|
} else if (!root.awaitingConfirmation && !root.revertExpected && (
|
||||||
|
root.lastError === "Could not read the connected displays."
|
||||||
|
|| root.lastError === "The display list could not be read.")) {
|
||||||
|
root.lastError = "";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
root.lastError = "The display list could not be read.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "4500x3000@60.00Hz" -> a sortable record. The compositor reports the same
|
||||||
|
// resolution at distinct rates such as 60.00 and 59.94. Those identities
|
||||||
|
// remain separate because confirmation and recovery must read back the
|
||||||
|
// exact mode the user chose, even when their rounded labels look similar.
|
||||||
|
function normaliseModes(raw: var): var {
|
||||||
|
const seen = {};
|
||||||
|
const out = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
const match = String(entry).match(/^(\d+)x(\d+)@([\d.]+)Hz$/);
|
||||||
|
if (!match)
|
||||||
|
continue;
|
||||||
|
const width = Number(match[1]);
|
||||||
|
const height = Number(match[2]);
|
||||||
|
const refreshText = match[3];
|
||||||
|
const refresh = Number(refreshText);
|
||||||
|
const roundedRefresh = Math.round(refresh);
|
||||||
|
const key = `${width}x${height}@${refreshText}`;
|
||||||
|
if (seen[key])
|
||||||
|
continue;
|
||||||
|
seen[key] = true;
|
||||||
|
out.push({
|
||||||
|
label: `${width} × ${height}`,
|
||||||
|
refreshLabel: Math.abs(refresh - roundedRefresh) < 0.005
|
||||||
|
? `${roundedRefresh} Hz`
|
||||||
|
: `${refresh.toFixed(2)} Hz`,
|
||||||
|
mode: `${width}x${height}@${refreshText}`,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
refresh: refresh
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => (b.width * b.height) - (a.width * a.height) || b.refresh - a.refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
function monitorNamed(name: string): var {
|
||||||
|
return root.monitors.find(monitor => monitor.name === name) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeParts(mode: string): var {
|
||||||
|
const match = String(mode).match(/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
|
||||||
|
if (!match)
|
||||||
|
return null;
|
||||||
|
return {
|
||||||
|
width: Number(match[1]),
|
||||||
|
height: Number(match[2]),
|
||||||
|
refresh: Number(match[3])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isScaleClean(mode: string, scale: real): bool {
|
||||||
|
const parts = root.modeParts(mode);
|
||||||
|
if (!parts || root.scales.indexOf(scale) < 0 || !Number.isFinite(scale) || scale <= 0)
|
||||||
|
return false;
|
||||||
|
const logicalWidth = parts.width / scale;
|
||||||
|
const logicalHeight = parts.height / scale;
|
||||||
|
return Math.abs(logicalWidth - Math.round(logicalWidth)) < 0.0001
|
||||||
|
&& Math.abs(logicalHeight - Math.round(logicalHeight)) < 0.0001;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scalesForMode(mode: string): var {
|
||||||
|
return root.scales.filter(scale => root.isScaleClean(mode, scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestCleanScale(mode: string, preferred: real): real {
|
||||||
|
const choices = root.scalesForMode(mode);
|
||||||
|
if (choices.length === 0)
|
||||||
|
return 1.0;
|
||||||
|
return choices.reduce((best, candidate) =>
|
||||||
|
Math.abs(candidate - preferred) < Math.abs(best - preferred) ? candidate : best,
|
||||||
|
choices[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesRequest(monitor: var, requested: var): bool {
|
||||||
|
if (!monitor || !requested || monitor.name !== requested.output)
|
||||||
|
return false;
|
||||||
|
const parts = root.modeParts(requested.mode);
|
||||||
|
return !!parts
|
||||||
|
&& monitor.width === parts.width
|
||||||
|
&& monitor.height === parts.height
|
||||||
|
&& Math.abs(monitor.refreshRate - parts.refresh) < 0.01
|
||||||
|
&& Math.abs(monitor.scale - requested.scale) < 0.001
|
||||||
|
&& monitor.transform === requested.transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeIsCurrent(monitor: var, candidate: var): bool {
|
||||||
|
return !!monitor && !!candidate
|
||||||
|
&& monitor.width === candidate.width
|
||||||
|
&& monitor.height === candidate.height
|
||||||
|
&& Math.abs(monitor.refreshRate - candidate.refresh) < 0.01;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies immediately and starts the countdown. Nothing is stored yet: the
|
||||||
|
// settings file is only written by confirm().
|
||||||
|
function apply(output: string, mode: string, scale: real, transform: int): bool {
|
||||||
|
if (root.externalChangeBlocked) {
|
||||||
|
root.lastError = "Wait for Settings to finish restoring before changing a display.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (root.busy) {
|
||||||
|
root.lastError = "Wait for the current display operation to finish.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (root.awaitingConfirmation) {
|
||||||
|
root.lastError = "Finish the current display change first.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const monitor = root.monitorNamed(output);
|
||||||
|
if (!monitor) {
|
||||||
|
root.lastError = "That display is not connected.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!monitor.modes.some(candidate => candidate.mode === mode)) {
|
||||||
|
root.lastError = "That display does not offer that mode.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!root.isScaleClean(mode, scale)) {
|
||||||
|
root.lastError = "That scale does not divide this resolution cleanly.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!root.transforms.some(candidate => candidate.value === transform)) {
|
||||||
|
root.lastError = "That rotation is not one Panama offers.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.pendingPrevious = {
|
||||||
|
output: output,
|
||||||
|
mode: monitor.mode,
|
||||||
|
scale: monitor.scale,
|
||||||
|
transform: monitor.transform
|
||||||
|
};
|
||||||
|
root.operationGeneration++;
|
||||||
|
root.pendingRequested = {
|
||||||
|
output: output,
|
||||||
|
mode: mode,
|
||||||
|
scale: scale,
|
||||||
|
transform: transform
|
||||||
|
};
|
||||||
|
root.pendingOutput = output;
|
||||||
|
root.pendingVerified = false;
|
||||||
|
root.revertQueued = false;
|
||||||
|
root.secondsLeft = root.confirmSeconds;
|
||||||
|
root.lastError = "";
|
||||||
|
countdown.restart();
|
||||||
|
|
||||||
|
root.push(output, mode, scale, transform);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function push(output: string, mode: string, scale: real, transform: int): void {
|
||||||
|
// Values are validated above and the output name comes from the
|
||||||
|
// compositor's own list, so nothing user-authored reaches the payload.
|
||||||
|
applyRun.exec(["hyprctl", "eval",
|
||||||
|
`hl.monitor({ output = "${output}", mode = "${mode}", scale = ${scale}, transform = ${transform} })`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm(): bool {
|
||||||
|
if (!root.canConfirm || !root.matchesRequest(
|
||||||
|
root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
||||||
|
if (root.awaitingConfirmation)
|
||||||
|
root.lastError = "Wait for the display to finish applying before keeping it.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = DesktopPreferences.get("displays");
|
||||||
|
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
|
||||||
|
next[root.pendingOutput] = {
|
||||||
|
mode: root.pendingRequested.mode,
|
||||||
|
scale: root.pendingRequested.scale,
|
||||||
|
transform: root.pendingRequested.transform
|
||||||
|
};
|
||||||
|
if (!DesktopPreferences.set("displays", next)) {
|
||||||
|
root.lastError = "That display setting could not be saved. Revert it and try again.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.clearPending();
|
||||||
|
root.lastError = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPending(): void {
|
||||||
|
countdown.stop();
|
||||||
|
verifyTimer.stop();
|
||||||
|
root.pendingOutput = "";
|
||||||
|
root.pendingPrevious = null;
|
||||||
|
root.pendingRequested = null;
|
||||||
|
root.pendingVerified = false;
|
||||||
|
root.revertQueued = false;
|
||||||
|
root.secondsLeft = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function revert(): void {
|
||||||
|
root.revertWithMessage("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function revertWithMessage(message: string): void {
|
||||||
|
if (!root.awaitingConfirmation)
|
||||||
|
return;
|
||||||
|
countdown.stop();
|
||||||
|
verifyTimer.stop();
|
||||||
|
root.pendingVerified = false;
|
||||||
|
root.revertReason = message;
|
||||||
|
if (message !== "")
|
||||||
|
root.lastError = message;
|
||||||
|
if (applyRun.running || query.running) {
|
||||||
|
root.revertQueued = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.performRevert();
|
||||||
|
}
|
||||||
|
|
||||||
|
function performRevert(): void {
|
||||||
|
const previous = root.pendingPrevious;
|
||||||
|
root.operationGeneration++;
|
||||||
|
root.revertGeneration = root.operationGeneration;
|
||||||
|
root.revertExpected = previous;
|
||||||
|
root.revertVerificationActive = false;
|
||||||
|
root.clearPending();
|
||||||
|
if (previous) {
|
||||||
|
revertRun.exec(["hyprctl", "eval",
|
||||||
|
`hl.monitor({ output = "${previous.output}", mode = "${previous.mode}", scale = ${previous.scale}, transform = ${previous.transform} })`]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clears any stored override for an output so it returns to the value
|
||||||
|
// shipped in hypr/monitors.lua on the next start.
|
||||||
|
function forget(output: string): void {
|
||||||
|
const stored = DesktopPreferences.get("displays");
|
||||||
|
if (!stored || typeof stored !== "object" || stored[output] === undefined)
|
||||||
|
return;
|
||||||
|
const next = Object.assign({}, stored);
|
||||||
|
delete next[output];
|
||||||
|
DesktopPreferences.set("displays", next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverridden(output: string): bool {
|
||||||
|
const stored = DesktopPreferences.get("displays");
|
||||||
|
return !!(stored && typeof stored === "object" && stored[output] !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: verifyTimer
|
||||||
|
property int attempts: 0
|
||||||
|
property int ticks: 0
|
||||||
|
interval: 120
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
ticks++;
|
||||||
|
if (ticks > 50) {
|
||||||
|
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (root.refresh())
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: revertVerifyTimer
|
||||||
|
property int attempts: 0
|
||||||
|
property int ticks: 0
|
||||||
|
interval: 120
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
ticks++;
|
||||||
|
if (ticks > 50) {
|
||||||
|
stop();
|
||||||
|
root.revertVerificationActive = false;
|
||||||
|
root.revertGeneration = -1;
|
||||||
|
root.revertExpected = null;
|
||||||
|
root.revertReason = "";
|
||||||
|
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (root.refresh())
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: countdown
|
||||||
|
interval: 1000
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
root.secondsLeft -= 1;
|
||||||
|
if (root.secondsLeft <= 0)
|
||||||
|
root.revert();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Redacted Home Assistant configuration state. The helper is the only object
|
||||||
|
// that touches the private env file; QML never receives the stored token.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-home-assistant-config"
|
||||||
|
|
||||||
|
property string url: ""
|
||||||
|
property var entities: []
|
||||||
|
property bool tokenConfigured: false
|
||||||
|
property bool configured: false
|
||||||
|
property string lastError: ""
|
||||||
|
property string pendingPayload: ""
|
||||||
|
property var refreshHomeAssistant: function() { HomeAssistant.refresh(); }
|
||||||
|
readonly property bool busy: statusProc.running || writeProc.running
|
||||||
|
|
||||||
|
signal configurationSaved
|
||||||
|
|
||||||
|
function errorMessage(code: string): string {
|
||||||
|
switch (code) {
|
||||||
|
case "invalid-url": return "Enter an HTTP or HTTPS Home Assistant URL.";
|
||||||
|
case "invalid-token": return "The access token contains unsupported characters.";
|
||||||
|
case "invalid-entities": return "Entity IDs must look like light.living_room.";
|
||||||
|
case "read-failed": return "The private configuration file could not be read.";
|
||||||
|
case "write-failed": return "The private configuration file could not be saved.";
|
||||||
|
case "invalid-payload": return "The configuration could not be validated.";
|
||||||
|
default: return code ? "Home Assistant configuration is unavailable." : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyResult(text: string, saved: bool): void {
|
||||||
|
let result = null;
|
||||||
|
try {
|
||||||
|
result = JSON.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
result = { ok: false, error: "invalid-response" };
|
||||||
|
}
|
||||||
|
if (result.ok !== true) {
|
||||||
|
root.lastError = root.errorMessage(String(result.error || "invalid-response"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.url = String(result.url || "");
|
||||||
|
root.entities = Array.isArray(result.entities) ? result.entities : [];
|
||||||
|
root.tokenConfigured = result.tokenConfigured === true;
|
||||||
|
root.configured = result.configured === true;
|
||||||
|
root.lastError = "";
|
||||||
|
if (saved) {
|
||||||
|
root.configurationSaved();
|
||||||
|
root.refreshHomeAssistant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!statusProc.running && !writeProc.running)
|
||||||
|
statusProc.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty token means "keep the stored token". Clearing is an explicit
|
||||||
|
// separate action so editing the URL can never erase a secret by accident.
|
||||||
|
function save(url: string, entitiesText: string, token: string): bool {
|
||||||
|
if (root.busy)
|
||||||
|
return false;
|
||||||
|
const payload = { url: url, entities: entitiesText };
|
||||||
|
if (token.trim() !== "")
|
||||||
|
payload.token = token;
|
||||||
|
return root.startWrite(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearToken(): bool {
|
||||||
|
if (root.busy || !root.tokenConfigured)
|
||||||
|
return false;
|
||||||
|
return root.startWrite({ token: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startWrite(payload: var): bool {
|
||||||
|
root.lastError = "";
|
||||||
|
root.pendingPayload = JSON.stringify(payload);
|
||||||
|
writeProc.running = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: statusProc
|
||||||
|
command: [root.helperPath, "status"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.applyResult(this.text, false)
|
||||||
|
}
|
||||||
|
onExited: (code, status) => {
|
||||||
|
if (code !== 0 && root.lastError === "")
|
||||||
|
root.lastError = "Home Assistant configuration could not be loaded.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: writeProc
|
||||||
|
command: [root.helperPath, "write"]
|
||||||
|
stdinEnabled: true
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.applyResult(this.text, true)
|
||||||
|
}
|
||||||
|
onStarted: {
|
||||||
|
writeProc.write(root.pendingPayload + "\n");
|
||||||
|
root.pendingPayload = "";
|
||||||
|
}
|
||||||
|
onExited: (code, status) => {
|
||||||
|
root.pendingPayload = "";
|
||||||
|
if (code !== 0 && root.lastError === "")
|
||||||
|
root.lastError = "Home Assistant configuration could not be saved.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.refresh()
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Idle, lock, and sleep timings.
|
||||||
|
//
|
||||||
|
// hypridle has no IPC for reconfiguration, and its config is hyprlang rather
|
||||||
|
// than the shared JSON, so this cannot work the way the compositor settings do.
|
||||||
|
// Instead scripts/panama-idle regenerates a config from the settings store and
|
||||||
|
// restarts the daemon.
|
||||||
|
//
|
||||||
|
// The generated file lives under XDG_STATE_HOME rather than ~/.config/hypr,
|
||||||
|
// because that directory is a symlink into the Panama repository -- writing
|
||||||
|
// there at runtime would put machine state into a tracked file. A systemd
|
||||||
|
// drop-in points hypridle at the generated path with `-c`.
|
||||||
|
//
|
||||||
|
// "Managed" is therefore a real state with two sides: when the drop-in is
|
||||||
|
// installed the timings below are in effect, and when it is not, hypridle is
|
||||||
|
// running the repository's shipped hypridle.conf and these values are only a
|
||||||
|
// stored intention. The Power page says which it is rather than showing
|
||||||
|
// controls that quietly do nothing.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-idle"
|
||||||
|
|
||||||
|
property bool managed: false
|
||||||
|
property string serviceState: "unknown"
|
||||||
|
property string generatedPath: ""
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
readonly property bool busy: statusQuery.running || applyRun.running
|
||||||
|
|
||||||
|
// The values as stored. They only describe what is running when `managed`.
|
||||||
|
readonly property int blankMinutes: DesktopPreferences.get("screenBlankMinutes")
|
||||||
|
readonly property int lockMinutes: DesktopPreferences.get("lockMinutes")
|
||||||
|
readonly property int suspendMinutes: DesktopPreferences.get("suspendMinutes")
|
||||||
|
readonly property bool lockOnSleep: DesktopPreferences.get("lockOnSleep")
|
||||||
|
|
||||||
|
// Blanking after locking is legal but pointless, and blanking with lock off
|
||||||
|
// is fine. Surfacing the one genuinely confusing combination beats silently
|
||||||
|
// reordering the user's numbers.
|
||||||
|
readonly property bool lockBeforeBlank: root.lockMinutes > 0
|
||||||
|
&& root.blankMinutes > 0
|
||||||
|
&& root.lockMinutes < root.blankMinutes
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: statusQuery
|
||||||
|
command: [root.helperPath, "status"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(this.text);
|
||||||
|
root.managed = state.managed === true;
|
||||||
|
root.serviceState = String(state.active ?? "unknown");
|
||||||
|
root.generatedPath = String(state.generated ?? "");
|
||||||
|
root.lastError = "";
|
||||||
|
} catch (error) {
|
||||||
|
root.lastError = "Could not read the idle configuration.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: applyRun
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.lastError = exitCode === 0 ? "" : "Could not update the idle configuration.";
|
||||||
|
root.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.refresh()
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!statusQuery.running)
|
||||||
|
statusQuery.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerates the config from the current settings and restarts hypridle if
|
||||||
|
// Panama is managing it. Safe to call when it is not: the file is written
|
||||||
|
// and nothing is restarted.
|
||||||
|
function apply(): void {
|
||||||
|
if (applyRun.running)
|
||||||
|
return;
|
||||||
|
applyRun.exec([root.helperPath, "apply"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setManaged(enabled: bool): void {
|
||||||
|
if (applyRun.running)
|
||||||
|
return;
|
||||||
|
applyRun.exec([root.helperPath, enabled ? "install" : "remove"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerate whenever one of the four inputs changes. Coalesced, because a
|
||||||
|
// slider drag settles through several commits and each one would otherwise
|
||||||
|
// restart the daemon.
|
||||||
|
Connections {
|
||||||
|
target: DesktopPreferences
|
||||||
|
function onRevisionChanged(): void {
|
||||||
|
if (root.managed)
|
||||||
|
regenerate.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: regenerate
|
||||||
|
interval: 400
|
||||||
|
onTriggered: root.apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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] }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,39 @@ Singleton {
|
|||||||
// Cleared when the notification centre is opened. The bar binds to this.
|
// Cleared when the notification centre is opened. The bar binds to this.
|
||||||
property int unreadCount: 0
|
property int unreadCount: 0
|
||||||
|
|
||||||
|
// Kept separate from the persisted map so this version can safely run
|
||||||
|
// before the matching schema entry lands. A later accepted write folds the
|
||||||
|
// complete map into DesktopPreferences and clears this fallback.
|
||||||
|
property var fallbackAppRules: ({})
|
||||||
|
|
||||||
|
// Display metadata is intentionally session-only. The durable shape stays
|
||||||
|
// just the per-application rule map, while a fresh notification gives the
|
||||||
|
// settings page a human-readable name straight away.
|
||||||
|
property var rememberedApplications: ({})
|
||||||
|
|
||||||
|
readonly property var persistedAppRules: {
|
||||||
|
const stored = DesktopPreferences.get("notificationAppRules");
|
||||||
|
return stored && typeof stored === "object" && !Array.isArray(stored) ? stored : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The schema change is the persistence boundary. This branch keeps a
|
||||||
|
// session fallback only so it remains usable while that companion change
|
||||||
|
// is being integrated; it intentionally makes no restart guarantee then.
|
||||||
|
readonly property bool appRulesSchemaAvailable: PreferenceSchema.has("notificationAppRules")
|
||||||
|
|
||||||
|
readonly property var appRules: Object.assign({}, root.persistedAppRules, root.fallbackAppRules)
|
||||||
|
|
||||||
|
readonly property var applications: {
|
||||||
|
// byId()/heuristicLookup() do not make a binding by themselves. This
|
||||||
|
// read updates persisted app labels once DesktopEntries finishes scan.
|
||||||
|
const entries = DesktopEntries.applications.values;
|
||||||
|
const remembered = root.rememberedApplications;
|
||||||
|
return Object.keys(root.appRules).map(appId => ({
|
||||||
|
id: appId,
|
||||||
|
name: root.applicationLabel(appId, entries, remembered)
|
||||||
|
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
// Arrival times, keyed by notification id — the protocol carries no
|
// Arrival times, keyed by notification id — the protocol carries no
|
||||||
// timestamp. Deliberately formatted once at arrival rather than shown as
|
// timestamp. Deliberately formatted once at arrival rather than shown as
|
||||||
// "5 minutes ago", which would need a clock ticking behind every card.
|
// "5 minutes ago", which would need a clock ticking behind every card.
|
||||||
@@ -49,6 +82,79 @@ Singleton {
|
|||||||
|
|
||||||
readonly property bool hasNotifications: root.history.length > 0
|
readonly property bool hasNotifications: root.history.length > 0
|
||||||
|
|
||||||
|
function notificationAppId(notification: var): string {
|
||||||
|
const desktopEntry = String(notification.desktopEntry ?? "").trim();
|
||||||
|
return desktopEntry || String(notification.appName ?? "").trim() || "Notifications";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applicationLabel(appId: string, entries: var, remembered: var): string {
|
||||||
|
const desktopId = appId.endsWith(".desktop") ? appId.slice(0, -8) : appId;
|
||||||
|
const entry = DesktopEntries.byId(appId)
|
||||||
|
|| DesktopEntries.byId(desktopId)
|
||||||
|
|| DesktopEntries.heuristicLookup(appId)
|
||||||
|
|| DesktopEntries.heuristicLookup(desktopId);
|
||||||
|
return entry?.name || remembered[appId]?.name || appId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedAppRule(rule: var): var {
|
||||||
|
const source = rule && typeof rule === "object" && !Array.isArray(rule) ? rule : {};
|
||||||
|
return {
|
||||||
|
enabled: source.enabled !== false,
|
||||||
|
showOnLockScreen: source.showOnLockScreen !== false,
|
||||||
|
showContentOnLockScreen: source.showContentOnLockScreen !== false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appRule(appId: string): var {
|
||||||
|
return root.normalizedAppRule(root.appRules[appId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAppRule(appId: string, patch: var): bool {
|
||||||
|
if (!appId)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const current = root.appRule(appId);
|
||||||
|
const next = {};
|
||||||
|
for (const knownAppId of Object.keys(root.appRules))
|
||||||
|
next[knownAppId] = root.appRule(knownAppId);
|
||||||
|
next[appId] = {
|
||||||
|
enabled: patch.enabled === undefined ? current.enabled : patch.enabled === true,
|
||||||
|
showOnLockScreen: patch.showOnLockScreen === undefined ? current.showOnLockScreen : patch.showOnLockScreen === true,
|
||||||
|
showContentOnLockScreen: patch.showContentOnLockScreen === undefined ? current.showContentOnLockScreen : patch.showContentOnLockScreen === true
|
||||||
|
};
|
||||||
|
|
||||||
|
if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next))
|
||||||
|
root.fallbackAppRules = {};
|
||||||
|
else
|
||||||
|
root.fallbackAppRules = next;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberApplication(notification: var): string {
|
||||||
|
const appId = root.notificationAppId(notification);
|
||||||
|
const next = Object.assign({}, root.rememberedApplications);
|
||||||
|
next[appId] = {
|
||||||
|
name: String(notification.appName ?? "").trim() || appId
|
||||||
|
};
|
||||||
|
root.rememberedApplications = next;
|
||||||
|
|
||||||
|
if (root.appRules[appId] === undefined)
|
||||||
|
root.setAppRule(appId, {});
|
||||||
|
return appId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// These policy getters deliberately accept Notification objects, so a lock
|
||||||
|
// screen can use the same source of truth without duplicating app matching.
|
||||||
|
function shouldShowOnLockScreen(notification: var): bool {
|
||||||
|
const rule = root.appRule(root.notificationAppId(notification));
|
||||||
|
return rule.enabled && rule.showOnLockScreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowContentOnLockScreen(notification: var): bool {
|
||||||
|
const rule = root.appRule(root.notificationAppId(notification));
|
||||||
|
return rule.enabled && rule.showOnLockScreen && rule.showContentOnLockScreen;
|
||||||
|
}
|
||||||
|
|
||||||
// history grouped by app, in most-recent-app-first order — the shape
|
// history grouped by app, in most-recent-app-first order — the shape
|
||||||
// NotificationCenter.qml renders directly.
|
// NotificationCenter.qml renders directly.
|
||||||
readonly property var groups: {
|
readonly property var groups: {
|
||||||
@@ -85,13 +191,20 @@ Singleton {
|
|||||||
actionIconsSupported: true
|
actionIconsSupported: true
|
||||||
inlineReplySupported: true
|
inlineReplySupported: true
|
||||||
|
|
||||||
onNotification: notification => {
|
onNotification: notification => root.handleNotification(notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNotification(notification: var): void {
|
||||||
// Replayed from before a shell reload. Letting these through would
|
// Replayed from before a shell reload. Letting these through would
|
||||||
// re-toast and re-list everything on every edit, so they are left
|
// re-toast and re-list everything on every edit, so they are left
|
||||||
// untracked and allowed to die.
|
// untracked and allowed to die.
|
||||||
if (notification.lastGeneration)
|
if (notification.lastGeneration)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
const appId = root.rememberApplication(notification);
|
||||||
|
if (!root.appRule(appId).enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
// Without this the object is destroyed the instant this returns.
|
// Without this the object is destroyed the instant this returns.
|
||||||
notification.tracked = true;
|
notification.tracked = true;
|
||||||
root.arrivals[notification.id] = new Date();
|
root.arrivals[notification.id] = new Date();
|
||||||
@@ -109,11 +222,10 @@ Singleton {
|
|||||||
if (!root.doNotDisturb)
|
if (!root.doNotDisturb)
|
||||||
root.popups = [notification].concat(root.popups);
|
root.popups = [notification].concat(root.popups);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mutation ────────────────────────────────────────────────────────────
|
// ── Mutation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function pushHistory(n: Notification): void {
|
function pushHistory(n: var): void {
|
||||||
const next = [n].concat(root.history);
|
const next = [n].concat(root.history);
|
||||||
|
|
||||||
// Anything past the cap is released, otherwise it stays tracked
|
// Anything past the cap is released, otherwise it stays tracked
|
||||||
@@ -166,7 +278,7 @@ Singleton {
|
|||||||
|
|
||||||
// Called from the `closed` signal — the object is on its way out, so this
|
// Called from the `closed` signal — the object is on its way out, so this
|
||||||
// only ever removes references, never touches the notification.
|
// only ever removes references, never touches the notification.
|
||||||
function forget(n: Notification): void {
|
function forget(n: var): void {
|
||||||
delete root.arrivals[n.id];
|
delete root.arrivals[n.id];
|
||||||
if (root.history.indexOf(n) !== -1)
|
if (root.history.indexOf(n) !== -1)
|
||||||
root.history = root.history.filter(x => x !== n);
|
root.history = root.history.filter(x => x !== n);
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
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 readDisplays: function() { return DesktopPreferences.get("displays"); }
|
||||||
|
property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }
|
||||||
|
property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; }
|
||||||
|
property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
|
||||||
|
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); }
|
||||||
|
property var protectedDisplays: ({})
|
||||||
|
|
||||||
|
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.";
|
||||||
|
if (actionRun.restoring) {
|
||||||
|
root.setDisplayBlocked(false);
|
||||||
|
root.protectedDisplays = ({});
|
||||||
|
}
|
||||||
|
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.";
|
||||||
|
if (!homeReloaded) {
|
||||||
|
root.setDisplayBlocked(false);
|
||||||
|
root.protectedDisplays = ({});
|
||||||
|
}
|
||||||
|
} 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.setDisplayBlocked(false);
|
||||||
|
root.protectedDisplays = ({});
|
||||||
|
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();
|
||||||
|
if (!root.protectDisplays(root.protectedDisplays))
|
||||||
|
return false;
|
||||||
|
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.displayBusy()) {
|
||||||
|
root.lastError = "Finish the current display change before restoring settings.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!root.snapshots.some(snapshot => snapshot.name === name)) {
|
||||||
|
root.lastError = "That snapshot is not in the list.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const currentDisplays = root.readDisplays();
|
||||||
|
root.protectedDisplays = JSON.parse(JSON.stringify(
|
||||||
|
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
|
||||||
|
root.setDisplayBlocked(true);
|
||||||
|
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;
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// GNOME and GTK applications already honour these desktop sound preferences.
|
||||||
|
// Panama controls the same durable keys so moving between sessions does not
|
||||||
|
// create two competing notions of whether event feedback is enabled.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property bool eventSounds: true
|
||||||
|
property bool inputFeedback: false
|
||||||
|
property string lastError: ""
|
||||||
|
readonly property bool busy: eventRead.running || inputRead.running
|
||||||
|
|| eventWrite.running || inputWrite.running
|
||||||
|
|
||||||
|
function parsedBoolean(text: string, fallback: bool): bool {
|
||||||
|
const value = text.trim();
|
||||||
|
if (value === "true")
|
||||||
|
return true;
|
||||||
|
if (value === "false")
|
||||||
|
return false;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!eventRead.running)
|
||||||
|
eventRead.running = true;
|
||||||
|
if (!inputRead.running)
|
||||||
|
inputRead.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEventSounds(enabled: bool): void {
|
||||||
|
root.eventSounds = enabled;
|
||||||
|
eventWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "event-sounds", String(enabled)];
|
||||||
|
eventWrite.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInputFeedback(enabled: bool): void {
|
||||||
|
root.inputFeedback = enabled;
|
||||||
|
inputWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "input-feedback-sounds", String(enabled)];
|
||||||
|
inputWrite.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: eventRead
|
||||||
|
command: ["gsettings", "get", "org.gnome.desktop.sound", "event-sounds"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.eventSounds = root.parsedBoolean(this.text, root.eventSounds)
|
||||||
|
}
|
||||||
|
onExited: (code, status) => {
|
||||||
|
if (code !== 0)
|
||||||
|
root.lastError = "Event sound preferences could not be read.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: inputRead
|
||||||
|
command: ["gsettings", "get", "org.gnome.desktop.sound", "input-feedback-sounds"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.inputFeedback = root.parsedBoolean(this.text, root.inputFeedback)
|
||||||
|
}
|
||||||
|
onExited: (code, status) => {
|
||||||
|
if (code !== 0)
|
||||||
|
root.lastError = "Input feedback preferences could not be read.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: eventWrite
|
||||||
|
onExited: (code, status) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
root.lastError = "Event sound preferences could not be changed.";
|
||||||
|
root.refresh();
|
||||||
|
} else {
|
||||||
|
root.lastError = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: inputWrite
|
||||||
|
onExited: (code, status) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
root.lastError = "Input feedback preferences could not be changed.";
|
||||||
|
root.refresh();
|
||||||
|
} else {
|
||||||
|
root.lastError = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.refresh()
|
||||||
|
}
|
||||||
@@ -33,6 +33,16 @@ Singleton {
|
|||||||
property string quickshellVersion: "0.3.0"
|
property string quickshellVersion: "0.3.0"
|
||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
|
|
||||||
|
// Explicit seams keep reset sequencing testable without changing the live
|
||||||
|
// keymap, wallpaper, or display from an isolated contract harness.
|
||||||
|
property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; }
|
||||||
|
property var readDisplays: function() { return DesktopPreferences.get("displays"); }
|
||||||
|
property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }
|
||||||
|
property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
|
||||||
|
property var reloadKeybinds: function() { Keybinds.applyReload(); }
|
||||||
|
property var keybindsReloading: function() { return Keybinds.reloading; }
|
||||||
|
property var applyWallpaper: function(path) { Wallpaper.set(path); }
|
||||||
|
|
||||||
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|
||||||
|| configWrite.running || configVerify.running || bluebubblesQuery.running
|
|| configWrite.running || configVerify.running || bluebubblesQuery.running
|
||||||
|
|
||||||
@@ -196,6 +206,11 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Applying options ────────────────────────────────────────────────────
|
// ── Applying options ────────────────────────────────────────────────────
|
||||||
|
// Test harnesses may replace the external compositor boundary while still
|
||||||
|
// exercising validation, commit routing, persistence, and reset replay.
|
||||||
|
// Production leaves this unset and always uses the verified Hyprland path.
|
||||||
|
property var compositorApplyOverride: null
|
||||||
|
|
||||||
// `values` maps schema keys to values, e.g. { vrrPolicy: 3, gapsOut: 12 }.
|
// `values` maps schema keys to values, e.g. { vrrPolicy: 3, gapsOut: 12 }.
|
||||||
// The whole batch is validated before anything is sent, so one bad value
|
// The whole batch is validated before anything is sent, so one bad value
|
||||||
// rejects the batch rather than half-applying it.
|
// rejects the batch rather than half-applying it.
|
||||||
@@ -216,14 +231,41 @@ Singleton {
|
|||||||
}
|
}
|
||||||
if (Object.keys(requested).length === 0)
|
if (Object.keys(requested).length === 0)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
if (root.compositorApplyOverride !== null)
|
||||||
|
return root.compositorApplyOverride(requested);
|
||||||
|
|
||||||
|
// 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 +350,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 +377,89 @@ 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(): bool {
|
||||||
|
if (root.displayBusy()) {
|
||||||
|
root.lastError = "Finish the current display change before restoring defaults.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDisplays = root.readDisplays();
|
||||||
|
const protectedDisplays = JSON.parse(JSON.stringify(
|
||||||
|
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
|
||||||
|
root.setDisplayBlocked(true);
|
||||||
|
DesktopPreferences.resetDesktopDefaults();
|
||||||
|
if (!root.protectDisplays(protectedDisplays)) {
|
||||||
|
root.setDisplayBlocked(false);
|
||||||
|
root.lastError = "The current display setting could not be protected during reset.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: resettleTimer
|
||||||
|
interval: 60
|
||||||
|
onTriggered: {
|
||||||
|
root.applyPersistedDisplayPolicy();
|
||||||
|
root.reloadKeybinds();
|
||||||
|
root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));
|
||||||
|
resetRelease.attempts = 0;
|
||||||
|
resetRelease.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: resetRelease
|
||||||
|
property int attempts: 0
|
||||||
|
interval: 100
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
attempts++;
|
||||||
|
if ((!root.keybindsReloading() && !root.busy) || attempts >= 50) {
|
||||||
|
stop();
|
||||||
|
root.setDisplayBlocked(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAutoHdr(enabled: bool): void {
|
function setAutoHdr(enabled: bool): void {
|
||||||
@@ -359,9 +485,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,173 @@
|
|||||||
|
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")
|
||||||
|
readonly property string shippedPath: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg`
|
||||||
|
|
||||||
|
// 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 string storedValue: ""
|
||||||
|
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.storedValue);
|
||||||
|
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 {
|
||||||
|
const effectivePath = path === "" ? root.shippedPath : path;
|
||||||
|
if (PreferenceSchema.coerce("wallpaperPath", effectivePath) === undefined) {
|
||||||
|
root.lastError = "That file path cannot be used as a wallpaper.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (apply.running)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
apply.requested = effectivePath;
|
||||||
|
apply.storedValue = path;
|
||||||
|
|
||||||
|
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]},${effectivePath}`]);
|
||||||
|
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,102 @@
|
|||||||
|
// 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: []
|
||||||
|
property bool displayOperationBusy: false
|
||||||
|
property bool displayBlocked: false
|
||||||
|
property var displayGeneration: ({ "DP-2": { mode: "4500x3000@60", scale: 1.5, transform: 0 } })
|
||||||
|
|
||||||
|
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.readDisplays = function() { return root.displayGeneration; };
|
||||||
|
SettingsBackup.protectDisplays = function(value) {
|
||||||
|
root.record("display.protect:" + JSON.stringify(value));
|
||||||
|
root.displayGeneration = value;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
SettingsBackup.displayBusy = function() { return root.displayOperationBusy; };
|
||||||
|
SettingsBackup.setDisplayBlocked = function(blocked) {
|
||||||
|
root.record("display.block:" + blocked);
|
||||||
|
root.displayBlocked = blocked;
|
||||||
|
};
|
||||||
|
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 = [];
|
||||||
|
root.displayOperationBusy = false;
|
||||||
|
root.displayBlocked = false;
|
||||||
|
SettingsBackup.protectedDisplays = root.displayGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(output: string): bool {
|
||||||
|
return SettingsBackup.handleRestoreOutput(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreWhileDisplayBusy(): bool {
|
||||||
|
root.displayOperationBusy = true;
|
||||||
|
SettingsBackup.snapshots = [{ name: "settings-20260818-010203004.json" }];
|
||||||
|
return SettingsBackup.restore("settings-20260818-010203004.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
calls: root.calls,
|
||||||
|
initialized: root.homeInitialized,
|
||||||
|
favorites: root.homeFavorites,
|
||||||
|
displayBlocked: root.displayBlocked,
|
||||||
|
displays: root.displayGeneration,
|
||||||
|
lastError: SettingsBackup.lastError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,48 @@ import qs.config
|
|||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
ShellRoot {
|
ShellRoot {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var resetCalls: []
|
||||||
|
property var appliedBatches: []
|
||||||
|
property bool displayBlocked: false
|
||||||
|
|
||||||
|
function recordReset(name: string): void {
|
||||||
|
const next = root.resetCalls.slice();
|
||||||
|
next.push(name);
|
||||||
|
root.resetCalls = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
// Keep compositor verification entirely inside the isolated harness.
|
||||||
|
// Production applyOptions is covered separately by the Hyprland write
|
||||||
|
// contract; this seam proves commit/reset routing without changing the
|
||||||
|
// desktop that is running the test.
|
||||||
|
if (Quickshell.env("PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR") === "1") {
|
||||||
|
SystemSettings.compositorApplyOverride = function(requested) {
|
||||||
|
const batches = root.appliedBatches.slice();
|
||||||
|
batches.push(requested);
|
||||||
|
root.appliedBatches = batches;
|
||||||
|
for (const key in requested)
|
||||||
|
DesktopPreferences.set(key, requested[key]);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
SystemSettings.displayBusy = function() { return false; };
|
||||||
|
SystemSettings.readDisplays = function() { return DesktopPreferences.get("displays"); };
|
||||||
|
SystemSettings.protectDisplays = function(value) {
|
||||||
|
root.recordReset("display.protect");
|
||||||
|
return DesktopPreferences.set("displays", value);
|
||||||
|
};
|
||||||
|
SystemSettings.setDisplayBlocked = function(blocked) {
|
||||||
|
root.recordReset("display.block:" + blocked);
|
||||||
|
root.displayBlocked = blocked;
|
||||||
|
};
|
||||||
|
SystemSettings.reloadKeybinds = function() { root.recordReset("keybinds.reload"); };
|
||||||
|
SystemSettings.keybindsReloading = function() { return false; };
|
||||||
|
SystemSettings.applyWallpaper = function(path) { root.recordReset("wallpaper.set:" + path); };
|
||||||
|
}
|
||||||
|
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
target: "settings-system-test"
|
target: "settings-system-test"
|
||||||
|
|
||||||
@@ -29,6 +71,46 @@ 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(): bool {
|
||||||
|
root.resetCalls = [];
|
||||||
|
return SystemSettings.restoreDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetState(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
calls: root.resetCalls,
|
||||||
|
displayBlocked: root.displayBlocked,
|
||||||
|
appliedBatches: root.appliedBatches
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyState(): string {
|
||||||
|
return JSON.stringify(root.appliedBatches);
|
||||||
|
}
|
||||||
|
|
||||||
function panelAllowed(panel: string): bool {
|
function panelAllowed(panel: string): bool {
|
||||||
return SystemSettings.isGnomePanelAllowed(panel);
|
return SystemSettings.isGnomePanelAllowed(panel);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Read-only contract harness for the Sound page. It instantiates every device
|
||||||
|
// row against the real PipeWire graph but exposes no mutating IPC methods.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import Quickshell.Services.Pipewire
|
||||||
|
import QtQuick
|
||||||
|
import qs.services
|
||||||
|
import qs.modules.settings
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
SoundPage {
|
||||||
|
width: 760
|
||||||
|
height: 900
|
||||||
|
}
|
||||||
|
|
||||||
|
PwObjectTracker {
|
||||||
|
objects: AudioDevices.outputs.concat(AudioDevices.inputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "sound-page-test"
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
ready: Pipewire.ready,
|
||||||
|
outputs: AudioDevices.outputs.length,
|
||||||
|
inputs: AudioDevices.inputs.length,
|
||||||
|
defaultOutput: AudioDevices.label(AudioDevices.current(true)),
|
||||||
|
defaultInput: AudioDevices.label(AudioDevices.current(false))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
```
|
||||||
Executable
+82
@@ -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'
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property int refreshCalls: 0
|
||||||
|
|
||||||
|
FileView {
|
||||||
|
id: tokenFile
|
||||||
|
path: Quickshell.env("PANAMA_TEST_TOKEN_FILE")
|
||||||
|
blockLoading: true
|
||||||
|
printErrors: false
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
HomeAssistant.fixtureMode = true;
|
||||||
|
HomeAssistantConfig.refreshHomeAssistant = function() { root.refreshCalls++; };
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "home-assistant-config-test"
|
||||||
|
|
||||||
|
function save(url: string, entities: string): bool {
|
||||||
|
return HomeAssistantConfig.save(url, entities, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveWithToken(url: string, entities: string): bool {
|
||||||
|
return HomeAssistantConfig.save(url, entities, tokenFile.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearToken(): bool {
|
||||||
|
return HomeAssistantConfig.clearToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
HomeAssistantConfig.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
url: HomeAssistantConfig.url,
|
||||||
|
entities: HomeAssistantConfig.entities,
|
||||||
|
tokenConfigured: HomeAssistantConfig.tokenConfigured,
|
||||||
|
configured: HomeAssistantConfig.configured,
|
||||||
|
busy: HomeAssistantConfig.busy,
|
||||||
|
lastError: HomeAssistantConfig.lastError,
|
||||||
|
pendingPayloadEmpty: HomeAssistantConfig.pendingPayload === "",
|
||||||
|
refreshCalls: root.refreshCalls
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
function notification(idValue: int, desktopEntryValue: string, appNameValue: string): var {
|
||||||
|
const closeHandlers = [];
|
||||||
|
return {
|
||||||
|
id: idValue,
|
||||||
|
desktopEntry: desktopEntryValue,
|
||||||
|
appName: appNameValue,
|
||||||
|
appIcon: "",
|
||||||
|
transient: false,
|
||||||
|
lastGeneration: false,
|
||||||
|
tracked: false,
|
||||||
|
dismissed: false,
|
||||||
|
closed: {
|
||||||
|
connect: callback => closeHandlers.push(callback)
|
||||||
|
},
|
||||||
|
dismiss: function() {
|
||||||
|
this.dismissed = true;
|
||||||
|
for (const callback of closeHandlers)
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetNotifications(): void {
|
||||||
|
Notifs.history = [];
|
||||||
|
Notifs.popups = [];
|
||||||
|
Notifs.unreadCount = 0;
|
||||||
|
Notifs.doNotDisturb = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
root.resetNotifications();
|
||||||
|
Notifs.fallbackAppRules = {};
|
||||||
|
Notifs.rememberedApplications = {};
|
||||||
|
DesktopPreferences.set("notificationAppRules", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "notification-app-rules-test"
|
||||||
|
|
||||||
|
function exercise(): string {
|
||||||
|
root.reset();
|
||||||
|
|
||||||
|
const signal = root.notification(1, "org.signal.Signal.desktop", "Signal");
|
||||||
|
Notifs.handleNotification(signal);
|
||||||
|
const appId = Notifs.notificationAppId(signal);
|
||||||
|
const initialRules = DesktopPreferences.get("notificationAppRules");
|
||||||
|
|
||||||
|
const fallback = root.notification(6, "", "Fallback Terminal");
|
||||||
|
Notifs.handleNotification(fallback);
|
||||||
|
const fallbackId = Notifs.notificationAppId(fallback);
|
||||||
|
const fallbackApplication = Notifs.applications.find(app => app.id === fallbackId);
|
||||||
|
|
||||||
|
root.resetNotifications();
|
||||||
|
Notifs.setAppRule(appId, { enabled: false });
|
||||||
|
const muted = root.notification(2, "org.signal.Signal.desktop", "Signal");
|
||||||
|
Notifs.handleNotification(muted);
|
||||||
|
const mutedResult = {
|
||||||
|
tracked: muted.tracked,
|
||||||
|
history: Notifs.history.length,
|
||||||
|
popups: Notifs.popups.length,
|
||||||
|
unread: Notifs.unreadCount
|
||||||
|
};
|
||||||
|
|
||||||
|
root.reset();
|
||||||
|
Notifs.doNotDisturb = true;
|
||||||
|
const dnd = root.notification(3, "org.signal.Signal.desktop", "Signal");
|
||||||
|
Notifs.handleNotification(dnd);
|
||||||
|
|
||||||
|
Notifs.setAppRule("org.privacy.App.desktop", {
|
||||||
|
showOnLockScreen: false,
|
||||||
|
showContentOnLockScreen: false
|
||||||
|
});
|
||||||
|
const privateNotification = root.notification(4, "org.privacy.App.desktop", "Private");
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
appId: appId,
|
||||||
|
initialRules: initialRules,
|
||||||
|
fallback: {
|
||||||
|
id: fallbackId,
|
||||||
|
application: fallbackApplication
|
||||||
|
},
|
||||||
|
muted: mutedResult,
|
||||||
|
dnd: {
|
||||||
|
tracked: dnd.tracked,
|
||||||
|
history: Notifs.history.length,
|
||||||
|
popups: Notifs.popups.length,
|
||||||
|
unread: Notifs.unreadCount
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
visible: Notifs.shouldShowOnLockScreen(privateNotification),
|
||||||
|
content: Notifs.shouldShowContentOnLockScreen(privateNotification)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(): string {
|
||||||
|
root.reset();
|
||||||
|
const notification = root.notification(5, "org.persist.App.desktop", "Persist");
|
||||||
|
Notifs.handleNotification(notification);
|
||||||
|
Notifs.setAppRule("org.persist.App.desktop", {
|
||||||
|
enabled: false,
|
||||||
|
showOnLockScreen: true,
|
||||||
|
showContentOnLockScreen: false
|
||||||
|
});
|
||||||
|
return JSON.stringify(DesktopPreferences.get("notificationAppRules"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function restored(): string {
|
||||||
|
return JSON.stringify(DesktopPreferences.get("notificationAppRules"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applications(): string {
|
||||||
|
return JSON.stringify(Notifs.applications);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+126
@@ -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'
|
||||||
Executable
+99
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Network & Devices reads real NetworkManager and BlueZ state.
|
||||||
|
#
|
||||||
|
# Both of the bugs this contract exists to prevent were silent. Neither logged
|
||||||
|
# anything; both produced a page that looked fine and told the user something
|
||||||
|
# false:
|
||||||
|
#
|
||||||
|
# * the device lookups used enum names that do not exist
|
||||||
|
# (NetworkDeviceType.Wifi rather than DeviceType.Wifi), so they returned
|
||||||
|
# null and the page reported "No Wi-Fi adapter" on a machine whose Wi-Fi was
|
||||||
|
# connected;
|
||||||
|
# * signalStrength is 0.0-1.0, not a percentage, so thresholds written for
|
||||||
|
# 0-100 put every network including the connected one in the bottom bucket.
|
||||||
|
#
|
||||||
|
# So this compares what the service resolves against what NetworkManager itself
|
||||||
|
# reports, rather than merely checking the service does not crash.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
harness="$repo_dir/config/dot/quickshell/connectivity-harness.qml"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'connectivity contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
command -v nmcli >/dev/null || fail 'nmcli is needed to check the service against reality'
|
||||||
|
|
||||||
|
run() { qs -p "$harness" "$@"; }
|
||||||
|
harness_pid=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
run ipc call connectivity-test setActive false >/dev/null 2>&1 || true
|
||||||
|
# By PID: never `pkill -f connectivity-harness`, which also matches the
|
||||||
|
# shell running this script.
|
||||||
|
[[ -n "$harness_pid" ]] && kill "$harness_pid" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
qs -p "$harness" --daemonize >/dev/null
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
run ipc show 2>/dev/null | rg -q '^target connectivity-test$' && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
run ipc show 2>/dev/null | rg -q '^target connectivity-test$' || fail 'test IPC target did not start'
|
||||||
|
harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')"
|
||||||
|
|
||||||
|
# Scanning only runs while the page says it is visible.
|
||||||
|
run ipc call connectivity-test setActive true >/dev/null
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
state="$(run ipc call connectivity-test status)"
|
||||||
|
|
||||||
|
# ── Devices the service finds must match the ones NetworkManager reports ─────
|
||||||
|
nm_wifi="$(nmcli -t -f DEVICE,TYPE device | awk -F: '$2 == "wifi" { print $1; exit }')"
|
||||||
|
nm_wired="$(nmcli -t -f DEVICE,TYPE,STATE device | awk -F: '$2 == "ethernet" && $3 == "connected" { print $1; exit }')"
|
||||||
|
|
||||||
|
if [[ -n "$nm_wifi" ]]; then
|
||||||
|
[[ "$(jq -r .wifiDevice <<<"$state")" == "$nm_wifi" ]] \
|
||||||
|
|| fail "NetworkManager reports Wi-Fi device '$nm_wifi' but the service found '$(jq -r .wifiDevice <<<"$state")'"
|
||||||
|
fi
|
||||||
|
if [[ -n "$nm_wired" ]]; then
|
||||||
|
[[ "$(jq -r .wiredConnected <<<"$state")" == "true" ]] \
|
||||||
|
|| fail "NetworkManager reports '$nm_wired' connected but the service says it is not"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Signal strength is a ratio, and the labels must reflect that ─────────────
|
||||||
|
while IFS='|' read -r value expect; do
|
||||||
|
got="$(run ipc call connectivity-test labelFor "$value")"
|
||||||
|
[[ "$got" == "$expect" ]] || fail "signal $value labelled '$got', expected '$expect'"
|
||||||
|
done <<'CASES'
|
||||||
|
1.0|Excellent
|
||||||
|
0.85|Excellent
|
||||||
|
0.6|Good
|
||||||
|
0.4|Fair
|
||||||
|
0.1|Weak
|
||||||
|
0.0|No signal
|
||||||
|
CASES
|
||||||
|
|
||||||
|
# If a network is connected, it must not be described as the weakest possible
|
||||||
|
# thing -- that was the visible symptom of reading the ratio as a percentage.
|
||||||
|
active_ssid="$(jq -r .activeSsid <<<"$state")"
|
||||||
|
if [[ -n "$active_ssid" ]]; then
|
||||||
|
strength="$(jq -r .activeStrength <<<"$state")"
|
||||||
|
awk -v s="$strength" 'BEGIN { exit !(s >= 0 && s <= 1) }' \
|
||||||
|
|| fail "signalStrength $strength is outside 0.0-1.0; the label buckets assume a ratio"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Bluetooth ────────────────────────────────────────────────────────────────
|
||||||
|
if [[ "$(bluetoothctl list 2>/dev/null | wc -l)" -gt 0 ]]; then
|
||||||
|
[[ "$(jq -r .adapter <<<"$state")" == "true" ]] \
|
||||||
|
|| fail 'an adapter is present but the service did not find it'
|
||||||
|
fi
|
||||||
|
|
||||||
|
trap - EXIT
|
||||||
|
cleanup
|
||||||
|
printf 'connectivity contract: PASS\n'
|
||||||
Executable
+230
@@ -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'
|
||||||
Executable
+300
@@ -0,0 +1,300 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Display configuration.
|
||||||
|
#
|
||||||
|
# This is the only setting in Panama that can leave the user unable to SEE the
|
||||||
|
# screen well enough to undo it: a mode the panel cannot show, or a scale that
|
||||||
|
# makes everything unreadable, is not recoverable through the UI that caused it.
|
||||||
|
#
|
||||||
|
# So the property under test is not "can it change the resolution" but "does an
|
||||||
|
# unconfirmed change always come back". A regression here is not a broken
|
||||||
|
# feature, it is a user staring at a blank monitor.
|
||||||
|
#
|
||||||
|
# * an unconfirmed change reverts on its own, and stores nothing
|
||||||
|
# * a confirmed change is what writes to the settings store
|
||||||
|
# * a mode, scale, rotation, or output the compositor did not offer is refused
|
||||||
|
# before anything is applied
|
||||||
|
#
|
||||||
|
# The compositor is the live one -- there is no way to test this otherwise --
|
||||||
|
# but preferences are isolated, and every path restores the display it started
|
||||||
|
# from.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
harness="$repo_dir/config/dot/quickshell/displays-harness.qml"
|
||||||
|
service="$repo_dir/config/dot/quickshell/services/Displays.qml"
|
||||||
|
page="$repo_dir/config/dot/quickshell/modules/settings/DisplaysPage.qml"
|
||||||
|
settings_page="$repo_dir/config/dot/quickshell/modules/settings/SettingsPage.qml"
|
||||||
|
monitors_lua="$repo_dir/config/dot/hypr/monitors.lua"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'displays contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep is unavailable until compositor readback exactly matches the request.
|
||||||
|
for contract in \
|
||||||
|
'property var pendingRequested:' \
|
||||||
|
'property var revertExpected:' \
|
||||||
|
'property bool revertVerificationActive:' \
|
||||||
|
'property int revertGeneration:' \
|
||||||
|
'readonly property bool canConfirm:' \
|
||||||
|
'function matchesRequest(' \
|
||||||
|
'function scalesForMode(' \
|
||||||
|
'function isScaleClean('; do
|
||||||
|
rg -Fq "$contract" "$service" || fail "display service contract is missing: $contract"
|
||||||
|
done
|
||||||
|
rg -Fq 'enabled: Displays.canConfirm' "$page" \
|
||||||
|
|| fail 'Keep is enabled before the display change is verified'
|
||||||
|
rg -Fq 'options: Displays.scalesForMode(' "$page" \
|
||||||
|
|| fail 'scale choices are not filtered for the active resolution'
|
||||||
|
rg -Fq 'property string selectedOutput:' "$page" \
|
||||||
|
|| fail 'connected outputs cannot be selected'
|
||||||
|
rg -Fq 'options: Displays.monitors.map(' "$page" \
|
||||||
|
|| fail 'the output selector is not populated from connected displays'
|
||||||
|
rg -Fq 'id: revertVerifyTimer' "$service" \
|
||||||
|
|| fail 'automatic restoration has no bounded readback verification'
|
||||||
|
rg -Fq 'if (root.busy)' "$service" \
|
||||||
|
|| fail 'the display service accepts a new apply while another operation is busy'
|
||||||
|
|
||||||
|
# Stored JSON is untyped at field level, so the Lua startup consumer is the
|
||||||
|
# final validation boundary and must support every named output it accepts.
|
||||||
|
for contract in 'valid_mode' 'valid_scale' 'valid_transform' 'pairs(displays)'; do
|
||||||
|
rg -Fq "$contract" "$monitors_lua" || fail "monitor startup validation is missing: $contract"
|
||||||
|
done
|
||||||
|
|
||||||
|
# SettingsPage headers are genuinely pinned outside its scrolling surface.
|
||||||
|
python3 - "$settings_page" <<'PY' || fail 'SettingsPage header is not pinned outside the Flickable'
|
||||||
|
import sys
|
||||||
|
text = open(sys.argv[1], encoding="utf-8").read()
|
||||||
|
loader = text.find("id: pinnedHeader")
|
||||||
|
flickable = text.find("id: pageScroll")
|
||||||
|
if loader < 0 or flickable < 0 or loader > flickable:
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
MONITORS_LUA="$monitors_lua" lua - <<'LUA' || fail 'monitor startup accepted invalid persisted geometry or ignored a named output'
|
||||||
|
package.preload["prefs"] = function()
|
||||||
|
return {
|
||||||
|
get = function()
|
||||||
|
return {
|
||||||
|
["DP-2"] = { mode = "not-a-mode", scale = -1, transform = 99 },
|
||||||
|
["HDMI-A-1"] = { mode = "1920x1080@60", scale = 1.5, transform = 1 },
|
||||||
|
["BAD OUTPUT"] = { mode = "1920x1080@60", scale = 1, transform = 0 },
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local calls = {}
|
||||||
|
hl = { monitor = function(value) table.insert(calls, value) end }
|
||||||
|
assert(loadfile(os.getenv("MONITORS_LUA")))()
|
||||||
|
|
||||||
|
local by_output = {}
|
||||||
|
for _, value in ipairs(calls) do by_output[value.output] = value end
|
||||||
|
assert(by_output["DP-2"].mode == "4500x3000@60")
|
||||||
|
assert(by_output["DP-2"].scale == 1.5)
|
||||||
|
assert(by_output["DP-2"].transform == 0)
|
||||||
|
assert(by_output["HDMI-A-1"].mode == "1920x1080@60")
|
||||||
|
assert(by_output["HDMI-A-1"].scale == 1.5)
|
||||||
|
assert(by_output["HDMI-A-1"].transform == 1)
|
||||||
|
assert(by_output["BAD OUTPUT"] == nil)
|
||||||
|
assert(by_output[""] ~= nil)
|
||||||
|
LUA
|
||||||
|
|
||||||
|
if [[ "${PANAMA_DISPLAYS_STATIC_ONLY:-0}" == "1" ]]; then
|
||||||
|
printf 'displays contract: PASS (static)\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
config_home="$(mktemp -d /tmp/panama-displays-config.XXXXXX)"
|
||||||
|
|
||||||
|
run() { XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"; }
|
||||||
|
status() { run ipc call displays-test status; }
|
||||||
|
|
||||||
|
original_mode=""
|
||||||
|
original_scale=""
|
||||||
|
original_transform=""
|
||||||
|
original_width=""
|
||||||
|
original_height=""
|
||||||
|
original_refresh=""
|
||||||
|
monitor_name=""
|
||||||
|
|
||||||
|
monitor_state() {
|
||||||
|
hyprctl -j monitors | jq -c --arg output "$monitor_name" '.[] | select(.name == $output)'
|
||||||
|
}
|
||||||
|
|
||||||
|
display_is_restored() {
|
||||||
|
local current
|
||||||
|
current="$(monitor_state)"
|
||||||
|
[[ -n "$current" ]] || return 1
|
||||||
|
jq -e \
|
||||||
|
--argjson width "$original_width" \
|
||||||
|
--argjson height "$original_height" \
|
||||||
|
--argjson refresh "$original_refresh" \
|
||||||
|
--argjson scale "$original_scale" \
|
||||||
|
--argjson transform "$original_transform" \
|
||||||
|
'.width == $width and .height == $height
|
||||||
|
and ((.refreshRate - $refresh) | fabs) < 0.01
|
||||||
|
and ((.scale - $scale) | fabs) < 0.001
|
||||||
|
and .transform == $transform' <<<"$current" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_display() {
|
||||||
|
[[ -n "$original_mode" ]] || return 0
|
||||||
|
hyprctl eval "hl.monitor({ output = \"$monitor_name\", mode = \"$original_mode\", scale = $original_scale, transform = $original_transform })" >/dev/null \
|
||||||
|
|| return 1
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
display_is_restored && return 0
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_harness() {
|
||||||
|
# Kill by PID, never `pkill -f displays-harness`: that pattern also matches
|
||||||
|
# any shell whose command line contains this script's text, which includes
|
||||||
|
# the invoking shell itself.
|
||||||
|
[[ -n "${harness_pid:-}" ]] && kill "$harness_pid" >/dev/null 2>&1 || true
|
||||||
|
rm -rf "$config_home"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT
|
||||||
|
if ! restore_display; then
|
||||||
|
printf 'displays contract: FAILED to restore %s to %s scale %s transform %s\n' \
|
||||||
|
"$monitor_name" "$original_mode" "$original_scale" "$original_transform" >&2
|
||||||
|
status=1
|
||||||
|
fi
|
||||||
|
stop_harness
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
|
||||||
|
harness_pid=""
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
run ipc show 2>/dev/null | rg -q '^target displays-test$' && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
run ipc show 2>/dev/null | rg -q '^target displays-test$' || fail 'test IPC target did not start'
|
||||||
|
harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')"
|
||||||
|
|
||||||
|
refresh_fixture="$(run ipc call displays-test refreshIdentityFixture)"
|
||||||
|
jq -e '
|
||||||
|
.count == 2
|
||||||
|
and .modes == ["[email protected]", "[email protected]"]
|
||||||
|
and .selected == ["[email protected]"]
|
||||||
|
' <<<"$refresh_fixture" >/dev/null \
|
||||||
|
|| fail "59.94 Hz and 60.00 Hz lost their distinct selection identity: $refresh_fixture"
|
||||||
|
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
[[ "$(status | jq -r .count)" != "0" ]] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
state="$(status)"
|
||||||
|
monitor_name="$(jq -r .name <<<"$state")"
|
||||||
|
[[ -n "$monitor_name" ]] || fail "no display was detected: $state"
|
||||||
|
original_mode="$(jq -r .mode <<<"$state")"
|
||||||
|
original_width="$(jq -r .width <<<"$state")"
|
||||||
|
original_height="$(jq -r .height <<<"$state")"
|
||||||
|
original_refresh="$(jq -r .refresh <<<"$state")"
|
||||||
|
original_scale="$(jq -r .scale <<<"$state")"
|
||||||
|
original_transform="$(jq -r .transform <<<"$state")"
|
||||||
|
|
||||||
|
[[ "$(jq -r .modes <<<"$state")" -gt 0 ]] || fail 'the display reported no usable modes'
|
||||||
|
|
||||||
|
# ── Anything the compositor did not offer is refused before applying ─────────
|
||||||
|
while IFS= read -r kind; do
|
||||||
|
[[ "$(run ipc call displays-test applyBad "$kind")" == "false" ]] \
|
||||||
|
|| fail "an invalid $kind was accepted"
|
||||||
|
[[ "$(status | jq -r .awaiting)" == "false" ]] \
|
||||||
|
|| fail "an invalid $kind left a change pending"
|
||||||
|
done <<'KINDS'
|
||||||
|
mode
|
||||||
|
scale
|
||||||
|
transform
|
||||||
|
output
|
||||||
|
dirtyScale
|
||||||
|
KINDS
|
||||||
|
|
||||||
|
# The display must not have moved for any of those.
|
||||||
|
now="$(status)"
|
||||||
|
[[ "$(jq -r .scale <<<"$now")" == "$original_scale" ]] || fail 'a refused change still altered the scale'
|
||||||
|
|
||||||
|
# An immediate Revert may race both the apply process and its first readback.
|
||||||
|
# It must queue until both are clear, then verify the original generation.
|
||||||
|
target_scale=$(awk -v s="$original_scale" 'BEGIN { print (s == 1.25) ? 1.5 : 1.25 }')
|
||||||
|
[[ "$(run ipc call displays-test applyScale "$target_scale")" == "true" ]] \
|
||||||
|
|| fail 'the immediate-revert fixture could not apply'
|
||||||
|
run ipc call displays-test revertChange >/dev/null
|
||||||
|
immediate_reverted=false
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if display_is_restored && [[ "$(status | jq -r .awaiting)" == "false" ]]; then
|
||||||
|
immediate_reverted=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
[[ "$immediate_reverted" == true ]] \
|
||||||
|
|| fail 'an immediate Revert raced the apply/readback and did not restore the display'
|
||||||
|
|
||||||
|
# ── An unconfirmed change reverts on its own and stores nothing ──────────────
|
||||||
|
[[ "$(run ipc call displays-test applyScale "$target_scale")" == "true" ]] \
|
||||||
|
|| fail 'a valid scale change was refused'
|
||||||
|
|
||||||
|
applied=false
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
[[ "$(monitor_state | jq -r '.scale')" == "$target_scale" ]] && { applied=true; break; }
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
[[ "$applied" == true ]] || fail 'the scale change never reached the compositor'
|
||||||
|
[[ "$(status | jq -r .awaiting)" == "true" ]] || fail 'an applied change is not awaiting confirmation'
|
||||||
|
[[ "$(status | jq -r .canConfirm)" == "true" ]] || fail 'an applied change was never verified by compositor readback'
|
||||||
|
|
||||||
|
# Wait out the countdown. This is the whole point of the contract.
|
||||||
|
reverted=false
|
||||||
|
for _ in $(seq 1 120); do
|
||||||
|
if [[ "$(monitor_state | jq -r '.scale')" == "$original_scale" ]]; then
|
||||||
|
reverted=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
[[ "$reverted" == true ]] || fail 'an unconfirmed change did NOT revert -- this would strand a user on an unreadable display'
|
||||||
|
[[ "$(status | jq -r .awaiting)" == "false" ]] || fail 'the pending state survived the revert'
|
||||||
|
[[ "$(status | jq -r .overridden)" == "false" ]] || fail 'an unconfirmed change was written to the settings store'
|
||||||
|
|
||||||
|
# ── A confirmed change is what writes ────────────────────────────────────────
|
||||||
|
run ipc call displays-test applyScale "$target_scale" >/dev/null
|
||||||
|
[[ "$(run ipc call displays-test confirmChange)" == "false" ]] \
|
||||||
|
|| fail 'Keep accepted a display change before compositor readback'
|
||||||
|
verified=false
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
[[ "$(status | jq -r .canConfirm)" == "true" ]] && { verified=true; break; }
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
[[ "$verified" == true ]] || fail 'the confirmed change never became safe to keep'
|
||||||
|
[[ "$(run ipc call displays-test confirmChange)" == "true" ]] \
|
||||||
|
|| fail 'Keep refused a verified display change'
|
||||||
|
sleep 0.6
|
||||||
|
[[ "$(status | jq -r .awaiting)" == "false" ]] || fail 'confirming did not clear the pending state'
|
||||||
|
[[ "$(status | jq -r .overridden)" == "true" ]] || fail 'confirming did not store the change'
|
||||||
|
|
||||||
|
store="$config_home/panama/settings.json"
|
||||||
|
jq -e --arg m "$monitor_name" '.displays[$m].scale != null' "$store" >/dev/null \
|
||||||
|
|| fail 'the confirmed change is not in the settings store'
|
||||||
|
|
||||||
|
# ── Forgetting clears it ─────────────────────────────────────────────────────
|
||||||
|
run ipc call displays-test forget >/dev/null
|
||||||
|
sleep 0.6
|
||||||
|
[[ "$(status | jq -r .overridden)" == "false" ]] || fail 'forget did not clear the stored display setting'
|
||||||
|
|
||||||
|
restore_display || fail 'the final cleanup could not restore and verify the original display'
|
||||||
|
original_mode=""
|
||||||
|
stop_harness
|
||||||
|
trap - EXIT
|
||||||
|
printf 'displays contract: PASS\n'
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Persisted Fixture App
|
||||||
|
Exec=/usr/bin/true
|
||||||
|
Icon=applications-system
|
||||||
+217
@@ -0,0 +1,217 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
helper="$repo_dir/config/dot/quickshell/scripts/panama-home-assistant-config"
|
||||||
|
service="$repo_dir/config/dot/quickshell/services/HomeAssistantConfig.qml"
|
||||||
|
page="$repo_dir/config/dot/quickshell/modules/settings/HomePhonePage.qml"
|
||||||
|
password_field="$repo_dir/config/dot/quickshell/modules/settings/PasswordField.qml"
|
||||||
|
harness_fixture="$repo_dir/tests/quickshell/HomeAssistantConfigHarness.qml"
|
||||||
|
work="$(mktemp -d /tmp/panama-ha-config.XXXXXX)"
|
||||||
|
env_file="$work/env"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'Home Assistant config contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if declare -F qs_for_test >/dev/null; then
|
||||||
|
qs_for_test kill >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
rm -rf "$work"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
[[ -x "$helper" ]] || fail 'credential helper is missing or not executable'
|
||||||
|
[[ -f "$service" ]] || fail 'credential service is missing'
|
||||||
|
[[ -f "$harness_fixture" ]] || fail 'credential runtime harness is missing'
|
||||||
|
|
||||||
|
rg -Fq 'stdinEnabled: true' "$service" || fail 'credential writes do not use process stdin'
|
||||||
|
rg -Fq 'writeProc.write(root.pendingPayload + "\n")' "$service" || fail 'credential payload is not written over stdin'
|
||||||
|
rg -Fq 'root.pendingPayload = ""' "$service" || fail 'credential payload remains in service memory after write'
|
||||||
|
if rg -q 'command:.*(token|pendingPayload)' "$service"; then
|
||||||
|
fail 'credential data can reach a process command line'
|
||||||
|
fi
|
||||||
|
rg -Fq 'PasswordField {' "$page" || fail 'Home Assistant token is not entered through the masked field'
|
||||||
|
rg -Fq 'activeFocusOnTab: true' "$password_field" || fail 'masked credential field is not keyboard reachable'
|
||||||
|
rg -Fq 'HomeAssistantConfig.save(' "$page" || fail 'Home Assistant configuration cannot be saved from Settings'
|
||||||
|
rg -Fq 'HomeAssistantConfig.clearToken()' "$page" || fail 'stored Home Assistant token cannot be cleared'
|
||||||
|
rg -Fq 'id: clearTokenButton' "$page" || fail 'clear-token action has no keyboard control identity'
|
||||||
|
rg -Fq 'id: saveHomeConfigButton' "$page" || fail 'save action has no keyboard control identity'
|
||||||
|
rg -Fq 'activeFocusOnTab: enabled' "$page" || fail 'credential actions are not in tab order'
|
||||||
|
rg -Fq 'Keys.onReturnPressed:' "$page" || fail 'credential actions have no keyboard activation'
|
||||||
|
|
||||||
|
cat >"$env_file" <<'EOF'
|
||||||
|
# Existing private shell settings must survive byte-for-byte.
|
||||||
|
export KEEP_ME='untouched value'
|
||||||
|
export JIRA_CREDENTIALS='unrelated-secret'
|
||||||
|
export PANAMA_HOME_ASSISTANT_URL='https://old.example.test'
|
||||||
|
export PANAMA_HOME_ASSISTANT_TOKEN='old-token'
|
||||||
|
export PANAMA_HOME_ASSISTANT_ENTITIES='light.old'
|
||||||
|
EOF
|
||||||
|
chmod 0644 "$env_file"
|
||||||
|
|
||||||
|
run_helper() {
|
||||||
|
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" "$helper" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
status="$(run_helper status)" || fail 'status failed for a valid private env file'
|
||||||
|
jq -e '.configured == true and .tokenConfigured == true
|
||||||
|
and .url == "https://old.example.test"
|
||||||
|
and .entities == ["light.old"] and (has("token") | not)' \
|
||||||
|
<<<"$status" >/dev/null || fail "status exposed or misread credentials: $status"
|
||||||
|
if rg -q 'old-token|unrelated-secret' <<<"$status"; then
|
||||||
|
fail 'status output leaked a secret'
|
||||||
|
fi
|
||||||
|
|
||||||
|
secret='ha-secret-must-never-appear-in-ps-or-output'
|
||||||
|
payload="$work/payload.json"
|
||||||
|
jq -cn --arg token "$secret" '{
|
||||||
|
url: "https://home.example.test/",
|
||||||
|
token: $token,
|
||||||
|
entities: ["light.kitchen", "light.desk", "light.kitchen"]
|
||||||
|
}' >"$payload"
|
||||||
|
|
||||||
|
# Keep stdin open long enough to prove the token is absent from the helper's
|
||||||
|
# process arguments. The secret lives only in the private payload file/stdin.
|
||||||
|
fifo="$work/input.fifo"
|
||||||
|
mkfifo "$fifo"
|
||||||
|
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" "$helper" write <"$fifo" >"$work/write.out" 2>"$work/write.err" &
|
||||||
|
helper_pid=$!
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
kill -0 "$helper_pid" 2>/dev/null && break
|
||||||
|
sleep 0.05
|
||||||
|
done
|
||||||
|
if ps -o args= -p "$helper_pid" | rg -Fq "$secret"; then
|
||||||
|
fail 'token appeared in the credential helper process arguments'
|
||||||
|
fi
|
||||||
|
cp "$payload" "$fifo"
|
||||||
|
wait "$helper_pid" || fail 'stdin credential write failed'
|
||||||
|
|
||||||
|
write_result="$(cat "$work/write.out")"
|
||||||
|
jq -e '.ok == true and .configured == true and .tokenConfigured == true
|
||||||
|
and .url == "https://home.example.test"
|
||||||
|
and .entities == ["light.kitchen", "light.desk"] and (has("token") | not)' \
|
||||||
|
<<<"$write_result" >/dev/null || fail "write returned unsafe or incorrect state: $write_result"
|
||||||
|
if rg -q "$secret|old-token|unrelated-secret" "$work/write.out" "$work/write.err"; then
|
||||||
|
fail 'credential helper output leaked a secret'
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ "$(stat -c '%a' "$env_file")" == "600" ]] || fail 'private env file is not mode 0600'
|
||||||
|
rg -Fxq "export KEEP_ME='untouched value'" "$env_file" || fail 'unrelated env content changed'
|
||||||
|
rg -Fxq "export JIRA_CREDENTIALS='unrelated-secret'" "$env_file" || fail 'unrelated secret changed'
|
||||||
|
rg -Fq "$secret" "$env_file" || fail 'new token was not stored'
|
||||||
|
|
||||||
|
# Omitting token preserves it; an explicit empty token clears it.
|
||||||
|
printf '%s\n' '{"url":"https://new.example.test","entities":"light.office, light.hall"}' \
|
||||||
|
| run_helper write >/dev/null || fail 'non-secret update failed'
|
||||||
|
rg -Fq "$secret" "$env_file" || fail 'blank token field unexpectedly erased the stored token'
|
||||||
|
|
||||||
|
printf '%s\n' '{"token":""}' | run_helper write >/dev/null || fail 'token clear failed'
|
||||||
|
cleared="$(run_helper status)"
|
||||||
|
jq -e '.configured == false and .tokenConfigured == false
|
||||||
|
and .url == "https://new.example.test"
|
||||||
|
and .entities == ["light.office", "light.hall"]' \
|
||||||
|
<<<"$cleared" >/dev/null || fail "cleared state is wrong: $cleared"
|
||||||
|
|
||||||
|
before_hash="$(sha256sum "$env_file" | cut -d' ' -f1)"
|
||||||
|
printf '%s\n' '{"url":"file:///etc/passwd"}' | run_helper write >/dev/null 2>&1 \
|
||||||
|
&& fail 'invalid URL was accepted'
|
||||||
|
after_hash="$(sha256sum "$env_file" | cut -d' ' -f1)"
|
||||||
|
[[ "$before_hash" == "$after_hash" ]] || fail 'rejected input still modified the private env file'
|
||||||
|
|
||||||
|
# Exercise the actual QML Process.write() boundary with a pre-existing token.
|
||||||
|
# The IPC carries only non-secret fields; the helper must preserve the token.
|
||||||
|
config_path="$work/quickshell"
|
||||||
|
harness="$config_path/home-assistant-config-harness.qml"
|
||||||
|
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
|
||||||
|
cp "$harness_fixture" "$harness"
|
||||||
|
printf '%s\n' \
|
||||||
|
"export PANAMA_HOME_ASSISTANT_URL='https://qml-old.example.test'" \
|
||||||
|
"export PANAMA_HOME_ASSISTANT_TOKEN=''" \
|
||||||
|
"export PANAMA_HOME_ASSISTANT_ENTITIES='light.old'" >"$env_file"
|
||||||
|
chmod 0600 "$env_file"
|
||||||
|
qml_token_file="$work/qml-token"
|
||||||
|
printf '%s' 'qml-private-token' >"$qml_token_file"
|
||||||
|
chmod 0600 "$qml_token_file"
|
||||||
|
|
||||||
|
qs_for_test() {
|
||||||
|
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" \
|
||||||
|
PANAMA_TEST_TOKEN_FILE="$qml_token_file" \
|
||||||
|
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" \
|
||||||
|
qs -p "$harness" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_harness() {
|
||||||
|
qs_for_test kill >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
qs_for_test --daemonize >/dev/null
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
qs_for_test ipc show 2>/dev/null | rg -q '^target home-assistant-config-test$' && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
qs_for_test ipc show 2>/dev/null | rg -q '^target home-assistant-config-test$' \
|
||||||
|
|| fail 'credential QML harness did not start'
|
||||||
|
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
|
||||||
|
jq -e '.busy == false and .url == "https://qml-old.example.test"' <<<"$qml_status" >/dev/null && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
jq -e '.configured == false and .tokenConfigured == false and .pendingPayloadEmpty == true' \
|
||||||
|
<<<"$qml_status" >/dev/null || fail "QML service did not load redacted state: $qml_status"
|
||||||
|
|
||||||
|
qs_for_test ipc call home-assistant-config-test saveWithToken \
|
||||||
|
https://qml-new.example.test 'light.office,light.hall' >/dev/null \
|
||||||
|
|| fail 'QML service refused a private token-file update'
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
|
||||||
|
jq -e '.busy == false and .configured == true and .tokenConfigured == true
|
||||||
|
and .refreshCalls > 0' <<<"$qml_status" >/dev/null && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
jq -e '.configured == true and .tokenConfigured == true
|
||||||
|
and .url == "https://qml-new.example.test"
|
||||||
|
and .entities == ["light.office", "light.hall"]
|
||||||
|
and .pendingPayloadEmpty == true and .lastError == "" and .refreshCalls > 0' \
|
||||||
|
<<<"$qml_status" >/dev/null || fail "QML secret stdin save did not settle safely: $qml_status"
|
||||||
|
rg -Fq 'qml-private-token' "$env_file" || fail 'QML secret stdin save did not store the token'
|
||||||
|
if ps -o args= -p "$(qs_for_test list | awk '/Process ID:/ {print $3; exit}')" | rg -Fq 'qml-private-token'; then
|
||||||
|
fail 'QML token appeared in the shell process arguments'
|
||||||
|
fi
|
||||||
|
|
||||||
|
qs_for_test ipc call home-assistant-config-test save \
|
||||||
|
https://qml-final.example.test 'light.bedroom,light.hall' >/dev/null \
|
||||||
|
|| fail 'QML service refused a non-secret update'
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
|
||||||
|
jq -e '.busy == false and .url == "https://qml-final.example.test"
|
||||||
|
and .entities == ["light.bedroom", "light.hall"]' <<<"$qml_status" >/dev/null && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
jq -e '.configured == true and .tokenConfigured == true
|
||||||
|
and .pendingPayloadEmpty == true and .lastError == ""' \
|
||||||
|
<<<"$qml_status" >/dev/null || fail "QML stdin save did not settle safely: $qml_status"
|
||||||
|
rg -Fq 'qml-private-token' "$env_file" || fail 'QML non-secret save erased the stored token'
|
||||||
|
|
||||||
|
qs_for_test ipc call home-assistant-config-test clearToken >/dev/null \
|
||||||
|
|| fail 'QML service refused token clear'
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
|
||||||
|
jq -e '.busy == false and .tokenConfigured == false' <<<"$qml_status" >/dev/null && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
jq -e '.configured == false and .tokenConfigured == false
|
||||||
|
and .pendingPayloadEmpty == true and .lastError == ""' \
|
||||||
|
<<<"$qml_status" >/dev/null || fail "QML token clear did not settle safely: $qml_status"
|
||||||
|
if rg -Fq 'qml-private-token' "$env_file"; then
|
||||||
|
fail 'QML token clear left the old token in the private env file'
|
||||||
|
fi
|
||||||
|
stop_harness
|
||||||
|
|
||||||
|
trap - EXIT
|
||||||
|
cleanup
|
||||||
|
printf 'Home Assistant config contract: PASS\n'
|
||||||
@@ -13,6 +13,20 @@ helper="$project_root/config/dot/quickshell/scripts/panama-home-assistant"
|
|||||||
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
|
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
|
||||||
|
|
||||||
catalog="$($helper catalog)"
|
catalog="$($helper catalog)"
|
||||||
|
|
||||||
|
# This asserts a LIVE, authenticated Home Assistant. An absent credential is not
|
||||||
|
# a defect in Panama, so it skips rather than fails -- otherwise the suite is red
|
||||||
|
# on any machine that has not been given a token, and a red suite that is
|
||||||
|
# expected to be red stops being read.
|
||||||
|
#
|
||||||
|
# A configured-but-broken bridge still fails, which is the case worth catching.
|
||||||
|
if [[ "$(jq -r '.configured' <<<"$catalog")" != "true" ]]; then
|
||||||
|
printf 'Home Assistant helper contract: SKIP (no token configured)\n'
|
||||||
|
printf ' Set PANAMA_HOME_ASSISTANT_TOKEN in config/bash/env to exercise this.\n'
|
||||||
|
printf ' Reason reported by the helper: %s\n' "$(jq -r '.error // "unknown"' <<<"$catalog")"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
jq -e '
|
jq -e '
|
||||||
.ok == true and .configured == true and .error == "" and
|
.ok == true and .configured == true and .error == "" and
|
||||||
(.entities | type == "array" and length > 0) and
|
(.entities | type == "array" and length > 0) and
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -101,13 +105,21 @@ assert_contains 'Opens BlueBubbles' "$home_page"
|
|||||||
if rg -Fq 'index: model.index' "$home_page"; then
|
if rg -Fq 'index: model.index' "$home_page"; then
|
||||||
fail 'HomePhonePage.qml reads an undefined model.index instead of the delegate index'
|
fail 'HomePhonePage.qml reads an undefined model.index instead of the delegate index'
|
||||||
fi
|
fi
|
||||||
if rg -qi 'token|bearer|api/states' "$home_page"; then
|
if rg -qi 'bearer|api/states' "$home_page"; then
|
||||||
fail 'HomePhonePage.qml crosses the credential or REST privacy boundary'
|
fail 'HomePhonePage.qml crosses the Home Assistant REST boundary'
|
||||||
fi
|
fi
|
||||||
assert_contains 'signal aliasCommitted(string id, string alias)' "$favorite_card"
|
assert_contains 'signal aliasCommitted(string id, string alias)' "$favorite_card"
|
||||||
assert_contains 'signal removeRequested(string id)' "$favorite_card"
|
assert_contains 'signal removeRequested(string id)' "$favorite_card"
|
||||||
assert_contains 'signal moveRequested(string id, int targetIndex)' "$favorite_card"
|
assert_contains 'signal moveRequested(string id, int targetIndex)' "$favorite_card"
|
||||||
assert_contains 'DragHandler {' "$favorite_card"
|
assert_contains 'text: "↑"' "$favorite_card"
|
||||||
|
assert_contains 'text: "↓"' "$favorite_card"
|
||||||
|
assert_contains 'enabled: root.canMoveEarlier' "$favorite_card"
|
||||||
|
assert_contains 'enabled: root.canMoveLater' "$favorite_card"
|
||||||
|
if rg -Fq 'DragHandler {' "$favorite_card"; then
|
||||||
|
fail 'Home light cards still expose the broken drag affordance'
|
||||||
|
fi
|
||||||
|
assert_contains 'canMoveEarlier: index > 0' "$home_page"
|
||||||
|
assert_contains 'canMoveLater: index < favoritesGrid.count - 1' "$home_page"
|
||||||
assert_contains 'onEditingFinished:' "$favorite_card"
|
assert_contains 'onEditingFinished:' "$favorite_card"
|
||||||
assert_contains 'text: "Control Center"' "$favorite_card"
|
assert_contains 'text: "Control Center"' "$favorite_card"
|
||||||
assert_contains 'activeFocusOnTab: true' "$favorite_card"
|
assert_contains 'activeFocusOnTab: true' "$favorite_card"
|
||||||
|
|||||||
@@ -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,15 +82,50 @@ 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"]'
|
||||||
expected='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}],"saveError":""}'
|
expected='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}],"saveError":""}'
|
||||||
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"}]}'
|
||||||
|
reordered_expected='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"},{"id":"light.hall","alias":""}],"saveError":""}'
|
||||||
|
reordered_file='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"},{"id":"light.hall","alias":""}]}'
|
||||||
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 alias light.kitchen ' Island ' >/dev/null
|
||||||
|
qs_for_harness ipc call home-pref-test move light.desk 0 >/dev/null
|
||||||
|
wait_for_status "$reordered_expected"
|
||||||
|
wait_for_file_content "$reordered_file"
|
||||||
|
stop_harness
|
||||||
|
start_harness
|
||||||
|
wait_for_status "$reordered_expected"
|
||||||
|
|
||||||
|
qs_for_harness ipc call home-pref-test remove light.hall >/dev/null
|
||||||
|
wait_for_status "$expected"
|
||||||
|
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
|
||||||
|
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 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
|
||||||
qs_for_harness ipc call home-pref-test move light.desk 0 >/dev/null
|
qs_for_harness ipc call home-pref-test move light.desk 0 >/dev/null
|
||||||
@@ -99,10 +133,6 @@ 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"
|
||||||
|
|
||||||
stop_harness
|
|
||||||
start_harness
|
|
||||||
wait_for_status "$expected"
|
|
||||||
|
|
||||||
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
|
||||||
wait_for_status "$empty_expected"
|
wait_for_status "$empty_expected"
|
||||||
|
|||||||
Executable
+117
@@ -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'
|
||||||
Executable
+80
@@ -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"
|
||||||
+234
@@ -0,0 +1,234 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
service="$repo_dir/config/dot/quickshell/services/Notifs.qml"
|
||||||
|
page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml"
|
||||||
|
harness_fixture="$repo_dir/tests/quickshell/NotificationAppRulesHarness.qml"
|
||||||
|
desktop_entry_fixture="$repo_dir/tests/quickshell/fixtures/org.persist.App.desktop"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'notification application rules contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -f "$service" ]] || fail 'notification service is missing'
|
||||||
|
[[ -f "$page" ]] || fail 'notification settings page is missing'
|
||||||
|
[[ -f "$harness_fixture" ]] || fail 'runtime harness fixture is missing'
|
||||||
|
[[ -f "$desktop_entry_fixture" ]] || fail 'runtime desktop entry fixture is missing'
|
||||||
|
|
||||||
|
SERVICE_PATH="$service" PAGE_PATH="$page" bun -e '
|
||||||
|
const source = await Bun.file(process.env.SERVICE_PATH).text();
|
||||||
|
const page = await Bun.file(process.env.PAGE_PATH).text();
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
console.error(`notification application rules contract: ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function functionBody(name) {
|
||||||
|
const start = source.indexOf(`function ${name}(`);
|
||||||
|
if (start === -1)
|
||||||
|
fail(`missing ${name}()`);
|
||||||
|
const open = source.indexOf("{", start);
|
||||||
|
let depth = 0;
|
||||||
|
for (let index = open; index < source.length; index++) {
|
||||||
|
if (source[index] === "{") depth++;
|
||||||
|
if (source[index] === "}" && --depth === 0)
|
||||||
|
return source.slice(open + 1, index);
|
||||||
|
}
|
||||||
|
fail(`${name}() is unterminated`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationAppId = Function("notification", functionBody("notificationAppId"));
|
||||||
|
const normalizedAppRule = Function("rule", functionBody("normalizedAppRule"));
|
||||||
|
|
||||||
|
const identityFixtures = [
|
||||||
|
{ notification: { desktopEntry: "org.signal.Signal.desktop", appName: "Signal" }, expected: "org.signal.Signal.desktop" },
|
||||||
|
{ notification: { desktopEntry: "", appName: "Terminal" }, expected: "Terminal" },
|
||||||
|
{ notification: { desktopEntry: "", appName: "" }, expected: "Notifications" }
|
||||||
|
];
|
||||||
|
for (const fixture of identityFixtures) {
|
||||||
|
const actual = notificationAppId(fixture.notification);
|
||||||
|
if (actual !== fixture.expected)
|
||||||
|
fail(`stable app identity expected ${fixture.expected}, got ${actual}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultRule = normalizedAppRule({});
|
||||||
|
if (JSON.stringify(defaultRule) !== JSON.stringify({ enabled: true, showOnLockScreen: true, showContentOnLockScreen: true }))
|
||||||
|
fail(`missing rule fields did not default safely: ${JSON.stringify(defaultRule)}`);
|
||||||
|
|
||||||
|
const explicitRule = normalizedAppRule({ enabled: false, showOnLockScreen: false, showContentOnLockScreen: false });
|
||||||
|
if (JSON.stringify(explicitRule) !== JSON.stringify({ enabled: false, showOnLockScreen: false, showContentOnLockScreen: false }))
|
||||||
|
fail(`explicit rule was not preserved: ${JSON.stringify(explicitRule)}`);
|
||||||
|
|
||||||
|
for (const required of ["rememberApplication", "appRule", "setAppRule", "handleNotification", "shouldShowOnLockScreen", "shouldShowContentOnLockScreen"]) {
|
||||||
|
functionBody(required);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = source.indexOf("function handleNotification(notification: var)");
|
||||||
|
const tracked = source.indexOf("notification.tracked = true", handler);
|
||||||
|
const muted = source.indexOf("!root.appRule(appId).enabled", handler);
|
||||||
|
if (handler === -1 || tracked === -1 || muted === -1 || muted > tracked)
|
||||||
|
fail("muted applications are not rejected before tracking/history/unread/toast work");
|
||||||
|
if (!source.includes("root.handleNotification(notification)"))
|
||||||
|
fail("NotificationServer does not delegate delivery to the callable handler");
|
||||||
|
if (!source.includes("PreferenceSchema.has(\"notificationAppRules\")"))
|
||||||
|
fail("the schema dependency is not explicit");
|
||||||
|
if (!source.includes("DesktopPreferences.get(\"notificationAppRules\")"))
|
||||||
|
fail("rules are not read through DesktopPreferences");
|
||||||
|
if (!source.includes("DesktopPreferences.set(\"notificationAppRules\", next)"))
|
||||||
|
fail("rules are not written through DesktopPreferences");
|
||||||
|
if (!source.includes("next[knownAppId] = root.appRule(knownAppId)"))
|
||||||
|
fail("persisted rules are not normalized to the required three-field shape");
|
||||||
|
if (!source.includes("root.fallbackAppRules = next"))
|
||||||
|
fail("missing-schema preference writes do not retain an in-memory fallback");
|
||||||
|
if (!source.includes("if (!root.doNotDisturb)"))
|
||||||
|
fail("global DND popup override was removed");
|
||||||
|
for (const required of [
|
||||||
|
"DesktopEntries.applications.values",
|
||||||
|
"DesktopEntries.byId(appId)",
|
||||||
|
"DesktopEntries.heuristicLookup(appId)"
|
||||||
|
]) {
|
||||||
|
if (!source.includes(required))
|
||||||
|
fail(`persisted desktop entry ids are not reactively resolved through ${required}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const required of [
|
||||||
|
"Notifs.applications",
|
||||||
|
"Notifs.appRule(app.id).enabled",
|
||||||
|
"showOnLockScreen",
|
||||||
|
"showContentOnLockScreen",
|
||||||
|
"Notifs.setAppRule"
|
||||||
|
]) {
|
||||||
|
if (!page.includes(required))
|
||||||
|
fail(`settings page is missing ${required}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("notification application rules contract: PASS");
|
||||||
|
'
|
||||||
|
|
||||||
|
state_home="$(mktemp -d /tmp/panama-notification-rules-state.XXXXXX)"
|
||||||
|
config_home="$(mktemp -d /tmp/panama-notification-rules-config.XXXXXX)"
|
||||||
|
data_home="$(mktemp -d /tmp/panama-notification-rules-data.XXXXXX)"
|
||||||
|
config_path="$state_home/quickshell"
|
||||||
|
harness="$config_path/notification-app-rules-harness.qml"
|
||||||
|
shell_log="$state_home/notification-app-rules.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "${bus_pid:-}" ]]; then
|
||||||
|
kill "$bus_pid" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
rm -rf "$state_home" "$config_home" "$data_home"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
|
||||||
|
cp "$harness_fixture" "$harness"
|
||||||
|
mkdir -p "$data_home/applications"
|
||||||
|
cp "$desktop_entry_fixture" "$data_home/applications/org.persist.App.desktop"
|
||||||
|
|
||||||
|
# This is intentionally a copy-local integration dependency. The production
|
||||||
|
# schema is Claude's change; the runtime contract proves persistence only once
|
||||||
|
# that key exists and never stages a schema edit from this branch.
|
||||||
|
perl -0pi -e 's@(\n // ── Capture)@\n {\n key: "notificationAppRules", type: "json", def: {}, group: "notifications", internal: true\n },$1@' \
|
||||||
|
"$config_path/config/PreferenceSchema.qml"
|
||||||
|
rg -q 'key: "notificationAppRules", type: "json"' "$config_path/config/PreferenceSchema.qml" \
|
||||||
|
|| fail 'temporary schema integration key was not installed'
|
||||||
|
|
||||||
|
mapfile -t dbus_info < <(dbus-daemon --session --fork --print-address=1 --print-pid=1)
|
||||||
|
bus_address="${dbus_info[0]:-}"
|
||||||
|
bus_pid="${dbus_info[1]:-}"
|
||||||
|
[[ -n "$bus_address" && "$bus_pid" =~ ^[0-9]+$ ]] || fail 'private D-Bus session did not start'
|
||||||
|
|
||||||
|
qs_for_test() {
|
||||||
|
DBUS_SESSION_BUS_ADDRESS="$bus_address" \
|
||||||
|
XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" \
|
||||||
|
XDG_DATA_HOME="$data_home" XDG_DATA_DIRS="$data_home" \
|
||||||
|
qs -p "$harness" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_harness() {
|
||||||
|
qs_for_test kill >/dev/null 2>&1 || true
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
! qs_for_test ipc show >/dev/null 2>&1 && return
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
fail 'isolated notification harness did not stop cleanly'
|
||||||
|
}
|
||||||
|
|
||||||
|
start_harness() {
|
||||||
|
qs_for_test --daemonize >"$shell_log" 2>&1
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
qs_for_test ipc show 2>/dev/null | rg -q '^target notification-app-rules-test$' && return
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
sed -n '1,240p' "$shell_log" >&2
|
||||||
|
fail 'isolated notification harness did not start'
|
||||||
|
}
|
||||||
|
|
||||||
|
start_harness
|
||||||
|
exercise="$(qs_for_test ipc call notification-app-rules-test exercise)"
|
||||||
|
jq -e '
|
||||||
|
.appId == "org.signal.Signal.desktop" and
|
||||||
|
.initialRules == {
|
||||||
|
"org.signal.Signal.desktop": {
|
||||||
|
enabled: true,
|
||||||
|
showOnLockScreen: true,
|
||||||
|
showContentOnLockScreen: true
|
||||||
|
}
|
||||||
|
} and
|
||||||
|
.muted == { tracked: false, history: 0, popups: 0, unread: 0 } and
|
||||||
|
.dnd == { tracked: true, history: 1, popups: 0, unread: 1 } and
|
||||||
|
.privacy == { visible: false, content: false } and
|
||||||
|
.fallback == {
|
||||||
|
id: "Fallback Terminal",
|
||||||
|
application: { id: "Fallback Terminal", name: "Fallback Terminal" }
|
||||||
|
}
|
||||||
|
' <<<"$exercise" >/dev/null || fail "runtime notification policy fixture failed: $exercise"
|
||||||
|
|
||||||
|
persisted="$(qs_for_test ipc call notification-app-rules-test persist)"
|
||||||
|
jq -e '. == {
|
||||||
|
"org.persist.App.desktop": {
|
||||||
|
enabled: false,
|
||||||
|
showOnLockScreen: true,
|
||||||
|
showContentOnLockScreen: false
|
||||||
|
}
|
||||||
|
}' <<<"$persisted" >/dev/null || fail "runtime persistence fixture wrote the wrong shape: $persisted"
|
||||||
|
|
||||||
|
settings_file="$config_home/panama/settings.json"
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
[[ -f "$settings_file" ]] && jq -e '.notificationAppRules["org.persist.App.desktop"].enabled == false' "$settings_file" >/dev/null && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[[ -f "$settings_file" ]] || fail 'runtime persistence fixture did not write settings.json'
|
||||||
|
|
||||||
|
wait_for_persisted_application() {
|
||||||
|
local expected="$1"
|
||||||
|
local applications=""
|
||||||
|
for _ in $(seq 1 80); do
|
||||||
|
applications="$(qs_for_test ipc call notification-app-rules-test applications)"
|
||||||
|
if jq -e '.[] | select(.id == "org.persist.App.desktop" and .name == "Persisted Fixture App")' \
|
||||||
|
<<<"$applications" >/dev/null; then
|
||||||
|
[[ "$applications" == *"$expected"* ]] && printf '%s' "$applications" && return
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
fail "persisted desktop entry did not resolve to a friendly name: $applications"
|
||||||
|
}
|
||||||
|
|
||||||
|
before_restart_applications="$(wait_for_persisted_application 'Persisted Fixture App')"
|
||||||
|
|
||||||
|
stop_harness
|
||||||
|
start_harness
|
||||||
|
restored="$(qs_for_test ipc call notification-app-rules-test restored)"
|
||||||
|
[[ "$restored" == "$persisted" ]] || fail "notification rules did not survive isolated restart: $restored"
|
||||||
|
after_restart_applications="$(wait_for_persisted_application 'Persisted Fixture App')"
|
||||||
|
[[ "$before_restart_applications" == *'"id":"org.persist.App.desktop","name":"Persisted Fixture App"'* ]] \
|
||||||
|
|| fail "persisted application name was wrong before restart: $before_restart_applications"
|
||||||
|
[[ "$after_restart_applications" == *'"id":"org.persist.App.desktop","name":"Persisted Fixture App"'* ]] \
|
||||||
|
|| fail "persisted application name was wrong after restart: $after_restart_applications"
|
||||||
|
stop_harness
|
||||||
|
|
||||||
|
printf 'notification application rules runtime contract: PASS\n'
|
||||||
+57
@@ -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"
|
||||||
Executable
+212
@@ -0,0 +1,212 @@
|
|||||||
|
#!/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,"displays":{"DP-2":{"mode":"3840x2160@60","scale":2,"transform":0}}}' >"$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')" == "3" ]] || fail 'snapshot key count is wrong'
|
||||||
|
|
||||||
|
printf '{"gapsOut":99,"displays":{"DP-2":{"mode":"4500x3000@60","scale":1.5,"transform":0}}}' >"$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 '.displays["DP-2"].scale' "$settings")" == "1.5" ]] \
|
||||||
|
|| fail 'restore bypassed display confirmation by applying snapshot geometry'
|
||||||
|
[[ "$(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 for ordinary preferences, but confirmed display
|
||||||
|
# geometry is protected state: it must survive even a Home-only snapshot.
|
||||||
|
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,"windowRounding":9,"displays":{"DP-2":{"mode":"4500x3000@60","scale":1.5,"transform":0}}}' >"$settings"
|
||||||
|
printf '{"initialized":false,"favorites":[]}' >"$home"
|
||||||
|
run restore "$desktop_absent_name" >/dev/null || fail 'Home-only snapshot restore failed'
|
||||||
|
[[ -e "$settings" ]] || fail 'Home-only restore discarded confirmed display geometry'
|
||||||
|
[[ "$(jq -r '.displays["DP-2"].scale' "$settings")" == "1.5" ]] \
|
||||||
|
|| fail 'Home-only restore changed confirmed display geometry'
|
||||||
|
[[ "$(jq 'keys == ["displays"]' "$settings")" == "true" ]] \
|
||||||
|
|| fail 'Home-only restore retained ordinary desktop preferences'
|
||||||
|
[[ "$(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'
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
#!/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();' \
|
||||||
|
'DesktopPreferences.set("displays", value);' \
|
||||||
|
'Displays.externalChangeBlocked = blocked;' \
|
||||||
|
'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",
|
||||||
|
"display.protect:{\"DP-2\":{\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0}}",
|
||||||
|
"system.apply",
|
||||||
|
"keybinds.reload",
|
||||||
|
"wallpaper.set:/tmp/restored-wallpaper.jpg",
|
||||||
|
"display.block:false",
|
||||||
|
"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",
|
||||||
|
"display.protect:{\"DP-2\":{\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0}}",
|
||||||
|
"system.apply",
|
||||||
|
"keybinds.reload",
|
||||||
|
"wallpaper.set:/tmp/restored-wallpaper.jpg",
|
||||||
|
"display.block:false",
|
||||||
|
"shell.reload"
|
||||||
|
]
|
||||||
|
and .initialized == false
|
||||||
|
and .favorites == []
|
||||||
|
' <<<"$status" >/dev/null || fail "absent Home handoff was wrong: $status"
|
||||||
|
|
||||||
|
# Restore refuses before launching the helper while a display apply/recovery is
|
||||||
|
# active, so no snapshot can race the confirmation boundary.
|
||||||
|
qs_test ipc call settings-backup-behavior reset >/dev/null
|
||||||
|
[[ "$(qs_test ipc call settings-backup-behavior restoreWhileDisplayBusy)" == "false" ]] \
|
||||||
|
|| fail 'snapshot restore started during an active display operation'
|
||||||
|
status="$(qs_test ipc call settings-backup-behavior status)"
|
||||||
|
jq -e '.calls == [] and (.lastError | contains("display change"))' <<<"$status" >/dev/null \
|
||||||
|
|| fail "display-busy restore refusal was not clean: $status"
|
||||||
|
|
||||||
|
trap - EXIT
|
||||||
|
cleanup
|
||||||
|
printf 'settings backup live contract: PASS\n'
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
#!/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"
|
||||||
|
wallpaper_service="$repo_dir/config/dot/quickshell/services/Wallpaper.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 harness replaces the compositor write seam as well,
|
||||||
|
# so interruption cannot leave the daily desktop modified.
|
||||||
|
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
|
||||||
|
rg -Fq 'root.protectDisplays(protectedDisplays)' "$system_settings" \
|
||||||
|
|| fail 'restoreDefaults can apply unconfirmed display geometry during reload'
|
||||||
|
rg -Fq 'Keybinds.applyReload();' "$system_settings" \
|
||||||
|
|| fail 'restoreDefaults does not replay shipped keybindings'
|
||||||
|
rg -Fq 'root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));' "$system_settings" \
|
||||||
|
|| fail 'restoreDefaults does not visibly reapply the shipped wallpaper'
|
||||||
|
rg -Fq 'const effectivePath = path === "" ? root.shippedPath : path;' "$wallpaper_service" \
|
||||||
|
|| fail 'clearing wallpaper preference leaves the old image visible'
|
||||||
|
rg -Fq 'property string storedValue:' "$wallpaper_service" \
|
||||||
|
|| fail 'the shipped wallpaper cannot remain represented by the default empty preference'
|
||||||
|
|
||||||
|
qs_for_harness() {
|
||||||
|
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
|
||||||
|
PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1 qs -p "$harness" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore() {
|
||||||
|
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" \
|
||||||
|
PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1 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 the verified apply boundary, then is stored ────
|
||||||
|
target_rounding=11
|
||||||
|
[[ "$(qs_for_harness ipc call settings-system-test commit windowRounding "$target_rounding")" == "true" ]] \
|
||||||
|
|| fail 'commitPreference refused a compositor key'
|
||||||
|
apply_state="$(qs_for_harness ipc call settings-system-test applyState)"
|
||||||
|
jq -e '.[-1].windowRounding == 11' <<<"$apply_state" >/dev/null \
|
||||||
|
|| fail "a compositor-backed commit did not reach the apply boundary: $apply_state"
|
||||||
|
[[ "$(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
|
||||||
|
display_fixture='{"DP-2":{"mode":"4500x3000@60","scale":1.5,"transform":0}}'
|
||||||
|
[[ "$(qs_for_harness ipc call settings-system-test commit displays "$display_fixture")" == "true" ]] \
|
||||||
|
|| fail 'the protected display fixture did not apply'
|
||||||
|
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)" == "true" ]] \
|
||||||
|
|| fail 'restoreDefaults refused a safe reset'
|
||||||
|
sleep 0.6
|
||||||
|
|
||||||
|
reset_state="$(qs_for_harness ipc call settings-system-test resetState)"
|
||||||
|
jq -e '.calls == [
|
||||||
|
"display.block:true",
|
||||||
|
"display.protect",
|
||||||
|
"keybinds.reload",
|
||||||
|
"wallpaper.set:",
|
||||||
|
"display.block:false"
|
||||||
|
] and .displayBlocked == false' <<<"$reset_state" >/dev/null \
|
||||||
|
|| fail "reset did not safely replay non-reactive state: $reset_state"
|
||||||
|
|
||||||
|
[[ "$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)" == "250" ]] \
|
||||||
|
|| fail 'reset did not restore a schema default'
|
||||||
|
[[ "$(qs_for_harness ipc call settings-system-test stored displays | jq -cS .)" == "$(jq -cS . <<<"$display_fixture")" ]] \
|
||||||
|
|| fail 'reset replaced confirmed display geometry without confirmation'
|
||||||
|
|
||||||
|
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 itself apply compositor policy, so the last
|
||||||
|
# isolated batch must contain the shipped default.
|
||||||
|
jq -e '.appliedBatches[-1].windowRounding == 18' <<<"$reset_state" >/dev/null \
|
||||||
|
|| fail "reset did not re-apply the compositor default: $reset_state"
|
||||||
|
|
||||||
|
trap - EXIT
|
||||||
|
restore
|
||||||
|
printf 'settings commit/reset contract: PASS\n'
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user