Author SHA1 Message Date
Gabriel Brown 63f4bdb547 Make display confirmation test deterministic 2026-08-18 15:32:59 -04:00
Gabriel Brown 1a93a788fa Verify settings search routing 2026-08-18 15:09:50 -04:00
Gabriel Brown 1debd37d01 Model rendered lock fallback in diagnostics 2026-08-18 15:05:12 -04:00
Gabriel Brown 4341628b2f Route pointer settings to Mouse 2026-08-18 15:02:41 -04:00
Gabriel Brown 7777f5c1aa Verify display position restoration 2026-08-18 15:01:49 -04:00
Gabriel Brown bc7bf3185c Test managed lock fallback integration 2026-08-18 15:00:54 -04:00
Gabriel Brown f3d91c14a1 Align managed lock screen palette 2026-08-18 15:00:19 -04:00
Gabriel Brown 21beb066e1 Integrate Phase 2 desktop settings 2026-08-18 15:00:19 -04:00
Gabriel Brown d2898d8806 Integrate complete display layouts 2026-08-18 15:00:18 -04:00
Gabriel Brown 0c3935f818 Add display identification overlays 2026-08-18 15:00:18 -04:00
Gabriel Brown 70a9d27c98 Add monitor arrangement canvas 2026-08-18 15:00:18 -04:00
Gabriel Brown 5cf2943ccf Apply monitor layouts transactionally 2026-08-18 15:00:18 -04:00
Gabriel Brown d0d9e196b0 Persist complete monitor layouts 2026-08-18 15:00:18 -04:00
Gabriel Brown 2613768efd Model multi-monitor layout geometry 2026-08-18 15:00:18 -04:00
Gabriel Brown 101797f8f0 Expose wallpaper output status 2026-08-18 15:00:18 -04:00
Gabriel Brown b9336d5930 Restore complete wallpaper policies 2026-08-18 15:00:18 -04:00
Gabriel Brown c36e423890 Add wallpaper mode controls 2026-08-18 15:00:18 -04:00
Gabriel Brown 1c12892252 Add event-driven wallpaper rotation 2026-08-18 15:00:18 -04:00
Gabriel Brown 52818b7290 Verify wallpaper policy application 2026-08-18 15:00:18 -04:00
Gabriel Brown c8004e400a Model wallpaper display policies 2026-08-18 15:00:18 -04:00
Gabriel Brown 3e98cc2216 Integrate managed lock screen recovery 2026-08-18 15:00:18 -04:00
Gabriel Brown a80a6f4dda Add lock screen appearance settings 2026-08-18 15:00:18 -04:00
Gabriel Brown 3520700983 Route session locking through Panama 2026-08-18 15:00:18 -04:00
Gabriel Brown a845534110 Generate managed lock screen configuration 2026-08-18 15:00:18 -04:00
Gabriel Brown db34b1e6ed Add application volume mixer 2026-08-18 15:00:18 -04:00
Gabriel Brown 0f3bddc452 Expose live application audio groups 2026-08-18 15:00:18 -04:00
Gabriel Brown 9aea519e54 Model live application audio streams 2026-08-18 15:00:18 -04:00
Gabriel Brown e5a2a430e0 Plan Phase 2 expectation gaps 2026-08-18 15:00:18 -04:00
Gabriel Brown bfa9c58b09 Design Phase 2 expectation gaps 2026-08-18 15:00:18 -04:00
Gabriel Brown 1360a80f07 Add a visible window switcher
Super+Tab already cycled windows, but nothing was drawn, so you chose
blind and could only confirm the choice by arriving. A visible switcher
is muscle memory for anyone arriving from macOS or GNOME, and it was the
last item of roadmap phase 03 that did not need coordination.

Ordered most-recently-used, not by creation, because that is what makes
the gesture useful: one Tab returns to the window you just came from.
Hyprland does not report an MRU order, so it is tracked from focus
changes and keyed by address, which is the only property stable for a
window's lifetime.

The gesture needs three binds rather than two. Tab steps the selection,
and the switch is committed on Super RELEASE -- the only way the
compositor can say the gesture is over. That bind is on the bare
modifier, so it fires on every Super release in the session; commit()
returns immediately when nothing is open, which is what makes it
affordable.

A list of names rather than thumbnails: at a glance you are looking for
"the other terminal", and a row of live previews is slower to read and
far more expensive to draw than this gesture deserves.

The interesting part is the bug. The overlay was built, mapped nothing,
and logged absolutely nothing -- because it declared `required property
var screen` while Variants supplies `modelData`. shell.qml has carried a
comment warning about exactly this since the Bar hit it, and I read that
comment earlier in the same session and still walked into it. A comment
that does not stop the person who read it is an argument for a test, so
per-screen-surface-contract now checks every per-screen delegate takes
its screen from modelData. Verified it catches the exact mistake.

Also fixes a regression from 8be3fc2: settings-pages-contract still
required vitalsIntervalMs on Home, where it no longer is. That contract
was pinning the split-across-two-pages arrangement the same commit
fixed, and I pushed without running it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 14:56:15 -04:00
Gabriel Brown 8be3fc2fdd Right-click a bar widget to open its settings
Four places in the entire shell could reach Settings. The bar, where a
person looks first, was not one of them -- and Pill has routed
right-click to a secondaryActivated signal all along, which nothing
connected, so the gesture did nothing on every widget in the bar.

Each widget now opens the page that owns its settings: the clock and the
calendar reminder open Date & Time, weather opens Home, the vitals
readout opens Appearance, the status glyphs open Network & Devices, the
media readout opens Sound, and the privacy indicator opens Privacy &
Security. Left-click behaviour is untouched.

Two routing bugs found while picking those destinations, both of the
same kind and both invisible from the code, since each page reads
perfectly well on its own:

  weather routed to Appearance while every weather control lives on
  Home, so searching "temperature unit" opened a page without it.

  vitals routed to Appearance, but the refresh interval sat on Home
  while the toggles it governs sat on Appearance -- one concept split
  across two pages, which is exactly what the ownership rule forbids.
  The interval now sits beside the toggles and Home's stub card is gone.

The jump contract guards the failure mode these share. openSettings()
falls back to Home for an unknown page, sensibly and completely
silently, so a typo or a later rename turns a right-click into "opens
the wrong page" with nothing logged. It also fails a Pill-based bar
widget that leaves right-click unconnected, since that is how the
gesture came to be inert everywhere in the first place.

A third instance of the routing bug is still open: followMouse and
pointerSensitivity sit in the input group, which routes to Keyboard,
while both render on Mouse. Fixing it is a two-line group change in
PreferenceSchema.qml, which codex currently owns, so the contract that
catches all three lands with that fix rather than red.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 14:46:04 -04:00
Gabriel Brown 2d69ce7648 Make the lock screen follow the colour scheme
hyprlock.conf shipped with Tokyo Night Moon hardcoded in six places, so
choosing light mode left the lock screen dark. Every other surface had
been taught to follow the scheme this week -- kitty, GTK, the launcher,
btop, tmux, neovim -- and this was the one left, which is unfortunate,
because it is the screen a user sees most often and the worst possible
place to find a theming bug: you discover it while locked out of the
machine and cannot fix it from there.

It is now generated from a template on every scheme change, the same
shape kitty, GTK, tmux and btop already use, and seeded by link-dotfiles
so the first lock of a fresh install is themed rather than falling back
to hyprlock's bare grey default. hyprlock is launched fresh on each lock
(`pidof hyprlock || hyprlock`), so it picks the file up with no restart.

The dark output is byte-identical to the file it replaces, ignoring
comments -- verified by diff -- so nothing changes for anyone already in
dark mode.

One detail worth recording: hyprlock takes rgba(r, g, b, a) in DECIMAL,
not hex, so the template carries "R, G, B" triples where every other
theme file in this repository uses hex. Two values are the exception,
sitting inside Pango markup where hyprlock wants ##rrggbb. Getting
either wrong is not a parse error -- hyprlock ignores the value and uses
its own default, silently.

Which is why this has a contract. It generates both schemes into a
fixture, never the live config, and checks that no placeholder survives
substitution, that every colour is a well-formed decimal triple, that
the Pango values are well-formed hex, that a light lock screen is
actually light, and that the two schemes differ at all. Verified it
catches a hardcoded colour left in the template and a light mode built
from the dark palette, which is the original bug exactly.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 14:36:35 -04:00
Gabriel Brown d87f4b6d6a Define Settings ownership boundaries 2026-08-18 13:27:52 -04:00
Gabriel Brown 9fc1fdbbbb Expose wallpaper health status 2026-08-18 13:07:23 -04:00
Gabriel Brown dd8d93c387 Ignore transient Quickshell clients in Health 2026-08-18 13:05:25 -04:00
Gabriel Brown b8832a0174 Preserve the GNOME Caps Lock behavior 2026-08-18 13:01:28 -04:00
Gabriel Brown 3f07d25858 Merge current Panama main 2026-08-18 12:58:40 -04:00
Gabriel Brown df1dcdfad5 Add effective desktop style controls 2026-08-18 12:57:54 -04:00
Gabriel Brown 91f7273f41 Keep pointer focus controls on Mouse 2026-08-18 12:56:24 -04:00
Gabriel Brown f9eba1e8c5 Add curated XKB option presets 2026-08-18 12:55:54 -04:00
Gabriel Brown b1edfb6fe4 Merge Panama health hardening 2026-08-18 12:51:14 -04:00
Gabriel Brown 1afa41526a Add startup application picker 2026-08-18 12:50:42 -04:00
Gabriel Brown 08b16fa03f Fix live network and phone discovery 2026-08-18 12:34:02 -04:00
Gabriel Brown ccf46c40ef Expose 19 more compositor options that only looks.lua could reach
Measured the gap first: of the 38 real Hyprland options Panama's own Lua
sets, only 16 were editable in Settings. Everything else required a text
editor, which is the thing this app exists to stop. This closes most of
that: 66 mapped options now, from 47.

Window shape and shadows on Appearance: corner shape (rounding_power),
focused and fullscreen opacity, shadow falloff and hard-edged shadows.
Window edges, master layout and Hyprland's own notices on Desktop & Dock.

Three of these are corrections rather than additions.

Master layout options existed nowhere, while Settings has offered "Master
and stack" as a choice since this morning -- a layout you can select and
cannot configure is barely a choice. Its card is hidden unless that
layout is actually selected, since settings that do nothing under the
layout you are running are worse than not offering the layout at all.

The four Hyprland notices -- logo, splash, update news, donation nag --
are all turned off by looks.lua on the user's behalf. Defensible as a
default, but not a decision anyone could reverse. They are stored
positively ("show this") and written as Hyprland's `disable_*` through a
new `invert` flag, because a switch labelled "Disable splash text" that
must be ON to hide something is a small cruelty. The Lua does the same
inversion so both sides agree.

Everything new also reads from prefs in looks.lua. Without that these
would apply live and silently revert on the next compositor reload,
which is the failure this codebase keeps designing against.

Two shapes the write path had never seen. Border colours are gradients
and shadow offsets are vec2, and the verifier understood neither -- it
returned false for anything outside int/bool/float/str/css, so both
would have reported every write as rejected. Gradients also need real
care: the stubs declare them as `string|{colors,angle}`, and the string
form carries only ONE stop, so writing "rgba(a) rgba(b) 45deg" as a
string is accepted and keeps the previous value. Verified that directly.
They are also written in one notation and read back in another
(`{colors={"rgba(3b426199)"},angle=45}` becomes `993b4261 45deg`), so
comparison normalises both sides.

Border COLOUR is deliberately not exposed yet. col.inactive_border is
written by ColorScheme on every scheme change, so a user's choice would
be silently overwritten, and col.active_border is the Prism gradient,
which needs a colour control this app does not have. Shadow offset is
left out for the same reason -- the vec2 support is in place for
whenever the widget exists.

Verified each new option applies and reverts against the live
compositor, and that the schema, enum-map, nav, write and commit/reset
contracts all pass.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 12:27:34 -04:00
Gabriel Brown 60a321e6f4 Harden Health verification isolation 2026-08-18 12:15:45 -04:00
Gabriel Brown 7d5c65be03 Merge remote-tracking branch 'origin/main' into feat/panama-health
# Conflicts:
#	config/dot/quickshell/modules/settings/HealthPage.qml
#	tests/quickshell/health-ui-contract.sh
2026-08-18 11:41:33 -04:00
Gabriel Brown f8e7512e01 Document Panama health and recovery 2026-08-18 11:36:06 -04:00
Gabriel Brown b7ce2c6e43 Assert the installer's process isolation, not its formatting
panama-command-install-contract matched the literal string
`do "$script"; done`, so it failed the moment that loop gained error
reporting and spanned more than one line -- while the property it exists
to protect, each setup stage running in its own process, was unchanged.

It now checks that property directly: the installer must not source
anything under setup/scripts, and must execute them. Verified it still
catches an installer rewritten to source its stages, which the
first attempt at the replacement did not -- the pattern anchored to the
start of a line, and the sourcing appeared mid-line behind an `if`.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 11:35:52 -04:00
Gabriel Brown 1f62256024 Make a fresh install actually produce a working desktop
Two things stood between this repository and a machine that could
install it.

The installer aborted on its own first question. The hostname prompt
defaults to N, and the N branch ran `exit` -- so pressing Enter, the
obvious answer when you do not want to rename your machine, skipped the
entire installation and said nothing about it. Declining now just
declines. The installer is also safe to re-run, which is the upgrade
path too: it reports which stages failed instead of scrolling the
failure past twenty minutes ago, and restores the idle settings on every
exit path rather than only on success.

The package lists had drifted badly from what the configs and helpers
actually use. jq alone has thirty-one call sites across the helpers and
the contracts; kitty has a full shipped config and a dock pin; tmux and
btop have shipped themes the colour scheme switches; ddcutil, qrencode
and orca back features added today. None were declared. Neither were
fontconfig, pciutils, libselinux-utils, libnotify, wireplumber, fwupd or
python3-dnf, all of which shipped scripts invoke by name. A fresh
machine following this repository's own instructions would have got a
desktop whose features quietly were not there -- the helpers report "not
installed" rather than crashing, which is good behaviour and completely
silent.

So the lists are corrected and a contract now checks that every external
command Panama's scripts invoke is installed by Panama's packages.

Writing it was instructive about its own blind spots. The first version
reported `then`, `esac` and `done` as missing packages, burying the real
findings. The second passed while jq was undeclared, because the pattern
required three characters and jq is two -- a dependency checker with a
blind spot for short names is worse than none, since it reports PASS.
The third missed ddcutil, which is only ever invoked as `timeout 10
ddcutil` and so never appears statement-initial. It now also reads
`command -v X`, which is how these helpers probe for a tool and
therefore the clearest statement of a dependency there is. Verified it
catches jq, ddcutil and qrencode individually.

Also replaced a fixed 0.3s sleep in the write contract with a bounded
wait. It was failing about one run in three with "a rejected value did
not surface an error" when the error had simply not arrived yet, which
reads as a missing guard rather than a slow one.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 11:29:52 -04:00
Gabriel Brown 4ef2f01baa Merge remote-tracking branch 'origin/main' into feat/panama-health
# Conflicts:
#	config/dot/quickshell/modules/settings/ServicesPage.qml
#	config/dot/quickshell/modules/settings/SettingsShell.qml
#	config/dot/quickshell/modules/settings/SettingsSidebar.qml
2026-08-18 11:23:07 -04:00
Gabriel Brown 6042c0b1c0 Merge codex's System Health and recovery work
Brings in panama-doctor (a 25-check diagnostic with fixture-backed
contracts), a Health service, a System Health page replacing Startup &
Services, and a bar indicator that stays absent until something is
actually degraded. All seven of its contracts pass on the merge.

Three things needed resolving rather than accepting:

The branch predates the debranding, so its user-visible strings still
named the product -- "Panama desktop is healthy", "Restart Panama",
"Panama tools". Rewritten to say the same thing without the name, which
is what the rest of the app now does.

Its Fedora hand-off card was a single button calling openGnomePanel
("network") under a subtitle naming five subjects. Main had already
replaced that with a row per subject, each opening the panel that owns
it, so those rows are ported into HealthPage instead. Printers and
online accounts stay on Network & Devices with the rest of the network
hardware.

That broke its own assertion, which matched the literal
openGnomePanel("network") string. Rewritten rather than reverted: it now
checks the boundary card exists and that every panel named in HealthPage
is one openGnomePanel actually allows, since a name outside the
allow-list opens nothing at all. Verified it catches a plausible-looking
wrong name.

SettingsShell and SettingsSidebar conflicted because both sides added
pages; resolved as the union, keeping its System Health page and live
footer alongside main's Mouse & Touchpad, Privacy & Security, Region &
Language and Online Accounts.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 11:20:06 -04:00
Gabriel Brown 0c4d132ee1 Fix focus-mode labels that said the opposite of what they did
Found by codex's GNOME Tweaks audit and verified against the compositor:
`hyprctl descriptions` publishes input:follow_mouse as
map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}].

Panama labelled 0 "Never", 1 "Click to focus", 2 "Sloppy focus". So this
desktop, sitting on the shipped value of 1, has been running
focus-follows-pointer the whole time while Settings called it "Click to
focus" -- and the way to actually GET click-to-focus was to choose
"Never". Value 3 was not offered at all. hypr/input.lua carried the same
wrong claim in a comment.

The shipped VALUE is left alone. Which focus mode this desktop should
use is a behaviour decision rather than a correction, and all four are
now reachable from Settings.

Nothing could have caught this. The compositor accepts 1, reads back 1,
and the write contract passes: the value is valid, it just means
something other than the label. The only authority on what each number
MEANS is the compositor, and it publishes that. So enum-hypr-map-contract
now checks every compositor-backed enum against the published map --
that offered values exist, and that published values are offered, since
a missing one is a capability nobody can reach.

Writing it immediately found two more of the same: variable refresh rate
offered Off and fullscreen-games while the compositor publishes four
(always-on and fullscreen-only were unreachable, and fullscreen-only is
what someone wanting VRR for video rather than games wants), and direct
scanout was missing its always-on value. Both now offer everything, with
a detail line per option rather than a bare word.

Verified the contract catches the original followMouse gap and a value
outside the map.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 11:13:42 -04:00
Gabriel Brown faa9a00716 Close Panama recovery race windows 2026-08-18 11:11:53 -04:00
Gabriel Brown 8bfae70284 Harden bounded Panama recovery actions 2026-08-18 10:58:48 -04:00
Gabriel Brown 2cc109f5e1 Add bounded Panama recovery actions 2026-08-18 10:35:18 -04:00
Gabriel Brown d855a26bd9 Add quiet System Health entry points 2026-08-18 10:19:03 -04:00
Gabriel Brown 8a0a09dbb4 Fix System Health ledger rendering 2026-08-18 10:08:12 -04:00
Gabriel Brown 3238623934 Build the System Health settings page 2026-08-18 09:50:15 -04:00
Gabriel Brown 21aa9223df Harden Panama health snapshots 2026-08-18 09:30:18 -04:00
Gabriel Brown e2e03252e4 Add Panama health state service 2026-08-18 09:20:16 -04:00
Gabriel Brown 0ca83f74c7 Harden Panama doctor probes 2026-08-18 09:00:35 -04:00
Gabriel Brown d58c431199 Add Panama system health diagnostics 2026-08-18 08:52:49 -04:00
Gabriel Brown 77f8102148 Approve diagnostic ledger health design 2026-08-18 08:39:47 -04:00
Gabriel Brown 24d8bfd641 Plan Panama health and recovery 2026-08-18 08:28:20 -04:00
Gabriel Brown af0ba13573 Design Panama Health and Recovery 2026-08-18 08:15:51 -04:00
138 changed files with 14861 additions and 618 deletions
+1
View File
@@ -21,3 +21,4 @@ __pycache__/
/config/dot/gtk-3.0/settings.ini
/config/dot/gtk-4.0/settings.ini
/config/dot/tmux/current-theme.conf
/config/dot/hypr/hyprlock.conf
+25
View File
@@ -37,6 +37,31 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
| System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
| System health and recovery | Settings → System Health, `Panama: Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live |
## System health and recovery
Panama stays silent while the desktop is healthy. A compact bar indicator
appears only for actionable warnings or errors and opens the same **System
Health** page available from Settings and the Vicinae command **Panama: Check
System Health**. The terminal summary is available with:
```bash
~/.config/quickshell/scripts/panama-doctor --summary
```
The doctor reports authored, redacted observations about Panama-owned services,
tools, links, and configured integrations. It does not read secrets, clipboard
or notification contents, calendar events, SSIDs, or device addresses. Repairs
are a small allow-list: Panama user services, Panama-owned links and launcher
commands, duplicate Panama Caffeine inhibitors, and a confirmed shell restart.
They never install packages, invoke `sudo`, delete user data, or rewrite
arbitrary configuration.
Generic Fedora configuration remains with the system tools that own it. The
final System Health card hands network settings, users, sharing, colour
profiles, and digital wellbeing to their exact GNOME Settings panels rather
than presenting inert Hyprland controls.
## GNOME extension migration
+3 -1
View File
@@ -9,6 +9,8 @@
-- instead. They still work here; see the session notes in autostart.lua.
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
-- ── GPU selection ───────────────────────────────────────────────────────────
-- This box has a discrete RX 7800 XT (0000:03:00.0) and a Granite Ridge iGPU
-- (0000:12:00.0). The monitor hangs off the dGPU, so the dGPU must render.
@@ -47,7 +49,7 @@ end
-- (it has cursors/ + index.theme and no hyprcursors/ directory or manifest.hl),
-- so setting it would point hyprcursor at nothing. Hyprland falls back to the
-- XCursor path, which is what we want.
hl.env("XCURSOR_THEME", "oreo_blue_cursors")
hl.env("XCURSOR_THEME", prefs.get("cursorTheme", "oreo_blue_cursors"))
hl.env("XCURSOR_SIZE", "24")
-- ── Toolkits ────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -15,7 +15,7 @@
general {
# `pidof` guard prevents stacking lockers if this fires twice.
lock_cmd = pidof hyprlock || hyprlock
lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run
before_sleep_cmd = loginctl lock-session
after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })'
@@ -1,8 +1,17 @@
# ─────────────────────────────────────────────────────────────────────────────
# hyprlock — lock screen
# hyprlock — lock screen.
#
# Tokyo Night Moon, matching quickshell/config/Theme.qml:
# accent #82aaff fg #c8d3f5 dim #828bb8 bg #222436 red #ff757f
# GENERATED FILE. Edit hyprlock.conf.template and re-run
# quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the
# next colour scheme change.
#
# The colours here follow the desktop's light/dark setting. They used to be
# hardcoded Tokyo Night Moon, which meant the one screen you see most often
# stayed dark when everything else went light.
#
# hyprlock takes rgba(r, g, b, a) in DECIMAL rather than hex, which is why the
# template carries "R, G, B" triples where the rest of Panama uses hex. The two
# Pango markup values are the exception and want ##rrggbb.
#
# hyprlang syntax, not Lua — hyprlock is a separate project from Hyprland.
# ─────────────────────────────────────────────────────────────────────────────
@@ -45,7 +54,7 @@ background {
vibrancy_darkness = 0.05
# Shown if the screenshot is unavailable.
color = rgba(34, 36, 54, 1.0)
color = rgba(@BG@, 1.0)
zindex = -1
}
@@ -54,7 +63,7 @@ background {
label {
monitor =
text = cmd[update:1000] date +"%-I:%M"
color = rgba(200, 211, 245, 1.0)
color = rgba(@FG@, 1.0)
font_size = 120
font_family = Adwaita Sans Light
position = 0, 260
@@ -65,7 +74,7 @@ label {
label {
monitor =
text = cmd[update:60000] date +"%A, %B %-d"
color = rgba(130, 139, 184, 1.0)
color = rgba(@MUTED@, 1.0)
font_size = 24
font_family = Adwaita Sans
position = 0, 160
@@ -84,18 +93,18 @@ input-field {
outline_thickness = 2
rounding = 26
outer_color = rgba(130, 170, 255, 0.9)
inner_color = rgba(46, 47, 61, 0.85)
font_color = rgba(200, 211, 245, 1.0)
check_color = rgba(130, 170, 255, 1.0)
fail_color = rgba(255, 117, 127, 1.0)
outer_color = rgba(@ACCENT@, 0.9)
inner_color = rgba(@FIELD@, 0.85)
font_color = rgba(@FG@, 1.0)
check_color = rgba(@ACCENT@, 1.0)
fail_color = rgba(@ERROR@, 1.0)
dots_size = 0.25
dots_spacing = 0.3
dots_center = true
placeholder_text = <span foreground="##828bb8"><i>Password</i></span>
fail_text = <span foreground="##ff757f"><i>$FAIL ($ATTEMPTS)</i></span>
placeholder_text = <span foreground="##@MUTED_HEX@"><i>Password</i></span>
fail_text = <span foreground="##@ERROR_HEX@"><i>$FAIL ($ATTEMPTS)</i></span>
fade_on_empty = false
hide_input = false
@@ -105,7 +114,7 @@ input-field {
label {
monitor =
text = $USER
color = rgba(200, 211, 245, 0.9)
color = rgba(@FG@, 0.9)
font_size = 16
font_family = Adwaita Sans
position = 0, -110
+12 -4
View File
@@ -10,9 +10,9 @@ local prefs = require("prefs")
hl.config({
input = {
kb_layout = prefs.get("keyboardLayout", "us"),
kb_variant = "",
kb_variant = prefs.get("keyboardVariant", ""),
kb_model = "",
kb_options = "",
kb_options = prefs.get("keyboardOptions", "caps:escape_shifted_capslock"),
kb_rules = "",
numlock_by_default = prefs.get("numlockByDefault", true),
@@ -21,10 +21,18 @@ hl.config({
repeat_delay = prefs.get("keyRepeatDelay", 500),
repeat_rate = prefs.get("keyRepeatRate", 33),
-- 1 = click to focus. GNOME's behaviour; NOT sloppy focus.
-- 1 = FOLLOW. The window under the pointer takes focus. This comment
-- previously claimed 1 was "click to focus, GNOME's behaviour", which
-- is the opposite of what Hyprland does -- `hyprctl descriptions` gives
-- map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}],
-- so click-to-focus is 0. Changing the shipped value is a behaviour
-- decision rather than a correction, so the value is left alone and
-- only the description is fixed; Settings exposes all four.
follow_mouse = prefs.getInt("followMouse", 1),
-- Don't refocus on mouse move alone -- only on click.
-- Softens follow_mouse: with this off, focus changes only when the
-- pointer crosses a window boundary, not on every movement inside one.
-- Still focus-follows-pointer, just less twitchy.
mouse_refocus = false,
-- Flat pointer response, no acceleration. Matters for gaming.
+15 -3
View File
@@ -195,9 +195,21 @@ bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = t
bind(mod .. " + SHIFT + P", 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).
bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" })
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
-- showing what you are choosing between.
--
-- The gesture needs three binds, not two. Tab steps the selection, and the
-- switch is only COMMITTED when the modifier is released -- which is the sole
-- way the compositor can tell the gesture is finished. That release bind is on
-- the bare modifier, so it fires on EVERY Super release in the session; the
-- handler returns immediately when no switch is open, which is why this is
-- affordable.
--
-- The release bind carries no description on purpose: it is not a shortcut
-- anyone would look up or rebind, and the Shortcuts page lists what it finds.
bind(mod .. " + Tab", hl.dsp.exec_cmd(qs("switcher", "next")), { description = "Next window" })
bind(mod .. " + SHIFT + Tab", hl.dsp.exec_cmd(qs("switcher", "previous")), { description = "Previous window" })
bind(mod, hl.dsp.exec_cmd(qs("switcher", "commit")), { release = true, description = "Commit window switch" })
-- Jump back to the previously focused window.
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
+49 -20
View File
@@ -22,21 +22,22 @@ hl.config({
border_size = prefs.get("borderSize", 2),
col = {
-- The prism: blue leads, orchid follows, on a diagonal so the pair
-- is visible on both a tall and a wide window. Same two colours as
-- the shell's hairline (quickshell/widgets/PrismEdge.qml) and the
-- tmux theme this palette came from.
-- The focused accent role: blue leads, orchid follows, on a
-- diagonal so the pair is visible on both a tall and a wide
-- window. ColorScheme never writes this role; a future accent
-- picker can own it without fighting light/dark mode.
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
-- Unfocused windows get no colour at all. The gradient only means
-- something if exactly one window on screen is wearing it.
-- Follows the colour scheme: a dark neutral is invisible against a
-- light desktop. services/ColorScheme.qml applies changes live;
-- this is the value a fresh session starts from.
-- The neutral inactive role follows the colour scheme because a
-- dark neutral disappears against a light desktop.
-- services/ColorScheme.qml applies the same values live; this is
-- the value a fresh session starts from.
inactive_border = prefs.get("colorScheme", "dark") == "light"
and "rgba(a8aecb99)" or "rgba(3b426199)",
},
resize_on_border = true,
resize_on_border = prefs.get("resizeOnBorder", true),
extend_border_grab_area = prefs.getInt("borderGrabArea", 15),
hover_icon_on_border = prefs.get("hoverIconOnBorder", true),
-- Enables the per-window "immediate" rule used for games in rules.lua.
-- Harmless on its own; tearing only happens where a rule opts in.
@@ -44,16 +45,22 @@ hl.config({
layout = "dwindle",
snap = { enabled = true },
snap = {
enabled = true,
window_gap = prefs.getInt("snapWindowGap", 10),
monitor_gap = prefs.getInt("snapMonitorGap", 10),
respect_gaps = prefs.get("snapRespectGaps", false),
},
},
decoration = {
-- 18 to match the shell's popover radius, so a window and a panel sitting
-- next to each other read as the same object family.
rounding = prefs.get("windowRounding", 18),
rounding_power = 2,
rounding_power = prefs.get("roundingPower", 2),
active_opacity = 1.0,
active_opacity = prefs.get("activeOpacity", 1.0),
fullscreen_opacity = prefs.get("fullscreenOpacity", 1.0),
inactive_opacity = prefs.get("inactiveOpacity", 1.0),
blur = {
@@ -83,9 +90,13 @@ hl.config({
shadow = {
enabled = prefs.get("shadowEnabled", true),
range = prefs.get("shadowRange", 20),
render_power = 3,
sharp = false,
render_power = prefs.getInt("shadowRenderPower", 3),
sharp = prefs.get("shadowSharp", false),
color = "rgba(15161eee)",
-- Deliberately not a setting: a two-axis offset needs a control we
-- do not have, and a slider bound to half a value is worse than
-- leaving it alone. SystemSettings understands the vec2 shape
-- already, so adding it later is only a matter of the widget.
offset = { 0, 4 },
scale = 1.0,
},
@@ -110,14 +121,32 @@ hl.config({
dwindle = {
-- Keep the split orientation a window was created with. Closest match
-- to how the Forge extension behaved on GNOME.
preserve_split = true,
preserve_split = prefs.get("preserveSplit", true),
smart_resizing = true,
},
-- Only in effect when the tiling layout is "master". Panama ships dwindle,
-- but Settings offers master as a choice, and a layout you can select and
-- cannot configure is barely a choice at all.
master = {
mfact = prefs.get("masterFactor", 0.55),
orientation = prefs.get("masterOrientation", "left"),
new_status = prefs.get("masterNewStatus", "slave"),
new_on_top = prefs.get("masterNewOnTop", false),
},
misc = {
force_default_wallpaper = 0,
disable_hyprland_logo = true,
disable_splash_rendering = true,
-- Stored as "show the logo / show the splash" and written as Hyprland's
-- `disable_*`, matching the `invert` flag on these entries in
-- PreferenceSchema so both sides agree about which way round they are.
disable_hyprland_logo = not prefs.get("hyprlandLogo", false),
disable_splash_rendering = not prefs.get("hyprlandSplash", false),
-- Keep native Wayland selection paste and GTK's matching preference
-- in lockstep. DesktopStyle applies the GTK half only after this value
-- has been read back and stored by SystemSettings.
middle_click_paste = prefs.get("middleClickPaste", true),
-- Same setting as Theme.fontFamily in the shell. If only the QML side
-- followed the preference, the compositor and the shell would disagree
@@ -172,8 +201,8 @@ hl.config({
},
ecosystem = {
no_update_news = true,
no_donation_nag = true,
no_update_news = not prefs.get("hyprlandUpdateNews", false),
no_donation_nag = not prefs.get("hyprlandDonationNag", false),
},
xwayland = {
+40 -3
View File
@@ -20,7 +20,10 @@
local prefs = require("prefs")
-- Per-output overrides written by Panama Settings, keyed by output name:
-- { ["DP-2"] = { mode = "3840x2160@60", scale = 2, transform = 0 } }
-- { ["DP-2"] = {
-- mode = "3840x2160@60", scale = 2, transform = 0,
-- x = 0, y = 0, primary = true,
-- } }
--
-- Only mode, scale, and transform are read. Colour management and bit depth
-- stay here, because those are the settings with a documented reason attached
@@ -71,6 +74,26 @@ local function valid_transform(transform)
and transform <= 3
end
local function valid_coordinate(value)
return type(value) == "number"
and value == value
and value == math.floor(value)
and value >= -100000
and value <= 100000
end
local function valid_position(entry)
return valid_coordinate(entry.x) and valid_coordinate(entry.y)
end
local function valid_primary(entry)
return type(entry.primary) == "boolean"
end
local function has_layout_fields(entry)
return entry.x ~= nil or entry.y ~= nil or entry.primary ~= nil
end
local function display_entry(output)
if type(output) ~= "string" or output == ""
or output:match("^[%w_.-]+$") == nil then
@@ -85,9 +108,23 @@ local function display_entry(output)
or not valid_transform(entry.transform) then
return nil
end
-- Legacy records have none of the layout fields and keep automatic
-- placement. A partially written extended record is unsafe: accepting its
-- mode but guessing its position could overlap or strand another output.
if has_layout_fields(entry)
and (not valid_position(entry) or not valid_primary(entry)) then
return nil
end
return entry
end
local function display_position(entry, fallback)
if entry ~= nil and has_layout_fields(entry) then
return string.format("%dx%d", entry.x, entry.y)
end
return fallback
end
local shipped_mode = "4500x3000@60"
local shipped_scale = 1.5
local shipped_transform = 0
@@ -96,7 +133,7 @@ local dp2 = display_entry("DP-2")
hl.monitor({
output = "DP-2",
mode = dp2 and dp2.mode or shipped_mode,
position = "0x0",
position = display_position(dp2, "0x0"),
scale = dp2 and dp2.scale or shipped_scale,
transform = dp2 and dp2.transform or shipped_transform,
@@ -119,7 +156,7 @@ for output, _ in pairs(displays) do
hl.monitor({
output = output,
mode = entry.mode,
position = "auto",
position = display_position(entry, "auto"),
scale = entry.scale,
transform = entry.transform,
})
@@ -0,0 +1,136 @@
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import QtQuick
import "services/AudioStreams.js" as AudioStreams
import qs.services
ShellRoot {
readonly property int audioOutStreamFlag: 4
property var fixtureNodes: [
{
id: 10,
ready: true,
type: audioOutStreamFlag,
properties: {
"application.id": "org.chromium.Chromium",
"application.name": "Chromium",
"application.icon_name": "chromium"
},
description: "Chromium audio",
audio: { volume: 0.4, muted: false }
},
{
id: 11,
ready: true,
type: audioOutStreamFlag,
properties: {
"application.id": "org.chromium.Chromium",
"application.name": "Chromium",
"application.icon_name": "chromium"
},
description: "Chromium audio",
audio: { volume: 0.8, muted: true }
},
{
id: 20,
ready: true,
type: audioOutStreamFlag,
properties: {
"application.process.binary": "spotify",
"application.name": "Spotify"
},
description: "Spotify",
audio: { volume: 0.25, muted: false }
},
{
id: 30,
ready: true,
type: 2,
properties: { "application.name": "Microphone capture" },
description: "Input stream",
audio: { volume: 0.5, muted: false }
},
{
id: 40,
ready: false,
type: audioOutStreamFlag,
properties: { "application.name": "Not ready" },
description: "Unready stream",
audio: { volume: 0.5, muted: false }
},
{
id: 99,
ready: true,
type: audioOutStreamFlag,
properties: {},
description: "",
audio: { volume: 1, muted: false }
}
]
function groups(): var {
return AudioStreams.group(fixtureNodes, audioOutStreamFlag);
}
IpcHandler {
target: "application-volume-test"
function summary(): string {
const applications = groups();
const chromium = applications.find(application =>
application.key === "org.chromium.Chromium");
return JSON.stringify({
groups: applications.map(application => ({
key: application.key,
label: application.label,
icon: application.icon,
count: application.nodes.length
})),
chromiumVolume: AudioStreams.volume(chromium),
chromiumMuted: AudioStreams.muted(chromium)
});
}
function mutateVolume(): string {
const chromium = groups().find(application =>
application.key === "org.chromium.Chromium");
const changed = AudioStreams.setVolume(chromium, 0.7);
return JSON.stringify({
changed,
volumes: chromium.nodes.map(node => node.audio.volume),
muted: chromium.nodes.map(node => node.audio.muted)
});
}
function mutateMute(): string {
const chromium = groups().find(application =>
application.key === "org.chromium.Chromium");
const changed = AudioStreams.setMuted(chromium, true);
return JSON.stringify({
changed,
muted: chromium.nodes.map(node => node.audio.muted)
});
}
function serviceSummary(): string {
const applications = AudioDevices.applications;
return JSON.stringify({
count: applications.length,
validTypes: applications.every(application =>
application.nodes.every(node =>
(node.type & PwNodeType.AudioOutStream)
=== PwNodeType.AudioOutStream))
});
}
function invalidMutations(): string {
return JSON.stringify({
nullVolume: AudioDevices.setApplicationVolume(null, 0.5),
emptyMute: AudioDevices.setApplicationMuted({ nodes: [] }, true)
});
}
}
}
+370 -16
View File
@@ -128,10 +128,21 @@ Singleton {
{
key: "vrrPolicy", type: "enum", def: 3, group: "display",
label: "Variable refresh rate",
detail: "Content-aware matches the display to what is on screen",
detail: "Matches the display's refresh rate to what is on screen",
// All four the compositor publishes, rather than the two that were
// here. Always-on VRR is a legitimate choice on a panel that
// handles it well, and it was simply unreachable -- as was
// fullscreen-only, which is what someone wanting VRR for video
// rather than games wants.
options: [
{ value: 0, label: "Off" },
{ value: 3, label: "Content-aware" }
{ value: 0, label: "Off",
detail: "The display runs at a fixed refresh rate" },
{ value: 1, label: "Always on",
detail: "Best on panels that handle low refresh rates without flicker" },
{ value: 2, label: "Fullscreen only",
detail: "Any fullscreen window, including video" },
{ value: 3, label: "Fullscreen games",
detail: "Only fullscreen games, which is the safest default" }
],
hypr: { path: ["misc", "vrr"], option: "misc:vrr", readAs: "int" }
},
@@ -140,8 +151,12 @@ Singleton {
label: "Direct scanout",
detail: "Lets fullscreen content bypass compositing",
options: [
{ value: 0, label: "Off" },
{ value: 2, label: "Automatic" }
{ value: 0, label: "Off",
detail: "Everything goes through the compositor" },
{ value: 1, label: "Always on",
detail: "Forced rather than decided per surface; can drop frames on some drivers" },
{ value: 2, label: "Automatic",
detail: "The compositor decides per surface, which is the safe default" }
],
hypr: { path: ["render", "direct_scanout"], option: "render:direct_scanout", readAs: "int" }
},
@@ -191,6 +206,147 @@ Singleton {
hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" }
},
{
key: "activeOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
group: "windows",
label: "Focused window opacity",
detail: "Fade even the focused window; 1.0 is fully opaque",
hypr: { path: ["decoration", "active_opacity"], option: "decoration:active_opacity", readAs: "float" }
},
{
key: "fullscreenOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
group: "windows",
label: "Fullscreen opacity",
detail: "Applied instead of the focused opacity when a window is fullscreen",
hypr: { path: ["decoration", "fullscreen_opacity"], option: "decoration:fullscreen_opacity", readAs: "float" }
},
{
key: "roundingPower", type: "real", def: 2.0, min: 2.0, max: 10.0, step: 0.5,
group: "windows",
label: "Corner shape",
detail: "2 is a circular corner; higher values approach a squircle",
hypr: { path: ["decoration", "rounding_power"], option: "decoration:rounding_power", readAs: "float" }
},
// ── Window edges ────────────────────────────────────────────────────
// How the pointer interacts with a window's border, and how windows
// behave near each other. All shipped by looks.lua with no way to
// change any of it.
{
key: "resizeOnBorder", type: "bool", def: true, group: "edges",
label: "Resize by dragging the border",
detail: "Drag a window's edge to resize it, instead of only with the keyboard",
hypr: { path: ["general", "resize_on_border"], option: "general:resize_on_border", readAs: "bool" }
},
{
key: "borderGrabArea", type: "int", def: 15, min: 0, max: 40, step: 1,
unit: "px",
group: "edges",
label: "Border grab area",
detail: "How far outside the border still counts as grabbing it. Larger is easier to hit",
hypr: { path: ["general", "extend_border_grab_area"], option: "general:extend_border_grab_area", readAs: "int" }
},
{
key: "hoverIconOnBorder", type: "bool", def: true, group: "edges",
label: "Show the resize cursor",
detail: "Change the pointer when it is over a resizable border",
hypr: { path: ["general", "hover_icon_on_border"], option: "general:hover_icon_on_border", readAs: "bool" }
},
{
key: "snapWindowGap", type: "int", def: 10, min: 0, max: 60, step: 1,
unit: "px",
group: "edges",
label: "Snap distance between windows",
detail: "How close two floating windows must be before they snap together",
hypr: { path: ["general", "snap", "window_gap"], option: "general:snap:window_gap", readAs: "int" }
},
{
key: "snapMonitorGap", type: "int", def: 10, min: 0, max: 60, step: 1,
unit: "px",
group: "edges",
label: "Snap distance to screen edges",
detail: "How close a floating window must be to an edge before it snaps to it",
hypr: { path: ["general", "snap", "monitor_gap"], option: "general:snap:monitor_gap", readAs: "int" }
},
{
key: "snapRespectGaps", type: "bool", def: false, group: "edges",
label: "Snapping respects gaps",
detail: "Snapped windows keep the configured gap instead of touching",
hypr: { path: ["general", "snap", "respect_gaps"], option: "general:snap:respect_gaps", readAs: "bool" }
},
// ── Master layout ───────────────────────────────────────────────────
// Only meaningful when the tiling layout is Master and stack. Offering
// that layout with none of its options was an omission: it is the one
// layout whose whole behaviour is in these settings.
{
key: "masterFactor", type: "real", def: 0.55, min: 0.1, max: 0.9, step: 0.05,
group: "master",
label: "Master area size",
detail: "How much of the screen the master window takes",
hypr: { path: ["master", "mfact"], option: "master:mfact", readAs: "float" }
},
{
key: "masterOrientation", type: "enum", def: "left", group: "master",
label: "Master area position",
detail: "Which side of the screen the master window occupies",
options: [
{ value: "left", label: "Left" },
{ value: "right", label: "Right" },
{ value: "top", label: "Top" },
{ value: "bottom", label: "Bottom" },
{ value: "center", label: "Centre" }
],
hypr: { path: ["master", "orientation"], option: "master:orientation", readAs: "str" }
},
{
key: "masterNewStatus", type: "enum", def: "slave", group: "master",
label: "New windows become",
detail: "Whether a new window takes the master area or joins the stack",
options: [
{ value: "master", label: "The master window" },
{ value: "slave", label: "Part of the stack" },
{ value: "inherit", label: "Whatever the focused window is" }
],
hypr: { path: ["master", "new_status"], option: "master:new_status", readAs: "str" }
},
{
key: "masterNewOnTop", type: "bool", def: false, group: "master",
label: "Add new windows at the top",
detail: "New stack windows go above the others rather than below",
hypr: { path: ["master", "new_on_top"], option: "master:new_on_top", readAs: "bool" }
},
// ── Hyprland's own notices ──────────────────────────────────────────
// Panama turns all four off on the user's behalf. That is a defensible
// default and was not a decision anyone could reverse without editing
// looks.lua, which is precisely the kind of thing this app exists to
// stop.
{
key: "hyprlandLogo", type: "bool", def: false, group: "notices",
label: "Hyprland wallpaper",
detail: "The stock background Hyprland draws when no wallpaper is set",
hypr: { path: ["misc", "disable_hyprland_logo"], option: "misc:disable_hyprland_logo", readAs: "bool", invert: true }
},
{
key: "hyprlandSplash", type: "bool", def: false, group: "notices",
label: "Splash text",
detail: "The line of text Hyprland renders over the stock background",
hypr: { path: ["misc", "disable_splash_rendering"], option: "misc:disable_splash_rendering", readAs: "bool", invert: true }
},
{
key: "hyprlandUpdateNews", type: "bool", def: false, group: "notices",
label: "Update announcements",
detail: "The window Hyprland opens after an update to describe what changed",
hypr: { path: ["ecosystem", "no_update_news"], option: "ecosystem:no_update_news", readAs: "bool", invert: true }
},
{
key: "hyprlandDonationNag", type: "bool", def: false, group: "notices",
label: "Donation reminders",
detail: "The prompt Hyprland shows twice a year asking for support",
hypr: { path: ["ecosystem", "no_donation_nag"], option: "ecosystem:no_donation_nag", readAs: "bool", invert: true }
},
// ── Effects ─────────────────────────────────────────────────────────
{
key: "blurEnabled", type: "bool", def: true, group: "effects",
@@ -226,6 +382,19 @@ Singleton {
detail: "How far the shadow spreads from the window edge",
hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" }
},
{
key: "shadowSharp", type: "bool", def: false, group: "effects",
label: "Hard-edged shadow",
detail: "A crisp shadow instead of a soft falloff",
hypr: { path: ["decoration", "shadow", "sharp"], option: "decoration:shadow:sharp", readAs: "bool" }
},
{
key: "shadowRenderPower", type: "int", def: 3, min: 1, max: 4, step: 1,
group: "effects",
label: "Shadow falloff",
detail: "How sharply the shadow fades out. Higher is tighter to the window",
hypr: { path: ["decoration", "shadow", "render_power"], option: "decoration:shadow:render_power", readAs: "int" }
},
{
key: "glowEnabled", type: "bool", def: true, group: "effects",
label: "Focus glow",
@@ -267,7 +436,7 @@ Singleton {
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
},
{
key: "keyboardOptions", type: "string", def: "", group: "input",
key: "keyboardOptions", type: "string", def: "caps:escape_shifted_capslock", group: "input",
// XKB option names are colon-separated pairs in a comma-separated
// list, e.g. "compose:ralt,caps:escape".
pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
@@ -298,19 +467,35 @@ Singleton {
hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" }
},
{
key: "followMouse", type: "enum", def: 1, group: "input",
label: "Focus follows pointer",
detail: "Click to focus matches GNOME; sloppy focus follows the pointer",
key: "followMouse", type: "enum", def: 1, group: "pointer",
label: "Pointer focus",
detail: "What moving the pointer does to which window is focused",
// These labels were wrong, and wrong in the worst way: value 1 was
// shown as "Click to focus" while Hyprland's 1 means the opposite.
// The compositor publishes the authoritative mapping itself --
// `hyprctl descriptions` gives
// map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}]
// -- so a desktop labelled "Click to focus" was in fact following
// the pointer, and the way to actually get click-to-focus was to
// choose "Never". Value 3 was missing entirely.
//
// enum-hypr-map-contract now pins every mapped enum against that
// published map, so this cannot drift again.
options: [
{ value: 0, label: "Never" },
{ value: 1, label: "Click to focus" },
{ value: 2, label: "Sloppy focus" }
{ value: 0, label: "Click to focus",
detail: "Moving the pointer never changes focus" },
{ value: 1, label: "Focus follows pointer",
detail: "The window under the pointer takes focus as you move" },
{ value: 2, label: "Pointer detached",
detail: "The pointer highlights windows on its own; clicking moves keyboard focus" },
{ value: 3, label: "Pointer fully separate",
detail: "Clicking does not move keyboard focus at all" }
],
hypr: { path: ["input", "follow_mouse"], option: "input:follow_mouse", readAs: "int" }
},
{
key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
group: "input",
group: "pointer",
label: "Pointer speed",
detail: "Zero is flat, unaccelerated response",
hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" }
@@ -318,7 +503,7 @@ Singleton {
{
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
unit: "s",
group: "input",
group: "pointer",
label: "Hide pointer after",
detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
// Reported as a float even though it is only ever set to whole
@@ -363,6 +548,12 @@ Singleton {
detail: "Swap the primary and secondary buttons",
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
},
{
key: "middleClickPaste", type: "bool", def: true, group: "pointer",
label: "Middle-click paste",
detail: "Paste the primary selection in GTK and native Wayland applications",
hypr: { path: ["misc", "middle_click_paste"], option: "misc:middle_click_paste", readAs: "bool" }
},
// ── Touchpad ────────────────────────────────────────────────────────
//
@@ -556,6 +747,66 @@ Singleton {
label: "Wallpaper",
detail: "Shown on every output"
},
{
key: "wallpaperMode", type: "enum", def: "single", group: "wallpaper",
label: "Wallpaper mode", detail: "Use one image, rotate a collection, or choose per display",
options: [
{ value: "single", label: "Single" },
{ value: "slideshow", label: "Slideshow" },
{ value: "per-monitor", label: "Per display" }
]
},
{
key: "wallpaperSlideshowPaths", type: "json", def: ([]), group: "wallpaper", internal: true,
label: "Slideshow collection", detail: "Backgrounds selected for rotation"
},
{
key: "wallpaperIntervalMinutes", type: "int", def: 30, min: 5, max: 1440, step: 5,
unit: "min", group: "wallpaper", label: "Change background every",
detail: "Time between slideshow images"
},
{
key: "wallpaperShuffle", type: "bool", def: true, group: "wallpaper",
label: "Shuffle", detail: "Show every selected image before repeating"
},
{
key: "wallpaperPerMonitor", type: "json", def: ({}), group: "wallpaper", internal: true,
label: "Per-display backgrounds", detail: "Background assigned to each connected display"
},
// ── Lock-screen appearance ─────────────────────────────────────────
// scripts/panama-lock validates these again before generating a state
// config. The tracked hyprlock.conf remains the safe fallback.
{
key: "lockBackgroundMode", type: "enum", def: "screenshot", group: "lockAppearance",
label: "Background", detail: "What appears behind the lock screen",
options: [
{ value: "screenshot", label: "Blurred desktop" },
{ value: "wallpaper", label: "Current wallpaper" },
{ value: "solid", label: "Solid color" }
]
},
{
key: "lockBlurLevel", type: "int", def: 3, min: 0, max: 5, step: 1,
group: "lockAppearance", label: "Background blur",
detail: "Softens what is behind the password field"
},
{
key: "lockShowClock", type: "bool", def: true, group: "lockAppearance",
label: "Show clock", detail: "Use the desktop's 12 or 24-hour format"
},
{
key: "lockShowDate", type: "bool", def: true, group: "lockAppearance",
label: "Show date", detail: "Show the weekday and full date"
},
{
key: "lockShowUser", type: "bool", def: true, group: "lockAppearance",
label: "Show user name", detail: "Identify the signed-in account"
},
{
key: "lockFadeOnEmpty", type: "bool", def: false, group: "lockAppearance",
label: "Hide password field until typing", detail: "Keep the empty field out of the way"
},
// ── Idle, lock, and sleep ───────────────────────────────────────────
// Written into a generated hypridle config; see scripts/panama-idle.
@@ -621,6 +872,24 @@ Singleton {
]
},
// ── Application themes ─────────────────────────────────────────────
// ColorScheme owns GTK's light/dark theme. These are the two theme
// choices GNOME applications expose independently of that palette:
// their icons and pointer. DesktopStyle only accepts names found in
// the read-only XDG catalog before storing them.
{
key: "cursorTheme", type: "string", def: "oreo_blue_cursors", group: "themes",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Pointer theme",
detail: "The pointer design used by applications and Hyprland"
},
{
key: "iconTheme", type: "string", def: "Adwaita", group: "themes",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Application icons",
detail: "The icon set used by GTK applications"
},
// ── Typography ──────────────────────────────────────────────────────
// The single largest thing in this desktop that used to be changeable
// only by editing Theme.qml.
@@ -650,6 +919,91 @@ Singleton {
label: "Interface text size",
detail: "The base size the rest of the shell's type scales from"
},
{
key: "applicationFont", type: "string", def: "Adwaita Sans", group: "typography",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Application font",
detail: "Used by menus, controls, and labels in applications"
},
{
key: "applicationFontSize", type: "int", def: 11, min: 6, max: 32, step: 1,
unit: "pt", group: "typography",
label: "Application text size",
detail: "The base text size used by applications"
},
{
key: "documentFont", type: "string", def: "Adwaita Sans", group: "typography",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Document font",
detail: "Used for document content when an application follows the system choice"
},
{
key: "documentFontSize", type: "int", def: 12, min: 6, max: 32, step: 1,
unit: "pt", group: "typography",
label: "Document text size",
detail: "The default text size for document content"
},
{
key: "monospaceFont", type: "string", def: "VictorMono Nerd Font", group: "typography",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Monospace font",
detail: "Used by terminals, editors, and code fields that follow the system choice"
},
{
key: "monospaceFontSize", type: "int", def: 10, min: 6, max: 32, step: 1,
unit: "pt", group: "typography",
label: "Monospace text size",
detail: "The default text size for terminals and code"
},
{
key: "fontHinting", type: "enum", def: "slight", group: "typography",
label: "Font hinting",
detail: "How strongly text aligns to the pixel grid",
options: [
{ value: "none", label: "None" },
{ value: "slight", label: "Slight" },
{ value: "medium", label: "Medium" },
{ value: "full", label: "Full" }
]
},
{
key: "fontAntialiasing", type: "enum", def: "rgba", group: "typography",
label: "Text smoothing",
detail: "How application text softens its edges",
options: [
{ value: "none", label: "None" },
{ value: "grayscale", label: "Grayscale" },
{ value: "rgba", label: "Subpixel" }
]
},
// ── Application titlebars ──────────────────────────────────────────
// These affect applications that honour GNOME's window preferences.
// Hyprland itself has no server-side titlebar buttons, so minimize is
// deliberately absent rather than presented as a switch that lies.
{
key: "titlebarButtonSide", type: "enum", def: "right", group: "titlebar",
label: "Button side",
detail: "Place application titlebar buttons on the left or right",
options: [
{ value: "left", label: "Left" },
{ value: "right", label: "Right" }
]
},
{
key: "titlebarMaximizeButton", type: "bool", def: false, group: "titlebar",
label: "Maximize button",
detail: "Show a maximize button in application titlebars that support it"
},
{
key: "titlebarDoubleClick", type: "enum", def: "toggle-maximize", group: "titlebar",
label: "Double-click titlebar",
detail: "Choose what a double-click on an application titlebar does",
options: [
{ value: "toggle-maximize", label: "Toggle maximize" },
{ value: "none", label: "Do nothing" }
]
},
// ── Accessibility ───────────────────────────────────────────────────
// Backed by gsettings so GTK applications agree with the shell, and
@@ -837,7 +1191,7 @@ Singleton {
},
// ── Display configuration ───────────────────────────────────────────
// { "<output>": { mode, scale, transform } }, applied by
// { "<output>": { mode, scale, transform, x, y, primary } }, 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
@@ -846,7 +1200,7 @@ Singleton {
key: "displays", type: "json", def: ({}), group: "display",
internal: true,
label: "Display configuration",
detail: "Resolution, scale, and rotation per connected display"
detail: "Resolution, scale, rotation, position, and primary display"
},
// ── Per-application notification rules ──────────────────────────────
@@ -0,0 +1,81 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.modules.settings
import qs.services
ShellRoot {
DisplayIdentify {}
QtObject {
id: fixtureService
property var monitors: [
{
name: "DP-2", description: "Primary display", width: 4500, height: 3000,
refreshRate: 60, mode: "[email protected]", scale: 1.5,
transform: 0, x: 0, y: 0, primary: true
},
{
name: "HDMI-A-1", description: "Second display", width: 2560, height: 1440,
refreshRate: 60, mode: "[email protected]", scale: 1,
transform: 0, x: 3000, y: 0, primary: false
}
]
property var applied: []
function currentLayout(): var {
return monitors.map(record => Object.assign({}, record));
}
function applyLayout(layout: var): bool {
applied = layout.map(record => Object.assign({}, record));
return true;
}
}
DisplayArrangement {
id: arrangement
width: 800
displayService: fixtureService
selectedOutput: "HDMI-A-1"
}
IpcHandler {
target: "display-arrangement-test"
function status(width: int): string {
arrangement.width = width;
arrangement.resetDraft();
return JSON.stringify(arrangement.canvasSnapshot());
}
function dragFixture(): string {
arrangement.resetDraft();
arrangement.setDraftPosition("HDMI-A-1", 3016, 0, true);
arrangement.applyDraft();
return JSON.stringify(fixtureService.applied);
}
function keyboardFixture(): string {
arrangement.resetDraft();
arrangement.nudge("HDMI-A-1", -10, 0);
const afterArrow = arrangement.draftLayout.find(record => record.name === "HDMI-A-1").x;
arrangement.nudge("HDMI-A-1", -100, 0);
const afterShiftArrow = arrangement.draftLayout.find(record => record.name === "HDMI-A-1").x;
return JSON.stringify({ afterArrow, afterShiftArrow });
}
function primaryFixture(): string {
arrangement.resetDraft();
arrangement.makePrimary("HDMI-A-1");
return JSON.stringify(fixtureService.applied.map(record => ({
name: record.name, x: record.x, y: record.y, primary: record.primary
})));
}
function identify(): void { Displays.identify(); }
function identifying(): bool { return Displays.identifying; }
}
}
@@ -0,0 +1,50 @@
import Quickshell
import Quickshell.Io
import QtQuick
import "services/DisplayLayout.js" as DisplayLayout
ShellRoot {
readonly property var fixture: [
{ name: "DP-2", width: 4500, height: 3000, scale: 1.5, transform: 0, x: 140, y: 80, primary: true },
{ name: "HDMI-A-1", width: 2560, height: 1440, scale: 1, transform: 1, x: 3140, y: 80, primary: false }
]
IpcHandler {
target: "display-layout-test"
function status(): string {
const normalized = DisplayLayout.normalize(fixture);
const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20);
const near = normalized.map(record => Object.assign({}, record));
near[1].x = 3016;
const far = normalized.map(record => Object.assign({}, record));
far[1].x = 3017;
return JSON.stringify({
valid: DisplayLayout.validate(fixture),
sizes: fixture.map(DisplayLayout.logicalSize),
normalized: normalized.map(record => ({ name: record.name, x: record.x, y: record.y, primary: record.primary })),
bounds: canvas.bounds,
canvasScale: canvas.scale,
canvasRects: canvas.rects,
near: DisplayLayout.snap(near, "HDMI-A-1", 16).find(record => record.name === "HDMI-A-1").x,
far: DisplayLayout.snap(far, "HDMI-A-1", 16).find(record => record.name === "HDMI-A-1").x
});
}
function invalid(): string {
const base = fixture.map(record => Object.assign({}, record));
const cases = [];
const add = layout => cases.push(DisplayLayout.validate(layout));
add([base[0], Object.assign({}, base[1], { name: "DP-2" })]);
add(base.map(record => Object.assign({}, record, { primary: false })));
add(base.map(record => Object.assign({}, record, { primary: true })));
add([Object.assign({}, base[0], { x: 0.5 }), base[1]]);
add([Object.assign({}, base[0], { scale: 0 }), base[1]]);
add([Object.assign({}, base[0], { transform: 4 }), base[1]]);
add([Object.assign({}, base[0], { width: Infinity }), base[1]]);
add([Object.assign({}, base[0], { width: 0 }), base[1]]);
return JSON.stringify(cases);
}
}
}
@@ -20,6 +20,9 @@ ShellRoot {
mode: monitor ? monitor.mode : "",
scale: monitor ? monitor.scale : 0,
transform: monitor ? monitor.transform : -1,
x: monitor ? monitor.x : 0,
y: monitor ? monitor.y : 0,
primary: monitor ? monitor.primary : false,
modes: monitor ? monitor.modes.length : 0,
awaiting: Displays.awaitingConfirmation,
canConfirm: Displays.canConfirm,
@@ -36,6 +39,49 @@ ShellRoot {
return Displays.apply(monitor.name, mode, scale, monitor.transform);
}
function transactionStatus(): string {
return JSON.stringify({
layout: Displays.currentLayout(),
pending: Displays.pendingRequestedLayout,
previous: Displays.pendingPreviousLayout,
reverting: Displays.revertExpectedLayout,
awaiting: Displays.awaitingConfirmation,
canConfirm: Displays.canConfirm,
busy: Displays.busy,
generation: Displays.operationGeneration,
revertGeneration: Displays.revertGeneration,
lastError: Displays.lastError
});
}
function applyLayoutFixture(secondX: int, secondY: int): bool {
const layout = Displays.currentLayout();
if (layout.length !== 2) return false;
layout[0].x = 0;
layout[0].y = 0;
layout[0].primary = true;
layout[1].x = secondX;
layout[1].y = secondY;
layout[1].primary = false;
return Displays.applyLayout(layout);
}
function makePrimaryFixture(output: string): bool {
return Displays.makePrimary(output);
}
function injectReadback(text: string, generation: int): void {
Displays.parse(text, generation);
}
function expireApplyVerification(): void {
Displays.verificationTimedOut();
}
function expireRevertVerification(): void {
Displays.revertVerificationTimedOut();
}
function refreshIdentityFixture(): string {
const modes = Displays.normaliseModes([
"[email protected]",
@@ -49,6 +95,30 @@ ShellRoot {
});
}
function positionFixture(): string {
const previous = Displays.monitors;
Displays.parse(JSON.stringify([
{
name: "DP-2", description: "Primary", width: 4500, height: 3000,
refreshRate: 60, scale: 1.5, transform: 0, x: 140, y: 80,
availableModes: ["[email protected]"]
},
{
name: "HDMI-A-1", description: "Second", width: 2560, height: 1440,
refreshRate: 60, scale: 1, transform: 0, x: 3140, y: 80,
availableModes: ["[email protected]"]
}
]), Displays.operationGeneration);
const result = JSON.stringify(Displays.monitors.map(monitor => ({
name: monitor.name,
x: monitor.x,
y: monitor.y,
primary: monitor.primary
})));
Displays.monitors = previous;
return result;
}
function applyBad(kind: string): bool {
const monitor = Displays.monitors[0];
if (!monitor) return false;
+23
View File
@@ -0,0 +1,23 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
ShellRoot {
// The production singleton still performs its delayed startup scan. The
// harness turns it off before its 2200 ms deadline so each fixture drives
// only the state transition it is asserting.
Component.onCompleted: Health.startupScanEnabled = false
IpcHandler {
target: "health-test"
function accept(text: string, generation: int): bool { return Health.consumeSnapshot(text, generation); }
function queue(): void { Health.refresh(); Health.refresh(); }
function status(): string { return JSON.stringify(Health.diagnostics()); }
function repair(id: string): bool { return Health.repair(id, false); }
function report(): string { return JSON.stringify(Health.snapshot, null, 2); }
function copy(): bool { return Health.copyReport(); }
}
}
@@ -0,0 +1,32 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import qs.services
ShellRoot {
IpcHandler {
target: "lock-screen-test"
function status(): string {
return JSON.stringify({
generated: LockScreen.generated,
path: LockScreen.path,
fallback: LockScreen.fallback,
lastError: LockScreen.lastError,
busy: LockScreen.busy
});
}
function burst(): void {
DesktopPreferences.set("lockBlurLevel", 1);
DesktopPreferences.set("lockBlurLevel", 2);
DesktopPreferences.set("lockBlurLevel", 4);
}
function refresh(): void {
LockScreen.refresh();
}
}
}
@@ -0,0 +1,83 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.modules.settings
ShellRoot {
id: root
readonly property string fixtureWallpaper: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg`
LockScreenPreview {
id: screenshotPreview
width: 620
backgroundMode: "screenshot"
blurLevel: 4
showClock: true
showDate: true
showUser: true
fadeOnEmpty: false
wallpaperPath: root.fixtureWallpaper
}
LockScreenPreview {
id: wallpaperPreview
width: 620
backgroundMode: "wallpaper"
blurLevel: 2
showClock: false
showDate: true
showUser: false
fadeOnEmpty: true
wallpaperPath: root.fixtureWallpaper
}
LockScreenPreview {
id: solidPreview
width: 620
backgroundMode: "solid"
blurLevel: 0
showClock: true
showDate: false
showUser: true
fadeOnEmpty: false
wallpaperPath: root.fixtureWallpaper
}
IpcHandler {
target: "lock-screen-settings-test"
function status(): string {
return JSON.stringify({
screenshot: {
mode: screenshotPreview.previewMode,
wallpaperVisible: screenshotPreview.wallpaperVisible,
blurStrength: screenshotPreview.blurStrength,
clockVisible: screenshotPreview.clockVisible,
dateVisible: screenshotPreview.dateVisible,
userVisible: screenshotPreview.userVisible,
passwordVisible: screenshotPreview.passwordVisible
},
wallpaper: {
mode: wallpaperPreview.previewMode,
wallpaperVisible: wallpaperPreview.wallpaperVisible,
blurStrength: wallpaperPreview.blurStrength,
clockVisible: wallpaperPreview.clockVisible,
dateVisible: wallpaperPreview.dateVisible,
userVisible: wallpaperPreview.userVisible,
passwordVisible: wallpaperPreview.passwordVisible
},
solid: {
mode: solidPreview.previewMode,
wallpaperVisible: solidPreview.wallpaperVisible,
blurStrength: solidPreview.blurStrength,
clockVisible: solidPreview.clockVisible,
dateVisible: solidPreview.dateVisible,
userVisible: solidPreview.userVisible,
passwordVisible: solidPreview.passwordVisible
}
});
}
}
}
@@ -13,6 +13,9 @@ Pill {
horizontalPadding: 8
onActivated: ShellState.toggle("activity")
// Right-click opens the settings that govern this widget. Camera, microphone and screen-sharing state is a privacy readout.
onSecondaryActivated: ShellState.openSettings("privacy")
Text {
anchors.verticalCenter: parent.verticalCenter
text: {
@@ -108,6 +108,10 @@ PanelWindow {
anchors.verticalCenter: parent.verticalCenter
}
HealthIndicator {
anchors.verticalCenter: parent.verticalCenter
}
ActivityIndicator {
anchors.verticalCenter: parent.verticalCenter
}
@@ -15,6 +15,9 @@ Pill {
horizontalPadding: 8
onActivated: ShellState.openDateMenu("agenda")
// Right-click opens the settings that govern this widget. The same place the clock leads, since this is the calendar's own reminder.
onSecondaryActivated: ShellState.openSettings("datetime")
ToolTip.visible: root.hovered && root.visible
ToolTip.delay: 500
ToolTip.text: CalendarAgenda.nextEvent?.summary ?? "Upcoming event"
@@ -26,6 +26,11 @@ Pill {
onActivated: ShellState.toggleDateMenu("agenda")
// Right-click opens the settings that govern this widget. Timezone and clock format live on Date & Time. The
// format toggles are mirrored on Appearance, but someone right-clicking a
// clock is far more often after the time itself than its typography.
onSecondaryActivated: ShellState.openSettings("datetime")
Text {
anchors.verticalCenter: parent.verticalCenter
text: Qt.formatDateTime(clock.date, root.format)
@@ -0,0 +1,89 @@
// A deliberately absent-until-needed health affordance. Healthy and merely
// unconfigured systems leave no ornament or layout residue in the bar.
import QtQuick
import QtQuick.Controls
import qs.config
import qs.services
Rectangle {
id: root
signal activated
readonly property int issueCount: Health.summary.warnings + Health.summary.errors
readonly property color tone: Health.status === "error"
? Theme.danger
: (Health.status === "warning" ? Theme.warn : "transparent")
readonly property string statusText: root.issueCount === 1
? "1 system health issue"
: root.issueCount + " system health issues"
readonly property string accessibleLabel: Health.status === "error"
? "System Health: " + root.issueCount + (root.issueCount === 1 ? " issue requires action" : " issues require action")
: "System Health: " + root.issueCount + (root.issueCount === 1 ? " issue needs attention" : " issues need attention")
readonly property string tooltipText: root.accessibleLabel
visible: Health.actionable
implicitWidth: visible ? content.implicitWidth + 16 : 0
implicitHeight: visible ? 24 : 0
width: implicitWidth
height: implicitHeight
radius: 9
color: visible ? Theme.alpha(root.tone, 0.09) : "transparent"
border.width: activeFocus ? 2 : 1
border.color: visible ? Theme.alpha(root.tone, activeFocus ? 0.72 : 0.20) : "transparent"
activeFocusOnTab: visible
Accessible.role: Accessible.Button
Accessible.name: root.accessibleLabel
Accessible.description: "Open System Health"
Accessible.onPressAction: root.activated()
onActivated: {
ShellState.openSettings("services");
Health.refresh();
}
Keys.onReturnPressed: root.activated()
Keys.onEnterPressed: root.activated()
Keys.onSpacePressed: root.activated()
Row {
id: content
anchors.centerIn: parent
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
text: "\u{F0ECD}" // md-shield-alert-outline
color: root.tone
font.family: Theme.fontMono
font.pixelSize: 14
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: String(root.issueCount)
color: root.tone
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
}
MouseArea {
id: pointer
anchors.fill: parent
enabled: root.visible
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activated()
}
ToolTip.visible: pointer.containsMouse && root.visible
ToolTip.delay: 500
ToolTip.text: root.tooltipText
}
@@ -7,6 +7,7 @@
import QtQuick
import Quickshell.Services.Mpris
import qs.config
import qs.services
import qs.widgets
Pill {
@@ -34,6 +35,9 @@ Pill {
onActivated: if (root.player?.canTogglePlaying)
root.player.togglePlaying()
// Right-click opens the settings that govern this widget. Output device and per-application volume.
onSecondaryActivated: ShellState.openSettings("sound")
// Scroll up = previous, down = next — the same direction as the workspace
// switcher, so the whole bar scrolls consistently.
onScrolled: delta => {
@@ -24,6 +24,9 @@ Pill {
onActivated: root.requestQuickSettings()
// Right-click opens the settings that govern this widget. Network and Bluetooth, which is most of what these glyphs report.
onSecondaryActivated: ShellState.openSettings("connectivity")
// ── Audio ───────────────────────────────────────────────────────────────
// Without a tracker, volume and muted silently read as zero/false.
PwObjectTracker {
@@ -13,6 +13,10 @@ import qs.widgets
Pill {
id: root
// Right-click opens the settings that govern this widget. Which readouts
// appear in the bar, and how often they update.
onSecondaryActivated: ShellState.openSettings("appearance")
interactive: false
Row {
@@ -10,6 +10,9 @@ import qs.widgets
Pill {
id: root
// Right-click opens the settings that govern this widget. Location, units and refresh interval are all on Home.
onSecondaryActivated: ShellState.openSettings("home")
interactive: false
visible: Weather.available
@@ -17,8 +17,10 @@ import qs.services
SettingsPage {
id: root
property string expandedPicker: ""
title: "Appearance"
lede: "Drag anything below. The preview above is your real geometry, to scale."
lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale."
header: Component {
Column {
@@ -48,9 +50,16 @@ SettingsPage {
? Wallpaper.lastError
: "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
WallpaperControls {
id: wallpaperControls
width: parent.width
}
WallpaperPicker {
id: wallpapers
width: parent.width
mode: wallpaperControls.mode
selectedOutput: wallpaperControls.selectedOutput
}
ActionRow {
@@ -73,6 +82,24 @@ SettingsPage {
}
}
SettingsCard {
title: "Lock screen"
subtitle: LockScreen.lastError !== ""
? LockScreen.lastError
: "A representative preview of the screen shown before authentication."
LockScreenPreview {
width: parent.width
}
ChoiceRow { setting: "lockBackgroundMode" }
SliderRow { setting: "lockBlurLevel"; zeroLabel: "Off" }
ToggleRow { setting: "lockShowClock" }
ToggleRow { setting: "lockShowDate" }
ToggleRow { setting: "lockShowUser" }
ToggleRow { setting: "lockFadeOnEmpty"; divider: false }
}
SettingsCard {
title: "Colour scheme"
subtitle: ColorScheme.lastError !== ""
@@ -83,7 +110,7 @@ SettingsPage {
}
SettingsCard {
title: "Typography"
title: "Shell typography"
subtitle: Fonts.lastError !== ""
? Fonts.lastError
: "Every piece of text in the shell. Samples are drawn in the font they name."
@@ -123,6 +150,137 @@ SettingsPage {
}
}
SettingsCard {
title: "Application typography"
subtitle: DesktopStyle.lastError !== ""
? DesktopStyle.lastError
: "Fonts used by applications that follow the desktop defaults. Open one family at a time to keep the page calm."
ActionRow {
label: "Application font"
detail: DesktopStyle.applicationFont
action: root.expandedPicker === "application-font" ? "Close" : "Choose"
onTriggered: root.expandedPicker = root.expandedPicker === "application-font" ? "" : "application-font"
}
FontPicker {
visible: root.expandedPicker === "application-font"
width: parent.width
families: Fonts.interfaceFonts
current: DesktopStyle.applicationFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No application fonts found"
onPicked: family => {
if (DesktopStyle.setApplicationFont(family))
root.expandedPicker = "";
}
}
SliderRow { setting: "applicationFontSize" }
ActionRow {
label: "Document font"
detail: DesktopStyle.documentFont
action: root.expandedPicker === "document-font" ? "Close" : "Choose"
onTriggered: root.expandedPicker = root.expandedPicker === "document-font" ? "" : "document-font"
}
FontPicker {
visible: root.expandedPicker === "document-font"
width: parent.width
families: Fonts.interfaceFonts
current: DesktopStyle.documentFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No document fonts found"
onPicked: family => {
if (DesktopStyle.setDocumentFont(family))
root.expandedPicker = "";
}
}
SliderRow { setting: "documentFontSize" }
ActionRow {
label: "Monospace font"
detail: DesktopStyle.monospaceFont
action: root.expandedPicker === "monospace-font" ? "Close" : "Choose"
onTriggered: root.expandedPicker = root.expandedPicker === "monospace-font" ? "" : "monospace-font"
}
FontPicker {
visible: root.expandedPicker === "monospace-font"
width: parent.width
families: Fonts.monospaceFonts
current: DesktopStyle.monospaceFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No monospace fonts found"
onPicked: family => {
if (DesktopStyle.setMonospaceFont(family))
root.expandedPicker = "";
}
}
SliderRow { setting: "monospaceFontSize" }
ChoiceRow { setting: "fontHinting" }
ChoiceRow { setting: "fontAntialiasing"; divider: false }
}
SettingsCard {
title: "Icons & pointer"
subtitle: DesktopStyle.lastError !== ""
? DesktopStyle.lastError
: "Installed themes only. The pointer updates in applications and Hyprland together."
ActionRow {
label: "Application icons"
detail: DesktopStyle.iconTheme
action: root.expandedPicker === "icon-theme" ? "Close" : "Choose"
enabled: DesktopStyle.catalogLoaded
onTriggered: root.expandedPicker = root.expandedPicker === "icon-theme" ? "" : "icon-theme"
}
SearchPicker {
visible: root.expandedPicker === "icon-theme"
width: parent.width
items: DesktopStyle.iconThemes
current: DesktopStyle.iconTheme
placeholder: "Search icon themes"
emptyText: DesktopStyle.scanning ? "Reading installed icon themes…" : "No icon themes found"
onPicked: value => {
if (DesktopStyle.setIconTheme(value))
root.expandedPicker = "";
}
}
ActionRow {
label: "Pointer theme"
detail: DesktopStyle.cursorTheme
action: root.expandedPicker === "cursor-theme" ? "Close" : "Choose"
enabled: DesktopStyle.catalogLoaded
divider: false
onTriggered: root.expandedPicker = root.expandedPicker === "cursor-theme" ? "" : "cursor-theme"
}
SearchPicker {
visible: root.expandedPicker === "cursor-theme"
width: parent.width
items: DesktopStyle.cursorThemes
current: DesktopStyle.cursorTheme
placeholder: "Search pointer themes"
emptyText: DesktopStyle.scanning ? "Reading installed pointer themes…" : "No pointer themes found"
onPicked: value => {
if (DesktopStyle.setCursorTheme(value))
root.expandedPicker = "";
}
}
}
SettingsCard {
title: "Titlebars"
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
ChoiceRow { setting: "titlebarButtonSide" }
ToggleRow { setting: "titlebarMaximizeButton" }
ChoiceRow { setting: "titlebarDoubleClick"; divider: false }
}
SettingsCard {
title: "Windows"
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
@@ -131,7 +289,10 @@ SettingsPage {
SliderRow { setting: "gapsIn" }
SliderRow { setting: "gapsOut" }
SliderRow { setting: "borderSize"; zeroLabel: "None" }
SliderRow { setting: "inactiveOpacity"; divider: false }
SliderRow { setting: "roundingPower" }
SliderRow { setting: "inactiveOpacity" }
SliderRow { setting: "activeOpacity" }
SliderRow { setting: "fullscreenOpacity"; divider: false }
}
SettingsCard {
@@ -143,6 +304,8 @@ SettingsPage {
SliderRow { setting: "blurPasses" }
ToggleRow { setting: "shadowEnabled" }
SliderRow { setting: "shadowRange"; zeroLabel: "None" }
SliderRow { setting: "shadowRenderPower" }
ToggleRow { setting: "shadowSharp" }
ToggleRow { setting: "glowEnabled" }
SliderRow { setting: "glowRange"; zeroLabel: "None" }
ToggleRow { setting: "animationsEnabled"; divider: false }
@@ -162,7 +325,10 @@ SettingsPage {
ToggleRow { setting: "showCpu" }
ToggleRow { setting: "showMemory" }
ToggleRow { setting: "showGpu"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
ToggleRow { setting: "showGpu"; divider: true }
// Refresh interval was on the Home page, which split one concept across
// two pages -- what the vitals show here, how often they update there.
SliderRow { setting: "vitalsIntervalMs"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
// Only worth asking when there is a choice to make.
ChoiceGrid {
@@ -182,11 +348,4 @@ SettingsPage {
}
}
SettingsCard {
title: "Theme"
subtitle: "This desktop 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,48 @@
import Quickshell.Services.Pipewire
import QtQuick
import qs.config
import qs.services
Column {
id: root
property var applications: AudioDevices.applications
property bool pipewireReady: Pipewire.ready
readonly property int rowCount: applicationRepeater.count
readonly property string statusText: {
if (!root.pipewireReady)
return "PipeWire is unavailable";
if (root.applications.length === 0)
return "Applications playing sound will appear here";
return "";
}
width: parent ? parent.width : 620
spacing: 8
Repeater {
id: applicationRepeater
model: root.applications
ApplicationVolumeRow {
required property var modelData
width: root.width
application: modelData
}
}
Text {
width: parent.width
visible: root.statusText !== ""
text: root.statusText
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
horizontalAlignment: Text.AlignHCenter
topPadding: 18
bottomPadding: 18
}
}
@@ -0,0 +1,106 @@
import Quickshell.Services.Pipewire
import QtQuick
import qs.config
import qs.modules.quicksettings
import qs.services
import qs.widgets
Rectangle {
id: root
required property var application
readonly property real volume: AudioDevices.applicationVolume(root.application)
readonly property bool muted: AudioDevices.applicationMuted(root.application)
width: parent ? parent.width : 620
implicitHeight: 78
radius: Theme.cardRadius
color: Theme.alpha(Theme.fg, 0.025)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
PwObjectTracker {
objects: root.application?.nodes ?? []
}
ThemedIcon {
id: applicationIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.top: parent.top
anchors.topMargin: 11
size: 20
icon: root.application?.icon ?? "audio-x-generic-symbolic"
iconFallback: "audio-x-generic-symbolic"
tint: Theme.fg
}
Column {
anchors.left: applicationIcon.right
anchors.leftMargin: 10
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: applicationIcon.verticalCenter
spacing: 1
Text {
width: parent.width
text: root.application?.label ?? "Unknown application"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
visible: (root.application?.nodes?.length ?? 0) > 1
text: `${root.application?.nodes?.length ?? 0} audio streams`
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
IconButton {
id: muteButton
anchors.left: parent.left
anchors.leftMargin: 8
anchors.bottom: parent.bottom
anchors.bottomMargin: 6
size: 30
iconSize: 17
icon: root.muted || root.volume <= 0.001
? "audio-volume-muted-symbolic"
: "audio-volume-high-symbolic"
iconFallback: "audio-volume-high-symbolic"
onClicked: AudioDevices.setApplicationMuted(root.application, !root.muted)
}
ValueSlider {
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 => AudioDevices.setApplicationVolume(root.application, 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
}
}
@@ -12,6 +12,7 @@ SettingsPage {
lede: "Choose what opens your files and links, and what starts with your session."
property string expandedRole: ""
property bool addingAutostart: false
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"] },
@@ -178,7 +179,28 @@ SettingsPage {
SettingsCard {
title: "User autostart"
subtitle: "These desktop entries live in your user configuration. Select a row to toggle it."
subtitle: "Choose what starts with your session. Entries live in your user configuration, not the compositor."
ActionRow {
label: "Add an application"
detail: root.addingAutostart
? "Search the applications installed on this machine"
: "Start another installed application when you sign in"
action: root.addingAutostart ? "Close" : "Choose"
divider: !root.addingAutostart || DefaultApps.autostartEntries.length > 0
enabled: !DefaultApps.busy
onTriggered: root.addingAutostart = !root.addingAutostart
}
AutostartAppPicker {
visible: root.addingAutostart
width: parent.width
existing: DefaultApps.autostartEntries.map(entry => entry.id)
onPicked: id => {
DefaultApps.addAutostart(id);
root.addingAutostart = false;
}
}
TextRow {
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
@@ -32,7 +32,7 @@ SettingRow {
readonly property int leftIndex: root.channelIndex(PwAudioChannel.FrontLeft)
readonly property int rightIndex: root.channelIndex(PwAudioChannel.FrontRight)
readonly property bool available: root.node?.audio
readonly property bool available: !!root.node?.audio
&& root.leftIndex >= 0 && root.rightIndex >= 0
&& root.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex)
@@ -0,0 +1,77 @@
// Adds an installed application to the user's freedesktop autostart directory.
import QtQuick
import Quickshell
import qs.config
import qs.modules.clipboard
Column {
id: root
required property var existing
signal picked(string id)
spacing: 0
function desktopId(entry: var): string {
const id = String(entry?.id ?? "");
return id.endsWith(".desktop") ? id : id + ".desktop";
}
readonly property var matches: {
const needle = search.text.trim().toLowerCase();
if (needle === "")
return [];
const out = [];
for (const entry of DesktopEntries.applications.values) {
const desktopId = root.desktopId(entry);
if (entry.noDisplay || root.existing.indexOf(desktopId) >= 0)
continue;
const haystack = `${entry.name ?? ""} ${entry.genericName ?? ""} ${desktopId}`.toLowerCase();
if (haystack.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: String(candidate.modelData.name || root.desktopId(candidate.modelData))
detail: String(candidate.modelData.genericName || root.desktopId(candidate.modelData))
divider: candidate.index < root.matches.length - 1
controlWidth: 86
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Add"
onClicked: {
root.picked(root.desktopId(candidate.modelData));
search.text = "";
}
}
}
}
SettingRow {
visible: search.text.trim() !== "" && root.matches.length === 0
label: "No matching applications"
detail: "Only installed desktop applications can start with the session"
divider: false
}
}
@@ -67,6 +67,45 @@ SettingsPage {
}
// GNOME's Multitasking panel, in Hyprland's terms.
// Only meaningful when the layout above is Master and stack. Hidden
// otherwise, because a card of settings that do nothing under the layout
// you are actually running is worse than not offering the layout at all.
SettingsCard {
visible: DesktopPreferences.get("windowLayout") === "master"
title: "Master and stack"
subtitle: "How the master area behaves. These apply only while the tiling layout above is Master and stack."
SliderRow { setting: "masterFactor" }
ChoiceRow { setting: "masterOrientation" }
ChoiceRow { setting: "masterNewStatus" }
ToggleRow { setting: "masterNewOnTop"; divider: false }
}
SettingsCard {
title: "Window edges"
subtitle: "How the pointer grabs a window's border, and how floating windows behave near each other and the screen edge."
ToggleRow { setting: "resizeOnBorder" }
SliderRow { setting: "borderGrabArea"; zeroLabel: "Border only" }
ToggleRow { setting: "hoverIconOnBorder" }
SliderRow { setting: "snapWindowGap"; zeroLabel: "Touching" }
SliderRow { setting: "snapMonitorGap"; zeroLabel: "Touching" }
ToggleRow { setting: "snapRespectGaps"; divider: false }
}
// Hyprland's own interruptions. Panama turns all four off, which is a
// defensible default and was not previously a decision anyone could
// reverse without editing looks.lua.
SettingsCard {
title: "Hyprland notices"
subtitle: "Panama hides all of these by default. They are the compositor's own, not Panama's."
ToggleRow { setting: "hyprlandLogo" }
ToggleRow { setting: "hyprlandSplash" }
ToggleRow { setting: "hyprlandUpdateNews" }
ToggleRow { setting: "hyprlandDonationNag"; divider: false }
}
SettingsCard {
title: "Workspaces & focus"
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
@@ -74,8 +113,7 @@ SettingsPage {
ToggleRow { setting: "workspaceBackAndForth" }
ToggleRow { setting: "allowWorkspaceCycles" }
ToggleRow { setting: "focusOnActivate" }
ToggleRow { setting: "mouseMoveFocusesMonitor" }
ChoiceRow { setting: "followMouse"; divider: false }
ToggleRow { setting: "mouseMoveFocusesMonitor"; divider: false }
}
SettingsCard {
@@ -0,0 +1,318 @@
import QtQuick
import qs.config
import qs.widgets
import "../../services/DisplayLayout.js" as DisplayLayout
Item {
id: root
required property var displayService
property string selectedOutput: ""
property var draftLayout: []
property bool interactionEnabled: true
signal selectionRequested(string output)
implicitHeight: content.implicitHeight
readonly property var canvasData: DisplayLayout.canvasRects(
root.draftLayout, canvas.width, canvas.height, 18)
function copied(layout): var {
return (layout || []).map(record => Object.assign({}, record));
}
function resetDraft(): void {
root.draftLayout = root.copied(root.displayService.currentLayout());
}
function setDraftPosition(output: string, x: real, y: real, snapToEdges: bool): bool {
const next = root.copied(root.draftLayout);
const record = next.find(candidate => candidate.name === output);
if (!record)
return false;
record.x = Math.round(x);
record.y = Math.round(y);
root.draftLayout = snapToEdges ? DisplayLayout.snap(next, output, 16) : next;
return true;
}
function nudge(output: string, dx: int, dy: int): bool {
const record = root.draftLayout.find(candidate => candidate.name === output);
return !!record && root.setDraftPosition(output, record.x + dx, record.y + dy, false);
}
function applyDraft(): bool {
return root.displayService.applyLayout(root.copied(root.draftLayout));
}
function makePrimary(output: string): bool {
const next = root.copied(root.draftLayout);
if (!next.some(record => record.name === output))
return false;
for (const record of next)
record.primary = record.name === output;
root.draftLayout = DisplayLayout.normalize(next);
return root.applyDraft();
}
function canvasSnapshot(): var {
return {
bounds: root.canvasData.bounds,
scale: root.canvasData.scale,
rects: root.canvasData.rects.map(record => Object.assign({}, record))
};
}
Component.onCompleted: root.resetDraft()
Connections {
target: root.displayService
ignoreUnknownSignals: true
function onMonitorsChanged(): void {
if (!root.displayService.awaitingConfirmation)
root.resetDraft();
}
}
Column {
id: content
width: parent.width
spacing: 11
Rectangle {
id: canvas
width: parent.width
height: root.width >= 620 ? 232 : 190
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.76)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
clip: true
// A restrained coordinate field makes the topology feel like a
// precision instrument without turning it into a technical graph.
Repeater {
model: 4
Rectangle {
required property int index
y: (index + 1) * canvas.height / 5
width: canvas.width
height: 1
color: Theme.alpha(Theme.fg, 0.025)
}
}
Repeater {
model: root.canvasData.rects
Rectangle {
id: tile
required property var modelData
readonly property bool selected: root.selectedOutput === modelData.name
readonly property var draft: root.draftLayout.find(
record => record.name === modelData.name)
x: modelData.x
y: modelData.y
width: Math.max(64, modelData.width)
height: Math.max(48, modelData.height)
radius: 11
color: tile.selected
? Theme.alpha(Theme.bgHighlight, 0.92)
: Theme.alpha(Theme.bgPanel, hover.hovered ? 0.94 : 0.78)
border.width: tile.selected || activeFocus ? 2 : 1
border.color: activeFocus
? Theme.accentSecondary
: (tile.selected ? Theme.accent : Theme.alpha(Theme.fg, 0.14))
opacity: root.interactionEnabled ? 1 : 0.5
activeFocusOnTab: root.interactionEnabled
Accessible.role: Accessible.Button
Accessible.name: "Move " + tile.modelData.name
Accessible.description: tile.draft && tile.draft.primary
? "Primary display. Drag or use the arrow keys to move it."
: "Drag or use the arrow keys to move this display."
Rectangle {
anchors.fill: parent
anchors.margins: 2
radius: parent.radius - 2
visible: tile.selected
opacity: 0.24
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.accent }
GradientStop { position: 1; color: Theme.accentSecondary }
}
}
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 11
spacing: 2
Text {
width: parent.width
text: tile.modelData.name
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
width: parent.width
text: tile.draft
? `${Math.round(tile.draft.width / tile.draft.scale)} × ${Math.round(tile.draft.height / tile.draft.scale)}`
: ""
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 7
width: primaryText.implicitWidth + 12
height: 20
radius: Theme.pillRadius
visible: tile.draft && tile.draft.primary
color: Theme.alpha(Theme.accent, 0.2)
Text {
id: primaryText
anchors.centerIn: parent
text: "Primary"
color: Theme.accentAlt
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
font.weight: Font.DemiBold
}
}
HoverHandler {
id: hover
enabled: root.interactionEnabled
cursorShape: Qt.OpenHandCursor
}
TapHandler {
enabled: root.interactionEnabled
onTapped: {
root.selectionRequested(tile.modelData.name);
tile.forceActiveFocus();
}
}
DragHandler {
id: drag
target: null
enabled: root.interactionEnabled
property real initialX: 0
property real initialY: 0
property bool moved: false
onActiveChanged: {
if (active) {
const record = root.draftLayout.find(
candidate => candidate.name === tile.modelData.name);
initialX = record ? record.x : 0;
initialY = record ? record.y : 0;
moved = false;
root.selectionRequested(tile.modelData.name);
tile.forceActiveFocus();
} else if (moved) {
const record = root.draftLayout.find(
candidate => candidate.name === tile.modelData.name);
if (record) {
root.setDraftPosition(tile.modelData.name, record.x, record.y, true);
root.applyDraft();
}
}
}
onTranslationChanged: {
if (!active || root.canvasData.scale <= 0)
return;
moved = true;
root.setDraftPosition(
tile.modelData.name,
initialX + translation.x / root.canvasData.scale,
initialY + translation.y / root.canvasData.scale,
false);
}
}
Keys.onPressed: event => {
if (!root.interactionEnabled)
return;
const step = event.modifiers & Qt.ShiftModifier ? 100 : 10;
let handled = true;
if (event.key === Qt.Key_Left)
root.nudge(tile.modelData.name, -step, 0);
else if (event.key === Qt.Key_Right)
root.nudge(tile.modelData.name, step, 0);
else if (event.key === Qt.Key_Up)
root.nudge(tile.modelData.name, 0, -step);
else if (event.key === Qt.Key_Down)
root.nudge(tile.modelData.name, 0, step);
else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter)
root.applyDraft();
else if (event.key === Qt.Key_Escape)
root.resetDraft();
else
handled = false;
event.accepted = handled;
}
}
}
}
Row {
width: parent.width
spacing: 8
Text {
width: Math.max(0, parent.width - identifyButton.width
- primaryButton.width - applyButton.width - 24)
anchors.verticalCenter: parent.verticalCenter
text: root.width >= 600
? "Drag to arrange · arrows move 10 px · Shift moves 100 px"
: "Drag or use the arrow keys"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
SettingsButton {
id: identifyButton
text: "Identify"
enabled: root.interactionEnabled
onClicked: root.displayService.identify()
}
SettingsButton {
id: primaryButton
text: "Make primary"
enabled: root.interactionEnabled && root.selectedOutput !== ""
&& !root.draftLayout.find(record =>
record.name === root.selectedOutput)?.primary
onClicked: root.makePrimary(root.selectedOutput)
}
SettingsButton {
id: applyButton
text: "Apply"
enabled: root.interactionEnabled
onClicked: root.applyDraft()
}
}
}
}
@@ -0,0 +1,85 @@
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
import qs.widgets
Variants {
model: Quickshell.screens
PanelWindow {
id: win
property var modelData: null
readonly property string connector: win.modelData?.name ?? "Display"
readonly property int number: Math.max(1,
Displays.monitors.findIndex(monitor => monitor.name === win.connector) + 1)
readonly property string description: Displays.monitorNamed(win.connector)?.description ?? "Connected display"
screen: win.modelData
visible: Displays.identifying
implicitWidth: 260
implicitHeight: 172
color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore
mask: Region {}
WlrLayershell.namespace: "qs-display-identify"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
Rectangle {
anchors.fill: parent
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, 0.96)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.14)
PrismEdge {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
}
Column {
anchors.centerIn: parent
width: parent.width - 32
spacing: 4
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: String(win.number)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 68
font.weight: Font.DemiBold
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: win.connector
color: Theme.accentAlt
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: win.description
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
}
}
@@ -106,6 +106,20 @@ SettingsPage {
}
}
SettingsCard {
visible: Displays.monitors.length > 1
title: "Arrange displays"
subtitle: "Drag the screens into place. The primary display anchors the desktop at 0,0."
DisplayArrangement {
width: parent.width
displayService: Displays
selectedOutput: root.selectedOutput
interactionEnabled: !Displays.awaitingConfirmation && !Displays.busy
onSelectionRequested: output => root.selectedOutput = output
}
}
SettingsCard {
visible: Displays.monitors.length > 1
title: "Connected display"
@@ -0,0 +1,137 @@
import QtQuick
import qs.config
import qs.services
Item {
id: root
required property var check
property bool issue: false
property bool divider: true
property int actionActivationCount: 0
signal actionRequested(var check)
readonly property bool repairWorking: Health.repairingId === root.check.id
readonly property bool repairFailed: root.check.status !== "ok"
&& Health.lastRepair.checkId === root.check.id
&& (Health.lastRepair.accepted === false || Health.lastRepair.exitCode !== 0)
objectName: `health-check-row:${root.issue ? "issue" : "quiet"}:${root.check.id}`
implicitHeight: 62
// The isolated contract uses the same signal path as a pointer or keyboard
// activation instead of calling HealthPage's action handler directly.
function activateAction(): bool {
if (!actionButton.visible || !actionButton.enabled)
return false;
root.actionActivationCount += 1;
actionButton.clicked();
return true;
}
function statusLabel(status: string): string {
if (status === "ok") return "Healthy";
if (status === "warning") return "Needs attention";
if (status === "error") return "Action required";
return "Not set up";
}
function statusColor(status: string): color {
if (status === "ok") return Theme.ok;
if (status === "warning") return Theme.warn;
if (status === "error") return Theme.danger;
return Theme.fgMuted;
}
function displayedStatus(): string {
if (root.repairWorking)
return "Working…";
if (root.repairFailed)
return "Repair failed";
return root.statusLabel(root.check.status);
}
Rectangle {
id: issueMark
visible: root.issue
width: 7
height: 7
radius: 4
anchors.left: parent.left
anchors.leftMargin: 1
anchors.verticalCenter: parent.verticalCenter
color: root.statusColor(root.check.status)
border.width: 1
border.color: Theme.alpha(Theme.bgPanel, 0.9)
}
Column {
anchors.left: parent.left
anchors.leftMargin: root.issue ? 22 : 0
anchors.right: trailing.left
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: root.check.title
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.check.detail
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
Row {
id: trailing
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 12
Text {
objectName: `health-status-text:${root.issue ? "issue" : "quiet"}:${root.check.id}`
anchors.verticalCenter: parent.verticalCenter
text: root.displayedStatus()
color: root.statusColor(root.check.status)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
SettingsButton {
id: actionButton
objectName: `health-row-action:${root.issue ? "issue" : "quiet"}:${root.check.id}`
visible: root.check.action !== undefined
text: root.check.action?.label ?? ""
enabled: visible && !root.repairWorking && !Health.busy
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: root.actionRequested(root.check)
Keys.onReturnPressed: if (enabled) root.actionRequested(root.check)
Keys.onSpacePressed: if (enabled) root.actionRequested(root.check)
}
}
Rectangle {
visible: root.divider
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: Theme.alpha(Theme.fg, 0.07)
}
}
@@ -0,0 +1,458 @@
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "system-health-page"
title: "System Health"
lede: "Checks the parts of the desktop this app owns, and explains what needs attention."
property var pendingConfirmation: null
property string instructionTarget: ""
readonly property var issueChecks: Health.checks.filter(check => check.status === "warning" || check.status === "error")
readonly property var groups: [
{
group: "desktop-foundation",
title: "Desktop foundation",
subtitle: "Compositor, shell, portals, wallpaper, idle policy, and launcher."
},
{
group: "input-media",
title: "Input & media",
subtitle: "Sound, clipboard, capture, OCR, and display controls."
},
{
group: "integrations",
title: "Integrations",
subtitle: "Only configured integrations affect health."
},
{
group: "panama-tools",
title: "Desktop tools",
subtitle: "Tracked links, launcher commands, apps, and inhibitors."
}
]
readonly property var applicationTargets: ({
"integration.nextcloud": "nextcloud",
"integration.rustdesk": "rustdesk",
"integration.kdeconnect": "kdeconnect",
"integration.bluebubbles": "bluebubbles"
})
function statusLabel(status: string): string {
if (status === "ok") return "Healthy";
if (status === "warning") return "Needs attention";
if (status === "error") return "Action required";
return "Not set up";
}
function checksForGroup(group: string): var {
return Health.checks.filter(check => check.group === group
&& (check.status === "ok" || check.status === "unconfigured"));
}
function handleAction(check: var): void {
if (!check || !check.action)
return;
if (check.action.kind === "open") {
if (check.action.target) {
ShellState.openSettings(check.action.target);
return;
}
const application = root.applicationTargets[check.id];
if (application)
SystemSettings.openApplication(application);
return;
}
if (check.action.kind === "instructions") {
root.instructionTarget = check.action.target || "";
return;
}
if (check.action.kind !== "repair")
return;
if (check.action.confirm) {
root.pendingConfirmation = check;
return;
}
Health.repair(check.id, false);
}
function confirmRepair(): void {
const check = root.pendingConfirmation;
root.pendingConfirmation = null;
if (check)
Health.repair(check.id, false);
}
function descendants(item: var, prefix: string): var {
let matches = [];
if (!item)
return matches;
if (String(item.objectName || "").indexOf(prefix) === 0)
matches.push(item);
for (const child of item.children || [])
matches = matches.concat(root.descendants(child, prefix));
return matches;
}
function activateRenderedAction(id: string): bool {
const suffix = `:${id}`;
const rows = root.descendants(root, "health-check-row:").filter(row =>
row.visible && String(row.objectName).endsWith(suffix));
return rows.length === 1 && rows[0].activateAction();
}
function renderedFocusChain(): var {
// Repeater delegates enter Qt's tab chain lazily after their enabled
// binding changes at the end of a scan. Touch each rendered action's
// real next-focus link before traversing from the first hero control.
const renderedActions = root.descendants(root, "health-row-action:").filter(item =>
item.visible && item.enabled && item.activeFocusOnTab);
for (const action of renderedActions)
action.nextItemInFocusChain(true);
const starts = root.descendants(root, "health-copy-report-button").filter(item => item.visible && item.enabled);
if (starts.length !== 1)
return [];
const names = [];
const start = starts[0];
let current = start;
for (let index = 0; index < 128; index++) {
const name = String(current.objectName || "");
if (name !== "" && names.indexOf(name) < 0)
names.push(name);
current = current.nextItemInFocusChain(true);
if (!current || current === start)
break;
}
return names;
}
// Deterministic, read-only fixture seam used by the offscreen contract.
function uiDiagnostics(): var {
const summaries = root.descendants(root, "health-summary");
const rows = root.descendants(root, "health-check-row:").filter(row => row.visible);
const checkingLabels = root.descendants(root, "health-checking-label").filter(label => label.visible);
const confirmationSheets = root.descendants(root, "health-confirmation-sheet:").filter(sheet => sheet.visible);
const emptyGroups = root.descendants(root, "health-empty-group:").filter(label => label.visible);
const fedoraHandoffs = root.descendants(root, "health-fedora-handoff:").filter(row => row.visible);
return {
renderedRows: rows.map(row => {
const objectName = String(row.objectName);
const parts = objectName.split(":");
const statusTexts = root.descendants(row, "health-status-text:").filter(text => text.visible);
return {
objectName: objectName,
id: parts.slice(2).join(":"),
section: parts[1],
statusText: statusTexts.length === 1 ? statusTexts[0].text : ""
};
}),
summaryHeight: summaries.length > 0 ? summaries[0].height : 0,
rowHeights: rows.map(row => row.height),
checking: Health.busy,
checkingText: checkingLabels.length > 0 ? checkingLabels[0].text : "",
focusChain: root.renderedFocusChain(),
activatedRows: rows.filter(row => row.actionActivationCount > 0).map(row => String(row.objectName)),
emptyQuietGroups: emptyGroups.map(label => String(label.objectName).slice("health-empty-group:".length)),
fedoraHandoffs: fedoraHandoffs.map(row => ({
id: String(row.objectName).slice("health-fedora-handoff:".length),
label: row.label,
action: row.action
})),
confirmationVisible: confirmationSheets.length === 1,
confirmationId: confirmationSheets.length === 1
? String(confirmationSheets[0].objectName).slice("health-confirmation-sheet:".length)
: ""
};
}
Component.onCompleted: Health.refresh()
header: Component {
Rectangle {
objectName: `health-confirmation-sheet:${root.pendingConfirmation ? root.pendingConfirmation.id : ""}`
visible: root.pendingConfirmation !== null
implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0
radius: Theme.cardRadius
color: Theme.mix(Theme.bgPanel, Theme.warn, 0.1)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.34)
Row {
id: confirmRow
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 16
spacing: 14
Column {
width: parent.width - cancelButton.width - repairButton.width - 28
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: root.pendingConfirmation
? `${root.pendingConfirmation.action.label}?`
: "Restart the shell?"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: "The desktop chrome will disappear briefly and return when Quickshell restarts."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
SettingsButton {
id: cancelButton
text: "Cancel"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: root.pendingConfirmation = null
Keys.onReturnPressed: root.pendingConfirmation = null
Keys.onSpacePressed: root.pendingConfirmation = null
}
SettingsButton {
id: repairButton
text: root.pendingConfirmation ? root.pendingConfirmation.action.label : "Restart the shell"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: root.confirmRepair()
Keys.onReturnPressed: root.confirmRepair()
Keys.onSpacePressed: root.confirmRepair()
}
}
}
}
HealthSummary {}
SettingsCard {
visible: root.issueChecks.length > 0
title: Health.status === "error" ? "Action required" : "Needs attention"
subtitle: Health.status === "error"
? "Resolve these items first. Healthy systems remain listed below."
: "Nothing here prevents you from using the desktop."
Item {
width: parent.width
implicitHeight: issueRows.implicitHeight
Rectangle {
anchors.left: parent.left
anchors.leftMargin: 4
anchors.top: parent.top
anchors.topMargin: 25
anchors.bottom: parent.bottom
anchors.bottomMargin: 25
width: 1
visible: root.issueChecks.length > 1
color: Theme.alpha(Health.status === "error" ? Theme.danger : Theme.warn, 0.44)
}
Column {
id: issueRows
width: parent.width
Repeater {
id: issueRepeater
model: root.issueChecks
HealthCheckRow {
required property var modelData
required property int index
width: issueRows.width
check: modelData
issue: true
divider: index < issueRepeater.count - 1
onActionRequested: check => root.handleAction(check)
}
}
}
}
}
SettingsCard {
visible: root.instructionTarget === "ddc-permissions"
title: "External monitor brightness"
subtitle: "The monitor reports DDC/CI support, but this session cannot reach the monitor bus."
Item {
width: parent.width
implicitHeight: Math.max(instructionCopy.implicitHeight, doneButton.implicitHeight) + 8
Text {
id: instructionCopy
anchors.left: parent.left
anchors.right: doneButton.left
anchors.rightMargin: 18
anchors.verticalCenter: parent.verticalCenter
text: "Reload the installed udev rules and trigger the i2c-dev and DRM devices. Sign out and back in if monitor access is still unavailable."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
SettingsButton {
id: doneButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Done"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: root.instructionTarget = ""
Keys.onReturnPressed: root.instructionTarget = ""
Keys.onSpacePressed: root.instructionTarget = ""
}
}
}
Grid {
id: groupGrid
width: parent.width
columns: width >= 700 ? 2 : 1
columnSpacing: 16
rowSpacing: 16
Repeater {
model: root.groups
SettingsCard {
required property var modelData
readonly property var quietChecks: root.checksForGroup(modelData.group)
width: groupGrid.columns === 2
? (groupGrid.width - groupGrid.columnSpacing) / 2
: groupGrid.width
title: modelData.title
subtitle: modelData.subtitle
Column {
id: groupRows
width: parent.width
Text {
objectName: `health-empty-group:${modelData.group}`
width: parent.width
visible: quietChecks.length === 0
text: "Items needing attention are listed above."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
topPadding: 8
bottomPadding: 8
wrapMode: Text.WordWrap
}
Repeater {
id: groupRepeater
model: quietChecks
HealthCheckRow {
required property var modelData
required property int index
width: groupRows.width
check: modelData
divider: index < groupRepeater.count - 1
onActionRequested: check => root.handleAction(check)
}
}
}
}
}
}
SettingsCard {
title: "Fedora system settings"
subtitle: "These areas remain owned by Fedora and GNOME's mature system panels."
Item {
width: parent.width
implicitHeight: 42
Text {
anchors.left: parent.left
anchors.right: gnomeSettingsButton.left
anchors.rightMargin: 18
anchors.verticalCenter: parent.verticalCenter
text: "Use GNOME Settings for the parts of the system this app does not manage."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
SettingsButton {
id: gnomeSettingsButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Open GNOME Settings"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: SystemSettings.openGnomePanel("network")
Keys.onReturnPressed: SystemSettings.openGnomePanel("network")
Keys.onSpacePressed: SystemSettings.openGnomePanel("network")
}
}
ActionRow {
objectName: "health-fedora-handoff:users"
label: "Users"
detail: "Accounts, passwords, and automatic login"
action: "Open users"
onTriggered: SystemSettings.openGnomePanel("system", "users")
}
ActionRow {
objectName: "health-fedora-handoff:sharing"
label: "Sharing"
detail: "Remote desktop, media sharing, and remote login"
action: "Open sharing"
onTriggered: SystemSettings.openGnomePanel("sharing")
}
ActionRow {
objectName: "health-fedora-handoff:color"
label: "Colour profiles"
detail: "ICC profiles for displays, printers, and scanners"
action: "Open colour"
onTriggered: SystemSettings.openGnomePanel("color")
}
ActionRow {
objectName: "health-fedora-handoff:wellbeing"
label: "Digital wellbeing"
detail: "Screen time and break reminders"
action: "Open wellbeing"
divider: false
onTriggered: SystemSettings.openGnomePanel("wellbeing")
}
}
}
@@ -0,0 +1,169 @@
import QtQuick
import qs.config
import qs.services
SettingsCard {
id: root
objectName: "health-summary"
implicitHeight: 126
readonly property int observationCount: Health.summary.warnings + Health.summary.errors
readonly property string heroTitle: {
if (Health.diagnosticUnavailable)
return "Health check unavailable";
if (Health.checks.length === 0)
return "Checking the desktop";
if (Health.status === "error")
return "Action required";
if (Health.status === "warning")
return "Needs attention";
return "Healthy";
}
readonly property string heroDetail: {
if (Health.diagnosticUnavailable)
return Health.lastError || "The latest health check could not be completed.";
if (Health.checks.length === 0)
return "Checking the desktop services, tools, and integrations this app owns.";
if (Health.status === "error")
return root.observationCount === 1
? "One part of the desktop needs action."
: `${root.observationCount} parts of the desktop need action.`;
if (Health.status === "warning")
return root.observationCount === 1
? "Your desktop is working. One feature needs a decision."
: `Your desktop is working. ${root.observationCount} features need a decision.`;
return "Desktop services and tools are working normally.";
}
readonly property color statusColor: {
if (Health.diagnosticUnavailable || Health.status === "error")
return Theme.danger;
if (Health.status === "warning")
return Theme.warn;
if (Health.checks.length === 0)
return Theme.fgMuted;
return Theme.ok;
}
function scanTime(): string {
const generatedAt = String(Health.snapshot.generatedAt || "");
if (generatedAt === "")
return "No completed check yet";
const date = new Date(generatedAt);
if (Number.isNaN(date.getTime()))
return "Last check completed";
return `Last checked ${date.toLocaleTimeString(Qt.locale(), Locale.ShortFormat)}`;
}
Item {
width: parent.width
implicitHeight: 96
Column {
anchors.left: parent.left
anchors.right: actions.left
anchors.rightMargin: 22
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Text {
width: parent.width
text: Health.diagnosticUnavailable
? "DIAGNOSTICS"
: root.observationCount > 0
? root.observationCount + (root.observationCount === 1 ? " OBSERVATION" : " OBSERVATIONS")
: "PANAMA DESKTOP"
color: root.statusColor
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.letterSpacing: 1.15
}
Text {
width: parent.width
text: root.heroTitle
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 24
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.heroDetail
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
Row {
spacing: 10
Text {
text: root.scanTime()
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
objectName: "health-checking-label"
visible: Health.busy
text: "Checking…"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
visible: Health.lastCopyResult !== ""
text: Health.lastCopyResult
color: Health.lastCopyResult === "Report copied." ? Theme.ok : Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
Row {
id: actions
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
id: copyButton
objectName: "health-copy-report-button"
visible: !Health.diagnosticUnavailable
text: "Copy report"
enabled: Health.checks.length > 0
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: Health.copyReport()
Keys.onReturnPressed: if (enabled) Health.copyReport()
Keys.onSpacePressed: if (enabled) Health.copyReport()
}
SettingsButton {
id: refreshButton
objectName: "health-refresh-button"
text: Health.diagnosticUnavailable ? "Retry" : "Refresh"
tone: Health.diagnosticUnavailable ? "normal" : "accent"
enabled: !Health.busy
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : (tone === "accent" ? 0 : 1)
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: Health.refresh()
Keys.onReturnPressed: if (enabled) Health.refresh()
Keys.onSpacePressed: if (enabled) Health.refresh()
}
}
}
}
@@ -117,12 +117,6 @@ SettingsPage {
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
}
SettingsCard {
title: "System vitals"
subtitle: "Processor, memory, and graphics activity in the bar"
SliderRow { setting: "vitalsIntervalMs"; divider: false }
}
Grid {
id: summaryCards
@@ -0,0 +1,152 @@
import Quickshell
import QtQuick
import qs.config
import qs.services
Rectangle {
id: root
property string backgroundMode: DesktopPreferences.get("lockBackgroundMode")
property int blurLevel: DesktopPreferences.get("lockBlurLevel")
property bool showClock: DesktopPreferences.get("lockShowClock")
property bool showDate: DesktopPreferences.get("lockShowDate")
property bool showUser: DesktopPreferences.get("lockShowUser")
property bool fadeOnEmpty: DesktopPreferences.get("lockFadeOnEmpty")
property bool use24Hour: DesktopPreferences.get("use24Hour")
property string wallpaperPath: Wallpaper.active !== ""
? Wallpaper.active
: (Wallpaper.configured !== "" ? Wallpaper.configured : Wallpaper.shippedPath)
readonly property string previewMode: root.backgroundMode
readonly property real blurStrength: Math.max(0, Math.min(1, root.blurLevel / 5))
readonly property bool wallpaperVisible: wallpaper.visible
readonly property bool clockVisible: clockLabel.visible
readonly property bool dateVisible: dateLabel.visible
readonly property bool userVisible: userLabel.visible
readonly property bool passwordVisible: passwordField.visible
width: parent ? parent.width : 620
implicitHeight: 230
radius: Theme.cardRadius
clip: true
color: Theme.bg
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.10)
Image {
id: wallpaper
anchors.fill: parent
visible: root.backgroundMode === "wallpaper"
source: visible && root.wallpaperPath !== "" ? root.wallpaperPath : ""
asynchronous: true
cache: true
fillMode: Image.PreserveAspectCrop
sourceSize.width: 960
sourceSize.height: 540
}
// Screenshot mode stays representative instead of taking a real desktop
// capture inside Settings. The layered window silhouettes communicate the
// selected softness without a live ShaderEffect or GPU repaint loop.
Rectangle {
anchors.fill: parent
visible: root.backgroundMode === "screenshot"
color: Theme.bgDark
Rectangle {
x: parent.width * 0.10
y: parent.height * 0.12
width: parent.width * 0.45
height: parent.height * 0.66
radius: 14 + root.blurStrength * 10
color: Theme.alpha(Theme.bgHighlight, 0.52 - root.blurStrength * 0.18)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.14)
}
Rectangle {
x: parent.width * 0.48
y: parent.height * 0.24
width: parent.width * 0.40
height: parent.height * 0.57
radius: 14 + root.blurStrength * 10
color: Theme.alpha(Theme.bgPanel, 0.64 - root.blurStrength * 0.20)
border.width: 1
border.color: Theme.alpha(Theme.accentSecondary, 0.13)
}
}
Rectangle {
anchors.fill: parent
color: root.backgroundMode === "wallpaper"
? Theme.alpha(Theme.bg, 0.42)
: Theme.alpha(Theme.bg, 0.12 + root.blurStrength * 0.16)
}
Text {
id: clockLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 26
visible: root.showClock
text: Qt.formatDateTime(clock.date, root.use24Hour ? "HH:mm" : "h:mm")
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 42
font.weight: Font.Light
}
Text {
id: dateLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: clockLabel.visible ? clockLabel.bottom : parent.top
anchors.topMargin: clockLabel.visible ? 1 : 35
visible: root.showDate
text: Qt.formatDateTime(clock.date, "dddd, MMMM d")
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Rectangle {
id: passwordField
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: root.showUser ? 48 : 30
visible: !root.fadeOnEmpty
width: Math.min(270, parent.width * 0.48)
height: 36
radius: height / 2
color: Theme.alpha(Theme.bgPanel, 0.84)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.78)
Text {
anchors.centerIn: parent
text: "Password"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.italic: true
}
}
Text {
id: userLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 19
visible: root.showUser
text: Quickshell.env("USER") || "User"
color: Theme.alpha(Theme.fg, 0.90)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
SystemClock {
id: clock
precision: SystemClock.Minutes
}
}
@@ -31,7 +31,12 @@ SettingsPage {
ChoiceRow { setting: "accelProfile" }
ToggleRow { setting: "naturalScroll" }
SliderRow { setting: "scrollFactor" }
ToggleRow { setting: "leftHanded"; divider: false }
ToggleRow { setting: "leftHanded" }
ToggleRow {
setting: "middleClickPaste"
detail: "Paste the primary selection in GTK and native Wayland applications; individual apps may choose not to support it"
divider: false
}
}
SettingsCard {
@@ -4,6 +4,33 @@ 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.
## System Health
The stable internal `services` route renders **System Health**. It is reachable
from the Settings sidebar and its live 54px footer, the degraded-only bar
indicator, and Vicinae's **Panama: Check System Health** command. Healthy scans
reserve no bar space and produce no notification.
`services/Health.qml` owns the last accepted redacted snapshot. It invokes
`scripts/panama-doctor` for scans and bounded repairs, `wl-copy` only for an
explicit **Copy Report**, and bounded `notify-send` only when an external repair
fails. For a concise terminal view, run:
```bash
~/.config/quickshell/scripts/panama-doctor --summary
```
The helper diagnoses Panama-owned desktop services, dependencies, links, and
configured integrations. It does not read secret values, clipboard or
notification contents, calendar events, SSIDs, addresses, or arbitrary command
output. Its repair interface is an authored allow-list: it never installs a
package, runs `sudo`, deletes user data, or repairs a service Panama does not
own. A repair remains degraded until a fresh scan observes recovery.
The final card is the ownership boundary. Network configuration and the exact
Users, Sharing, Colour profiles, and Digital wellbeing handoffs open GNOME
Settings because Fedora's system services own those areas.
## Adding a setting
One schema entry. That is the whole job.
@@ -32,6 +59,47 @@ 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.
## Setting ownership
Every preference has **one primary page**, derived from its schema `group` and
the route in `services/SettingsSearch.qml`. Search results always open that
owner. A control may appear on a second page only when the same adaptation is
part of another established mental model; otherwise use a labelled handoff to
the owner instead of duplicating it.
### Intentional mirrors
| Setting | Primary page | Mirror | Why the mirror earns its place |
|---|---|---|---|
| `animationsEnabled` | Appearance | Accessibility | Reduced motion belongs both to visual polish and motion accessibility. |
| `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behaviour but affects motor and visual access. |
| `cursorSize` | Accessibility | Mouse | Large cursors are an accessibility adaptation that users also look for beside pointer controls. |
| `inactiveOpacity` | Appearance | Accessibility | Window translucency is an appearance choice with a direct readability impact. |
| `lockMinutes` | Power | Privacy | Idle timing owns the mechanism; privacy owns the expectation that the unattended desktop locks. |
| `lockOnSleep` | Power | Privacy | Suspend owns the transition; privacy owns whether waking requires authentication. |
Lock-screen visuals belong only to **Appearance**: background source, blur,
clock, date, user name, and password-field presentation. **Power** owns when
the session locks, while **Privacy** keeps only the established timing mirrors
above. Visual controls must not be copied onto either page.
**Displays** is the sole owner of mode, scale, rotation, arrangement, and primary role.
Those values form one safety transaction: every connected output
is applied, verified, confirmed, or restored together. Other pages may link to
Displays, but must never expose a second geometry control or persist a partial
layout.
Mirrors must remain the same schema-backed control, never a second preference
or a copied default. Additions to this table require a concrete discoverability
reason and an update to `tests/quickshell/settings-ownership-contract.sh`.
Window border colour follows the same ownership rule. The inactive border is a
**scheme-relative role** owned by `ColorScheme.qml`: it changes only to retain
neutral contrast in light and dark modes. The focused Prism border is the
accent role owned by the visual theme (and, eventually, an accent picker).
`ColorScheme.qml` must never write the focused border, so changing schemes
cannot erase a user-selected accent.
## The rows
| Component | For |
@@ -83,11 +151,12 @@ 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.
**A content-identical Quickshell entry can share the live shell's ID.**
Quickshell derives the Shell ID from config *content*, not path. Runtime
harnesses therefore create a distinct semantic entry file, address that exact
file with `qs -p`, and discover its PID from the exact Config path in
`qs list --all`. They terminate only that recorded PID with `kill`; never use
`qs kill` from a copied configuration.
## Where state lives
@@ -97,6 +166,7 @@ single distinct `.qml` file are safe; copying the whole directory is not.
| `$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 |
| `$XDG_STATE_HOME/panama/hyprlock.conf` | Generated lock-screen appearance |
`SystemSettings.restoreDefaults()` spans all of them. A reset that silently
skipped one would be worse than having no reset, because nothing would say so.
@@ -1,159 +0,0 @@
import QtQuick
import qs.config
import qs.services
SettingsPage {
title: "Startup & Services"
lede: "A clear view of the background tools that make the desktop feel complete."
function status(active: bool): string {
return active ? "Running" : "Stopped";
}
Item {
width: parent.width
implicitHeight: refresh.implicitHeight
SettingsButton {
id: refresh
anchors.right: parent.right
text: SystemSettings.busy ? "Refreshing…" : "Refresh"
enabled: !SystemSettings.busy
onClicked: SystemSettings.refresh()
}
}
SettingsCard {
title: "Your services"
SettingRow {
label: "Nextcloud"
detail: "File synchronization and tray status"
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text {
anchors.verticalCenter: parent.verticalCenter
text: status(SystemSettings.nextcloudActive)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
text: "Open"
onClicked: SystemSettings.openApplication("nextcloud")
}
}
}
SettingRow {
label: "RustDesk"
detail: "Remote access through the enabled system service"
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text {
anchors.verticalCenter: parent.verticalCenter
text: status(SystemSettings.rustdeskActive)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
text: "Open"
onClicked: SystemSettings.openApplication("rustdesk")
}
}
}
SettingRow {
label: "KDE Connect"
detail: "Phone pairing, clipboard, files, and remote controls"
divider: false
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text {
anchors.verticalCenter: parent.verticalCenter
text: status(SystemSettings.kdeconnectActive)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
text: "Open"
onClicked: SystemSettings.openApplication("kdeconnect")
}
}
}
}
SettingsCard {
title: "Desktop foundation"
TextRow {
label: "Hyprpaper"
detail: "Wallpaper service"
value: status(SystemSettings.hyprpaperActive)
}
TextRow {
label: "Hypridle"
detail: "Idle and lock policy"
value: status(SystemSettings.hypridleActive)
}
TextRow {
label: "Vicinae"
detail: "Spotlight-style launcher daemon"
value: status(SystemSettings.vicinaeActive)
divider: false
}
}
SettingsCard {
title: "Fedora system settings"
subtitle: "Panels this app does not own, because they configure system services rather than the desktop. Each row opens the panel that actually owns it. Printers and online accounts live with the rest of the network hardware, on Network & Devices."
// This was one row listing five subjects and opening the network panel
// regardless. Naming a panel and then not opening it is worse than not
// offering it: it looks like a broken button rather than a deliberate
// hand-off, and someone looking for printers had to know to navigate
// once GNOME Settings appeared on the wrong page.
ActionRow {
label: "Users"
detail: "Accounts, passwords, and automatic login"
action: "Open users"
onTriggered: SystemSettings.openGnomePanel("system", "users")
}
ActionRow {
label: "Sharing"
detail: "Remote desktop, media sharing, and remote login"
action: "Open sharing"
onTriggered: SystemSettings.openGnomePanel("sharing")
}
ActionRow {
label: "Colour profiles"
detail: "ICC profiles for displays, printers, and scanners"
action: "Open colour"
onTriggered: SystemSettings.openGnomePanel("color")
}
ActionRow {
label: "Digital wellbeing"
detail: "Screen time and break reminders"
action: "Open wellbeing"
divider: false
onTriggered: SystemSettings.openGnomePanel("wellbeing")
}
}
}
@@ -11,6 +11,19 @@ Rectangle {
&& pageLoader.item.objectName === "home-phone-page"
? pageLoader.item.pageDiagnostics
: ({})
readonly property var healthDiagnostics: pageLoader.status === Loader.Ready
&& pageLoader.item
&& pageLoader.item.objectName === "system-health-page"
? pageLoader.item.uiDiagnostics()
: ({})
function requestHealthAction(id: string): bool {
if (pageLoader.status !== Loader.Ready
|| !pageLoader.item
|| pageLoader.item.objectName !== "system-health-page")
return false;
return pageLoader.item.activateRenderedAction(id);
}
color: Theme.bg
radius: 18
@@ -107,7 +120,7 @@ Rectangle {
case "power": return powerPage;
case "datetime": return dateTimePage;
case "applications": return applicationsPage;
case "services": return servicesPage;
case "services": return healthPage;
case "about": return aboutPage;
default: return homePage;
}
@@ -161,7 +174,7 @@ Rectangle {
Component { id: privacyPage; PrivacyPage {} }
Component { id: regionPage; RegionPage {} }
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
Component { id: servicesPage; ServicesPage {} }
Component { id: healthPage; HealthPage {} }
Component { id: aboutPage; AboutPage {} }
Shortcut {
@@ -40,7 +40,7 @@ Rectangle {
{ 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: "System Health", icon: "\u{F0493}" },
{ page: "about", label: "About", icon: "\u{F02FD}" }
]
@@ -292,8 +292,34 @@ Rectangle {
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 54
color: Theme.alpha(Theme.bg, 0.35)
border.width: 0
activeFocusOnTab: true
color: footerTap.hovered || activeFocus
? Theme.alpha(Theme.fg, 0.07)
: Theme.alpha(Theme.bg, 0.35)
border.width: activeFocus ? 2 : 0
border.color: Theme.accent
function footerText(): string {
if (Health.checks.length === 0)
return Health.diagnosticUnavailable ? "Health check unavailable" : "Checking the desktop";
if (Health.status === "error")
return "Desktop needs attention";
if (Health.status === "warning") {
const count = Health.summary.warnings + Health.summary.errors;
return count + (count === 1 ? " health observation" : " health observations");
}
return "Desktop is healthy";
}
function footerColor(): color {
if (Health.checks.length === 0)
return Health.diagnosticUnavailable ? Theme.danger : Theme.fgMuted;
if (Health.status === "error")
return Theme.danger;
if (Health.status === "warning")
return Theme.warn;
return Theme.ok;
}
Rectangle {
width: 7
@@ -302,17 +328,28 @@ Rectangle {
anchors.left: parent.left
anchors.leftMargin: 19
anchors.verticalCenter: parent.verticalCenter
color: Theme.ok
color: healthFooter.footerColor()
}
Text {
anchors.left: parent.left
anchors.leftMargin: 36
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Desktop is healthy"
text: healthFooter.footerText()
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
TapHandler {
id: footerTap
onTapped: root.pageRequested("services")
}
Keys.onReturnPressed: root.pageRequested("services")
Keys.onSpacePressed: root.pageRequested("services")
}
}
@@ -21,6 +21,25 @@ SettingsPage {
// when nothing is being captured. Held here rather than per row so that
// starting a new capture cancels any other.
property string capturingChord: ""
readonly property string storedXkbOptions: String(DesktopPreferences.get("keyboardOptions") ?? "")
function xkbOptions(): var {
return root.storedXkbOptions
.split(",")
.map(option => option.trim())
.filter(option => option !== "");
}
function currentXkbOption(prefix: string): string {
return root.xkbOptions().find(option => option.indexOf(prefix) === 0) ?? "";
}
function setXkbOption(prefix: string, option: string): void {
const options = root.xkbOptions().filter(option => option.indexOf(prefix) !== 0);
if (option !== "")
options.push(option);
SystemSettings.commitPreference("keyboardOptions", options.join(","));
}
title: "Input & Shortcuts"
lede: "The Forge mental model, carried forward into native tiling."
@@ -35,6 +54,52 @@ SettingsPage {
// they are real controls.
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
ChoiceGrid {
width: parent.width
label: "Caps Lock"
detail: "Keep it conventional, or turn a prime keyboard position into Escape or Control"
current: root.currentXkbOption("caps:")
options: [
{ value: "", label: "Standard" },
{ value: "caps:escape_shifted_capslock", label: "Esc · Shift for Caps" },
{ value: "caps:escape", label: "Escape" },
{ value: "caps:ctrl_modifier", label: "Control" }
]
onPicked: value => root.setXkbOption("caps:", value)
}
ChoiceGrid {
width: parent.width
label: "Compose key"
detail: "Type accented characters and symbols with memorable key sequences"
current: root.currentXkbOption("compose:")
options: [
{ value: "", label: "Off" },
{ value: "compose:ralt", label: "Right Alt" },
{ value: "compose:rwin", label: "Right Super" },
{ value: "compose:menu", label: "Menu" }
]
onPicked: value => root.setXkbOption("compose:", value)
}
ChoiceGrid {
width: parent.width
label: "Layout switching"
detail: "Used when Keyboard layout contains more than one comma-separated layout"
current: root.currentXkbOption("grp:")
options: [
{ value: "", label: "Off" },
{ value: "grp:win_space_toggle", label: "Super + Space" },
{ value: "grp:alt_shift_toggle", label: "Alt + Shift" },
{ value: "grp:ctrl_shift_toggle", label: "Ctrl + Shift" },
{ value: "grp:caps_toggle", label: "Caps Lock" }
]
onPicked: value => root.setXkbOption("grp:", value)
}
// Presets preserve every option outside their own category. The raw
// value remains visible for less common xkeyboard-config features.
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
SliderRow { setting: "keyRepeatDelay" }
SliderRow { setting: "keyRepeatRate" }
@@ -31,6 +31,15 @@ SettingsPage {
}
}
SettingsCard {
title: "Applications"
subtitle: "Control each application currently playing through PipeWire."
ApplicationMixer {
width: parent.width
}
}
SettingsCard {
title: "Sound feedback"
subtitle: "Use the same event preferences as GTK and GNOME applications."
@@ -67,8 +76,8 @@ SettingsPage {
title: "Advanced sound"
ActionRow {
label: "Application volumes and profiles"
detail: "Open Fedora's complete sound panel"
label: "Device profiles"
detail: "Open Fedora's complete device profile panel"
divider: false
action: "Open panel"
onTriggered: SystemSettings.openGnomePanel("sound")
@@ -0,0 +1,103 @@
import QtQuick
import qs.config
import qs.services
import qs.widgets
Column {
id: root
property string mode: DesktopPreferences.get("wallpaperMode")
property var outputs: Wallpaper.outputNames()
property string selectedOutput: root.outputs.length > 0 ? root.outputs[0] : ""
property var setModeAction: function(mode) { Wallpaper.setMode(mode); }
property var setIntervalAction: function(minutes) { Wallpaper.setIntervalMinutes(minutes); }
property var setShuffleAction: function(enabled) { Wallpaper.setShuffle(enabled); }
width: parent ? parent.width : 620
spacing: 4
ChoiceGrid {
width: parent.width
label: "Wallpaper mode"
detail: "Use one image, rotate a collection, or choose per display"
options: PreferenceSchema.spec("wallpaperMode").options
current: root.mode
onPicked: value => root.setModeAction(value)
}
ChoiceGrid {
visible: root.mode === "per-monitor"
width: parent.width
label: "Display"
detail: "Choose which display the thumbnail grid assigns"
options: root.outputs.map(output => ({ value: output, label: output }))
current: root.selectedOutput
onPicked: value => root.selectedOutput = value
}
SettingRow {
visible: root.mode === "slideshow"
label: "Change background every"
detail: "Time between slideshow images"
controlWidth: 280
Item {
anchors.fill: parent
ValueSlider {
anchors.left: parent.left
anchors.right: intervalText.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
value: (DesktopPreferences.get("wallpaperIntervalMinutes") - 5) / 1435
onMoved: ratio => {
const raw = 5 + ratio * 1435;
root.setIntervalAction(Math.max(5, Math.min(1440, Math.round(raw / 5) * 5)));
}
}
Text {
id: intervalText
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 62
text: DesktopPreferences.get("wallpaperIntervalMinutes") + " min"
color: Theme.fgDim
font.family: Theme.fontMono
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignRight
}
}
}
SettingRow {
visible: root.mode === "slideshow"
label: "Shuffle"
detail: "Show every selected image before repeating"
divider: false
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: DesktopPreferences.get("wallpaperShuffle") === true
onToggled: enabled => root.setShuffleAction(enabled)
}
}
Text {
width: parent.width
visible: root.mode === "slideshow"
&& (DesktopPreferences.get("wallpaperSlideshowPaths") ?? []).length === 0
text: "Select two or more images below to begin a slideshow."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignHCenter
topPadding: 5
bottomPadding: 8
}
}
@@ -24,6 +24,14 @@ Item {
// unreachable without a long scroll past pictures. Two rows by default,
// all of them on request.
property bool expanded: false
property string mode: DesktopPreferences.get("wallpaperMode")
property string selectedOutput: Wallpaper.outputNames()[0] ?? ""
property var activeByOutput: Wallpaper.activeByOutput
property var slideshowPaths: DesktopPreferences.get("wallpaperSlideshowPaths") ?? []
property var assignments: DesktopPreferences.get("wallpaperPerMonitor") ?? ({})
property var setSingleAction: function(path) { Wallpaper.setSingle(path); }
property var toggleSlideshowAction: function(path) { Wallpaper.toggleSlideshowPath(path); }
property var setAssignmentAction: function(output, path) { Wallpaper.setAssignment(output, path); }
readonly property int collapsedRows: 2
readonly property var shown: root.expanded
@@ -35,6 +43,27 @@ Item {
readonly property int columns: Math.max(2, Math.floor(width / 190))
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
function isCurrent(path: string): bool {
return root.activeByOutput[root.selectedOutput] === path;
}
function selected(path: string): bool {
if (root.mode === "slideshow")
return root.slideshowPaths.includes(path);
if (root.mode === "per-monitor")
return root.assignments[root.selectedOutput] === path;
return root.isCurrent(path);
}
function activate(path: string): void {
if (root.mode === "slideshow")
root.toggleSlideshowAction(path);
else if (root.mode === "per-monitor")
root.setAssignmentAction(root.selectedOutput, path);
else
root.setSingleAction(path);
}
Grid {
id: grid
@@ -50,7 +79,8 @@ Item {
required property var modelData
readonly property bool current: Wallpaper.active === tile.modelData
readonly property bool current: root.isCurrent(tile.modelData)
readonly property bool member: root.mode === "slideshow" && root.selected(tile.modelData)
width: root.cellWidth
height: Math.round(root.cellWidth * 9 / 16)
@@ -114,6 +144,28 @@ Item {
border.color: Theme.accent
}
Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 8
width: 24
height: 24
radius: 12
visible: tile.member
color: Theme.alpha(Theme.bgDark, 0.82)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.16)
Text {
anchors.centerIn: parent
text: "✓"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 14
font.weight: Font.DemiBold
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
@@ -132,7 +184,9 @@ Item {
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 7
text: tile.current ? "Current wallpaper" : Wallpaper.titleFor(tile.modelData)
text: tile.current
? "Current wallpaper"
: (tile.member ? "In slideshow" : Wallpaper.titleFor(tile.modelData))
color: tile.current ? Theme.accent : Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
@@ -144,7 +198,7 @@ Item {
HoverHandler { id: hover }
TapHandler {
onTapped: Wallpaper.set(tile.modelData)
onTapped: root.activate(tile.modelData)
}
}
}
+10 -1
View File
@@ -10,7 +10,9 @@ DisplaysPage 1.0 DisplaysPage.qml
HomePage 1.0 HomePage.qml
NotificationsPage 1.0 NotificationsPage.qml
ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml
ServicesPage 1.0 ServicesPage.qml
HealthPage 1.0 HealthPage.qml
HealthSummary 1.0 HealthSummary.qml
HealthCheckRow 1.0 HealthCheckRow.qml
SettingRow 1.0 SettingRow.qml
SettingsCard 1.0 SettingsCard.qml
SettingsButton 1.0 SettingsButton.qml
@@ -27,22 +29,29 @@ ChoiceRow 1.0 ChoiceRow.qml
ActionRow 1.0 ActionRow.qml
TextRow 1.0 TextRow.qml
DesktopPreview 1.0 DesktopPreview.qml
LockScreenPreview 1.0 LockScreenPreview.qml
PowerPage 1.0 PowerPage.qml
DateTimePage 1.0 DateTimePage.qml
AccessibilityPage 1.0 AccessibilityPage.qml
WallpaperPicker 1.0 WallpaperPicker.qml
WallpaperControls 1.0 WallpaperControls.qml
ApplicationsPage 1.0 ApplicationsPage.qml
AutostartAppPicker 1.0 AutostartAppPicker.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
DisplayArrangement 1.0 DisplayArrangement.qml
DisplayIdentify 1.0 DisplayIdentify.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
ApplicationMixer 1.0 ApplicationMixer.qml
ApplicationVolumeRow 1.0 ApplicationVolumeRow.qml
TimeOfDayRow 1.0 TimeOfDayRow.qml
LocationPicker 1.0 LocationPicker.qml
FontPicker 1.0 FontPicker.qml
@@ -0,0 +1,129 @@
// The Alt-Tab overlay.
//
// Deliberately a list of names rather than thumbnails: at a glance you are
// looking for "the other terminal", and a row of small live previews is both
// slower to read and considerably more expensive to draw than the gesture
// deserves. The dock already renders app identity this way, so the two agree.
//
// Only present while a switch is in progress -- there is nothing to keep alive
// between gestures, and a hidden always-loaded overlay is a surface that can
// go wrong while nobody is looking at it.
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
import qs.widgets
Loader {
id: root
// Plain `modelData`, not `required property var screen`. Variants supplies
// modelData, and shell.qml's own comment warns about exactly this: declaring
// `required property var screen` means the screen never resolves, the window
// is constructed and silently never maps, and NOTHING is logged. Bar and
// Dock both take the screen this way.
property var modelData: null
active: WindowSwitcherState.open
asynchronous: false
sourceComponent: PanelWindow {
screen: root.modelData
// Overlay so it sits above the focused window it is describing.
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "qs-switcher"
// Nothing here is clickable: the gesture is driven entirely from the
// keyboard, and taking input would steal focus from the compositor
// mid-switch, which is the one thing that would break it.
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
color: "transparent"
anchors { top: true; bottom: true; left: true; right: true }
Rectangle {
anchors.centerIn: parent
width: Math.min(560, parent.width - 96)
implicitHeight: layout.implicitHeight + 24
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgPopover, 0.97)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.09)
Column {
id: layout
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 2
Repeater {
model: WindowSwitcherState.windows
Rectangle {
id: row
required property var modelData
required property int index
readonly property bool current: row.index === WindowSwitcherState.index
readonly property string appId: row.modelData?.wayland?.appId ?? ""
readonly property var entry: DesktopEntries.heuristicLookup(row.appId)
width: parent.width
height: 44
radius: 10
border.width: 0
color: row.current ? Theme.alpha(Theme.accent, 0.20) : "transparent"
Image {
id: icon
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
width: 24
height: 24
sourceSize.width: 24
sourceSize.height: 24
source: row.entry?.icon ? Quickshell.iconPath(row.entry.icon, true) : ""
visible: source !== ""
}
Text {
anchors.left: icon.visible ? icon.right : parent.left
anchors.leftMargin: icon.visible ? 12 : 14
anchors.right: appName.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
// A window with no title yet is still a window you
// can switch to; naming it after its application is
// better than an empty row.
text: row.modelData?.title || row.entry?.name || row.appId
elide: Text.ElideRight
color: row.current ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: row.current ? Font.DemiBold : Font.Normal
}
Text {
id: appName
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
visible: (row.entry?.name ?? "") !== "" && row.entry.name !== row.modelData?.title
text: row.entry?.name ?? ""
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
}
}
}
}
+2 -1
View File
@@ -109,6 +109,7 @@ case "$action" in
clipboard) qs ipc call clipboard open ;;
overview) qs ipc call overview open ;;
settings) qs ipc call settings open ;;
health) qs ipc call health open ;;
dnd)
state="$(qs ipc call notifications dnd)"
@@ -148,7 +149,7 @@ case "$action" in
;;
*)
printf 'Usage: panama-action {%s}\n' \
'control-center|notifications|calendar|clipboard|overview|settings|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2
'control-center|notifications|calendar|clipboard|overview|settings|health|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2
exit 2
;;
esac
@@ -37,8 +37,8 @@ def xdg_data_roots() -> list[Path]:
return [data_home, *(Path(item) for item in data_dirs.split(":") if item)]
def discovered_desktop_ids() -> set[str]:
desktop_ids: set[str] = set()
def discovered_desktop_files() -> dict[str, Path]:
desktop_files: dict[str, Path] = {}
for root in xdg_data_roots():
applications = root / "applications"
if not applications.is_dir():
@@ -47,8 +47,12 @@ def discovered_desktop_ids() -> set[str]:
if not path.is_file():
continue
relative = path.relative_to(applications)
desktop_ids.add("-".join(relative.parts))
return desktop_ids
desktop_files.setdefault("-".join(relative.parts), path)
return desktop_files
def discovered_desktop_ids() -> set[str]:
return set(discovered_desktop_files())
def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None:
@@ -184,12 +188,7 @@ def set_default(role: str, desktop_id: str) -> None:
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
def with_hidden(original: str, *, hidden: bool) -> str:
lines = original.splitlines()
output: list[str] = []
section = ""
@@ -216,24 +215,63 @@ def update_hidden(path: Path, *, hidden: bool) -> None:
raise BoundaryError("That autostart entry is not a desktop file.")
if not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}")
return "\n".join(output) + "\n"
mode = path.stat().st_mode
def write_atomic(path: Path, text: str, *, mode: int) -> None:
temporary_path: Path | None = None
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.write(text)
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():
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
raise BoundaryError("That autostart entry could not be updated.") from error
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
mode = path.stat().st_mode
write_atomic(path, with_hidden(original, hidden=hidden), mode=mode)
def add_autostart(desktop_id: str) -> None:
desktop_files = discovered_desktop_files()
require_desktop_id(desktop_id, discovered=set(desktop_files))
source = desktop_files[desktop_id]
directory = autostart_directory()
try:
directory.mkdir(parents=True, exist_ok=True)
except OSError as error:
raise BoundaryError("The user autostart directory could not be created.") from error
target = directory / desktop_id
if target.is_symlink():
raise BoundaryError("That autostart entry is not available.")
if target.exists():
if not target.is_file():
raise BoundaryError("That autostart entry is not available.")
update_hidden(target, hidden=False)
return
try:
original = source.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise BoundaryError("That application could not be read.") from error
write_atomic(target, with_hidden(original, hidden=False), mode=0o644)
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.")
@@ -260,10 +298,12 @@ def main(arguments: list[str]) -> int:
set_default(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-autostart":
set_autostart(arguments[1], arguments[2])
elif len(arguments) == 2 and arguments[0] == "add-autostart":
add_autostart(arguments[1])
else:
raise BoundaryError(
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | "
"set-autostart DESKTOP_ID true|false"
"set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID"
)
except BoundaryError as error:
print(str(error), file=sys.stderr)
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Report installed cursor and icon themes from the standard XDG roots.
This helper is intentionally read-only and argument-free. Theme paths come
only from XDG_DATA_HOME and XDG_DATA_DIRS; directory symlinks are not followed.
"""
from __future__ import annotations
import json
import os
import stat
import sys
from pathlib import Path
def icon_roots() -> list[Path]:
home = Path(os.environ.get("HOME") or "/nonexistent")
data_home = Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share")
data_dirs = os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share"
roots = [data_home / "icons"]
roots.extend(Path(directory) / "icons" for directory in data_dirs.split(":") if directory)
# XDG paths are required to be absolute. Ignoring malformed relative
# entries also prevents the helper's working directory becoming an
# accidental caller-controlled search root.
return [root for root in roots if root.is_absolute()]
def is_real_directory(path: Path) -> bool:
try:
return stat.S_ISDIR(path.lstat().st_mode)
except OSError:
return False
def is_real_file(path: Path) -> bool:
try:
return stat.S_ISREG(path.lstat().st_mode)
except OSError:
return False
def has_icon_directories(index_path: Path) -> bool:
try:
with index_path.open(encoding="utf-8", errors="replace") as handle:
for raw_line in handle:
line = raw_line.strip()
if line.startswith(("#", ";")) or "=" not in line:
continue
key, value = line.split("=", 1)
if key.strip() == "Directories":
return bool(value.strip())
except OSError:
return False
return False
def catalog() -> dict[str, list[str]]:
cursor_themes: set[str] = set()
icon_themes: set[str] = set()
for root in icon_roots():
if not is_real_directory(root):
continue
try:
entries = list(os.scandir(root))
except OSError:
continue
for entry in entries:
if not entry.is_dir(follow_symlinks=False):
continue
theme = Path(entry.path)
if is_real_directory(theme / "cursors"):
cursor_themes.add(entry.name)
index_path = theme / "index.theme"
if is_real_file(index_path) and has_icon_directories(index_path):
icon_themes.add(entry.name)
return {
"cursorThemes": sorted(cursor_themes, key=str.casefold),
"iconThemes": sorted(icon_themes, key=str.casefold),
}
def main() -> int:
if len(sys.argv) != 1:
print("panama-desktop-style takes no arguments", file=sys.stderr)
return 2
print(json.dumps(catalog(), ensure_ascii=False, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+909
View File
@@ -0,0 +1,909 @@
#!/usr/bin/env python3
"""Redacted diagnostics and bounded repairs for Panama-owned functionality."""
from __future__ import annotations
import argparse
import ctypes
import errno
import json
import os
import re
import secrets
import signal
import shutil
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from types import MappingProxyType
from typing import Callable, Literal
Status = Literal["ok", "warning", "error", "unconfigured"]
Group = Literal["desktop-foundation", "input-media", "integrations", "panama-tools"]
ActionKind = Literal["repair", "open", "instructions"]
InhibitorRow = tuple[str, str, str, str, str, str, str, str]
AT_FDCWD = -100
RENAME_NOREPLACE = 1
RENAME_EXCHANGE = 2
@dataclass(frozen=True)
class Action:
kind: ActionKind
label: str
confirm: bool = False
# Only authored Settings page IDs and instruction IDs are allowed here.
# Repair commands never receive a caller-controlled target.
target: str | None = None
@dataclass(frozen=True)
class Check:
id: str
group: Group
title: str
status: Status
detail: str
action: Action | None = None
@dataclass(frozen=True)
class DoctorConfig:
root: Path
home: Path
config_home: Path
state_home: Path
runtime_dir: Path
path: str
timeout: float
@property
def command_env(self) -> dict[str, str]:
environment = {
"PATH": self.path,
"HOME": str(self.home),
"XDG_CONFIG_HOME": str(self.config_home),
"XDG_STATE_HOME": str(self.state_home),
"XDG_RUNTIME_DIR": str(self.runtime_dir),
}
for name in PROBE_ENVIRONMENT_KEYS:
if value := os.environ.get(name):
environment[name] = value
return environment
@dataclass(frozen=True)
class CommandResult:
state: Literal["ok", "missing", "timeout", "failed", "unavailable"]
stdout: str = ""
@dataclass(frozen=True)
class RepairResult:
check_id: str
accepted: bool
exit_code: int
message: str
def as_json(self) -> dict[str, object]:
return {
"schemaVersion": 1,
"checkId": self.check_id,
"accepted": self.accepted,
"exitCode": self.exit_code,
"message": self.message,
}
CHECK_ORDER = (
"desktop.hyprland", "desktop.quickshell", "desktop.notifications", "desktop.portals",
"desktop.hyprpaper", "desktop.hypridle", "desktop.hyprlock", "desktop.vicinae", "input.pipewire",
"input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness",
"integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles",
"integration.home-assistant", "integration.calendar", "panama.runtime-links", "panama.vicinae-commands",
"panama.selected-terminal", "panama.selected-launcher", "panama.processes", "panama.caffeine",
)
SYSTEMCTL_COMMANDS = {
"hyprpaper": ("systemctl", "--user", "is-active", "--quiet", "hyprpaper.service"),
"hypridle": ("systemctl", "--user", "is-active", "--quiet", "hypridle.service"),
"vicinae": ("systemctl", "--user", "is-active", "--quiet", "vicinae.service"),
"pipewire": ("systemctl", "--user", "is-active", "--quiet", "pipewire.service"),
"nextcloud": ("systemctl", "--user", "is-active", "--quiet", "nextcloud.service"),
"rustdesk": ("systemctl", "--user", "is-active", "--quiet", "rustdesk.service"),
}
REPAIR_COMMANDS = MappingProxyType({
"desktop.hyprpaper": ("systemctl", "--user", "restart", "hyprpaper.service"),
"desktop.hypridle": ("systemctl", "--user", "restart", "hypridle.service"),
"desktop.vicinae": ("systemctl", "--user", "restart", "vicinae.service"),
"desktop.quickshell": ("panama-action", "restart-shell"),
})
RUNTIME_LINK_TARGETS = (
("hypr", Path("config/dot/hypr")),
("quickshell", Path("config/dot/quickshell")),
("uwsm", Path("config/dot/uwsm")),
("vicinae", Path("config/dot/vicinae")),
)
REPAIR_IDS = frozenset((*REPAIR_COMMANDS.keys(), "panama.runtime-links", "panama.vicinae-commands", "panama.caffeine"))
PROCESS_NAMES = ("vicinae", "hyprpaper", "hypridle")
VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b")
REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
PROBE_ENVIRONMENT_KEYS = (
"LANG",
"LC_ALL",
"LC_CTYPE",
"TZ",
"DBUS_SESSION_BUS_ADDRESS",
"WAYLAND_DISPLAY",
"DISPLAY",
"XAUTHORITY",
"PANAMA_DOCTOR_FIXTURE_STOPPED",
"PANAMA_DOCTOR_FIXTURE_PROCESSES",
"PANAMA_DOCTOR_FIXTURE_BUS",
"PANAMA_DOCTOR_FIXTURE_QS",
"PANAMA_DOCTOR_FIXTURE_QS_VERSION",
"PANAMA_DOCTOR_FIXTURE_BLUEBUBBLES",
"PANAMA_DOCTOR_FIXTURE_CALENDAR",
"PANAMA_DOCTOR_FIXTURE_BRIGHTNESS",
"PANAMA_DOCTOR_FIXTURE_CAFFEINE",
)
CHECK_TITLES = {
"desktop.hyprland": "Hyprland",
"desktop.quickshell": "Quickshell",
"desktop.notifications": "Notifications",
"desktop.portals": "Desktop portals",
"desktop.hyprpaper": "Hyprpaper",
"desktop.hypridle": "Hypridle",
"desktop.hyprlock": "Lock screen",
"desktop.vicinae": "Vicinae",
"input.pipewire": "PipeWire",
"input.clipboard": "Clipboard",
"input.wallpaper": "Wallpaper",
"input.capture": "Capture",
"input.ocr": "OCR",
"input.brightness": "External monitor brightness",
"integration.nextcloud": "Nextcloud",
"integration.rustdesk": "RustDesk",
"integration.kdeconnect": "KDE Connect",
"integration.bluebubbles": "BlueBubbles",
"integration.home-assistant": "Home Assistant",
"integration.calendar": "Calendar",
"panama.runtime-links": "Panama runtime links",
"panama.vicinae-commands": "Panama commands",
"panama.selected-terminal": "Selected terminal",
"panama.selected-launcher": "Selected launcher",
"panama.processes": "Panama processes",
"panama.caffeine": "Caffeine inhibitor",
}
def environment_path(name: str, default: Path) -> Path:
value = os.environ.get(name)
return Path(value).expanduser() if value else default
def config_from_environment() -> DoctorConfig:
home = environment_path("PANAMA_DOCTOR_HOME", Path.home())
config_home = environment_path("PANAMA_DOCTOR_CONFIG_HOME", Path(os.environ.get("XDG_CONFIG_HOME", home / ".config")))
state_home = environment_path("PANAMA_DOCTOR_STATE_HOME", Path(os.environ.get("XDG_STATE_HOME", home / ".local/state")))
runtime_dir = environment_path("PANAMA_DOCTOR_RUNTIME_DIR", Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/0")))
root = environment_path("PANAMA_DOCTOR_ROOT", Path(__file__).resolve().parents[4])
try:
timeout = float(os.environ.get("PANAMA_DOCTOR_TIMEOUT", "3"))
except ValueError:
timeout = 3.0
return DoctorConfig(root, home, config_home, state_home, runtime_dir, os.environ.get("PANAMA_DOCTOR_PATH", os.environ.get("PATH", "")), max(0.05, min(timeout, 15.0)))
def run_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None = None) -> CommandResult:
"""Run an authored read-only command without reporting its unparsed output."""
try:
completed = subprocess.run(command, capture_output=True, text=True, timeout=config.timeout, check=False, env=config.command_env, cwd=cwd)
except FileNotFoundError:
return CommandResult("missing")
except subprocess.TimeoutExpired:
return CommandResult("timeout")
except OSError:
return CommandResult("unavailable")
if completed.returncode != 0:
return CommandResult("failed")
return CommandResult("ok", completed.stdout)
def run_repair_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None = None) -> tuple[int, str]:
"""Execute one authored repair argv and retain output only for strict parsing."""
try:
completed = subprocess.run(
command,
capture_output=True,
text=True,
timeout=config.timeout,
check=False,
env=config.command_env,
cwd=cwd,
)
except FileNotFoundError:
return 127, ""
except subprocess.TimeoutExpired:
return 124, ""
except OSError:
return 126, ""
exit_code = completed.returncode if 0 <= completed.returncode <= 255 else 1
return exit_code, completed.stdout
def executable_exists(name: str, config: DoctorConfig) -> bool:
return shutil.which(name, path=config.path) is not None
def action_json(action: Action) -> dict[str, object]:
result: dict[str, object] = {"kind": action.kind, "label": action.label, "confirm": action.confirm}
if action.target is not None:
result["target"] = action.target
return result
def check_json(check: Check) -> dict[str, object]:
result: dict[str, object] = {"id": check.id, "group": check.group, "title": check.title, "status": check.status, "detail": check.detail}
if check.action is not None:
result["action"] = action_json(check.action)
return result
def group_for(check_id: str) -> Group:
if check_id.startswith("desktop."):
return "desktop-foundation"
if check_id.startswith("input."):
return "input-media"
if check_id.startswith("integration."):
return "integrations"
return "panama-tools"
def service_check(check_id: str, title: str, service: str, config: DoctorConfig, action: Action | None = None) -> Check:
result = run_command(SYSTEMCTL_COMMANDS[service], config)
if result.state == "ok":
return Check(check_id, group_for(check_id), title, "ok", "Service is active.")
if result.state in {"missing", "unavailable"}:
return Check(check_id, group_for(check_id), title, "error", "Required system service probe is unavailable.")
return Check(check_id, group_for(check_id), title, "warning", "Service is not active.", action)
def ipc_target(config: DoctorConfig, target: str) -> CommandResult:
result = run_command(("qs", "ipc", "show"), config)
if result.state != "ok":
return result
return CommandResult("ok") if f"target {target}" in result.stdout.splitlines() else CommandResult("failed")
def simple_ipc_check(check_id: str, title: str, target: str, config: DoctorConfig) -> Check:
result = ipc_target(config, target)
if result.state == "ok":
return Check(check_id, "input-media", title, "ok", "Panama IPC target is available.")
if result.state == "missing":
return Check(check_id, "input-media", title, "error", "Required Quickshell executable is unavailable.")
if result.state == "timeout":
return Check(check_id, "input-media", title, "warning", "Panama IPC probe timed out.")
return Check(check_id, "input-media", title, "warning", "Panama IPC target is unavailable.")
def check_hyprland(config: DoctorConfig) -> Check:
if "hyprland" in os.environ.get("XDG_CURRENT_DESKTOP", "").casefold():
return Check("desktop.hyprland", "desktop-foundation", "Hyprland", "ok", "Hyprland session detected.")
return Check("desktop.hyprland", "desktop-foundation", "Hyprland", "error", "Hyprland session is not active.")
def check_quickshell(config: DoctorConfig) -> Check:
result = run_command(("qs", "--version"), config)
repair = Action("repair", "Restart Panama", True)
if result.state == "ok" and VERSION_PATTERN.search(result.stdout):
return Check("desktop.quickshell", "desktop-foundation", "Quickshell", "ok", "Quickshell executable is available.")
if result.state == "missing":
return Check("desktop.quickshell", "desktop-foundation", "Quickshell", "error", "Required Quickshell executable is unavailable.", repair)
return Check("desktop.quickshell", "desktop-foundation", "Quickshell", "warning", "Quickshell probe returned an invalid result.", repair)
def check_notifications(config: DoctorConfig) -> Check:
result = ipc_target(config, "notifications")
return Check("desktop.notifications", "desktop-foundation", "Notifications", "ok", "Notification service is available.") if result.state == "ok" else Check("desktop.notifications", "desktop-foundation", "Notifications", "warning", "Notification service is unavailable.")
def check_portals(config: DoctorConfig) -> Check:
result = run_command(("busctl", "--user", "--no-pager", "list"), config)
if result.state == "ok" and any(line.startswith("org.freedesktop.portal.Desktop ") for line in result.stdout.splitlines()):
return Check("desktop.portals", "desktop-foundation", "Desktop portals", "ok", "Desktop portal service is available.")
detail = "Desktop portal probe timed out." if result.state == "timeout" else "Desktop portal probe is unavailable." if result.state == "missing" else "Desktop portal service is unavailable."
return Check("desktop.portals", "desktop-foundation", "Desktop portals", "warning", detail)
def check_hyprlock(config: DoctorConfig) -> Check:
helper = config.root / "config/dot/quickshell/scripts/panama-lock"
tracked_fallback = config.config_home / "hypr/hyprlock.conf"
generated_config = config.state_home / "panama/hyprlock.conf"
# The doctor seals PATH for every probe. Invoke the authored helper with a
# fixed system-only PATH so its bash shebang and jq dependency remain
# available without inheriting arbitrary parent executables.
result = run_command(("/usr/bin/env", "PATH=/usr/bin:/bin", str(helper), "status"), config)
if result.state != "ok":
if tracked_fallback.is_file():
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "warning", "Managed lock-screen status is unavailable; the tracked fallback remains available.")
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "error", "No usable lock-screen configuration is available.")
try:
state = json.loads(result.stdout)
generated = state["generated"]
path = state["path"]
fallback = state["fallback"]
error = state["error"]
if not isinstance(generated, bool) or not isinstance(path, str) \
or not isinstance(fallback, bool) or not isinstance(error, str):
raise ValueError
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
if tracked_fallback.is_file():
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "warning", "Managed lock-screen status is invalid; the tracked fallback remains available.")
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "error", "No usable lock-screen configuration is available.")
if generated and not fallback and generated_config.is_file():
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "ok", "Managed lock-screen configuration is available.")
if tracked_fallback.is_file():
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "warning", "The tracked lock-screen fallback is in use.")
return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "error", "No usable lock-screen configuration is available.")
def check_brightness(config: DoctorConfig) -> Check:
result = run_command(("panama-brightness", "list"), config)
instructions = Action("instructions", "View setup instructions", target="ddc-permissions")
if result.state == "timeout":
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI probe timed out.", instructions)
if result.state != "ok":
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI support is unavailable.", instructions)
try:
listing = json.loads(result.stdout)
displays, error = listing["displays"], listing["error"]
if not isinstance(displays, list) or not isinstance(error, str):
raise ValueError
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI probe returned an invalid result.", instructions)
if error:
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "No accessible DDC/CI bus.", instructions)
if not displays:
return Check("input.brightness", "input-media", "External monitor brightness", "unconfigured", "No DDC/CI display is configured.")
return Check("input.brightness", "input-media", "External monitor brightness", "ok", f"{len(displays)} DDC/CI display{'s' if len(displays) != 1 else ''} available.")
def check_nextcloud(config: DoctorConfig) -> Check:
if not (config.config_home / "autostart" / "nextcloud.desktop").is_file():
return Check("integration.nextcloud", "integrations", "Nextcloud", "unconfigured", "Nextcloud autostart is not configured.")
return service_check("integration.nextcloud", "Nextcloud", "nextcloud", config, Action("open", "Open Nextcloud"))
def check_rustdesk(config: DoctorConfig) -> Check:
if not executable_exists("rustdesk", config):
return Check("integration.rustdesk", "integrations", "RustDesk", "unconfigured", "RustDesk is not installed.")
return service_check("integration.rustdesk", "RustDesk", "rustdesk", config, Action("open", "Open RustDesk"))
def check_kdeconnect(config: DoctorConfig) -> Check:
if not executable_exists("kdeconnect-cli", config):
return Check("integration.kdeconnect", "integrations", "KDE Connect", "unconfigured", "KDE Connect is not installed.")
result = run_command(("busctl", "--user", "--no-pager", "list"), config)
if result.state == "ok" and any(line.startswith("org.kde.kdeconnect ") for line in result.stdout.splitlines()):
return Check("integration.kdeconnect", "integrations", "KDE Connect", "ok", "KDE Connect service is available.")
return Check("integration.kdeconnect", "integrations", "KDE Connect", "warning", "KDE Connect service is unavailable.", Action("open", "Open KDE Connect"))
def check_bluebubbles(config: DoctorConfig) -> Check:
result = run_command(("flatpak", "info", "app.bluebubbles.BlueBubbles"), config)
if result.state == "ok":
return Check("integration.bluebubbles", "integrations", "BlueBubbles", "ok", "BlueBubbles is installed.")
if result.state in {"missing", "failed"}:
return Check("integration.bluebubbles", "integrations", "BlueBubbles", "unconfigured", "BlueBubbles is not installed.")
return Check("integration.bluebubbles", "integrations", "BlueBubbles", "warning", "BlueBubbles installation probe timed out.", Action("open", "Open BlueBubbles"))
def check_home_assistant(config: DoctorConfig) -> Check:
configured = all(name in os.environ for name in ("PANAMA_HOME_ASSISTANT_URL", "PANAMA_HOME_ASSISTANT_TOKEN"))
helper = config.config_home / "quickshell" / "scripts" / "panama-home-assistant"
if not configured:
return Check("integration.home-assistant", "integrations", "Home Assistant", "unconfigured", "Home Assistant is not configured.")
if not helper.is_file():
return Check("integration.home-assistant", "integrations", "Home Assistant", "warning", "Home Assistant bridge is unavailable.", Action("open", "Open Home settings", target="home-phone"))
return Check("integration.home-assistant", "integrations", "Home Assistant", "ok", "Home Assistant credentials are configured.")
def check_calendar(config: DoctorConfig) -> Check:
result = run_command(("calendar-agenda", "probe"), config)
action = Action("open", "Open Date & Time", target="datetime")
if result.state == "missing":
return Check("integration.calendar", "integrations", "Calendar", "unconfigured", "Calendar integration is not installed.")
if result.state == "timeout":
return Check("integration.calendar", "integrations", "Calendar", "warning", "Calendar probe timed out.", action)
if result.state != "ok":
return Check("integration.calendar", "integrations", "Calendar", "warning", "Calendar probe failed.", action)
try:
enabled_sources = json.loads(result.stdout)["enabledSources"]
if not isinstance(enabled_sources, int) or isinstance(enabled_sources, bool):
raise ValueError
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
return Check("integration.calendar", "integrations", "Calendar", "warning", "Calendar probe returned an invalid result.", action)
if enabled_sources <= 0:
return Check("integration.calendar", "integrations", "Calendar", "unconfigured", "No enabled calendar source is configured.")
return Check("integration.calendar", "integrations", "Calendar", "ok", f"{enabled_sources} enabled calendar source{'s' if enabled_sources != 1 else ''} configured.")
def check_runtime_links(config: DoctorConfig) -> Check:
def valid_link(name: str, relative_source: Path) -> bool:
destination = config.config_home / name
source = config.root / relative_source
try:
return source.is_dir() and destination.is_symlink() \
and destination.resolve(strict=False) == source.resolve(strict=True)
except OSError:
return False
if any(not valid_link(name, relative_source) for name, relative_source in RUNTIME_LINK_TARGETS):
return Check("panama.runtime-links", "panama-tools", "Panama runtime links", "warning", "One or more Panama runtime links are unavailable.", Action("repair", "Repair runtime links"))
return Check("panama.runtime-links", "panama-tools", "Panama runtime links", "ok", "Panama runtime links are available.")
def check_vicinae_commands(config: DoctorConfig) -> Check:
source = config.root / "config/local/share/vicinae/scripts"
installed = config.home / ".local/share/vicinae/scripts/panama"
try:
linked = source.is_dir() and installed.is_symlink() \
and installed.resolve(strict=False) == source.resolve(strict=True)
except OSError:
linked = False
if linked:
return Check("panama.vicinae-commands", "panama-tools", "Panama commands", "ok", "Panama Vicinae commands are linked.")
return Check("panama.vicinae-commands", "panama-tools", "Panama commands", "warning", "Panama Vicinae commands are not linked.", Action("repair", "Repair command link"))
def executable_check(check_id: str, title: str, executable: str, config: DoctorConfig) -> Check:
if executable_exists(executable, config):
return Check(check_id, group_for(check_id), title, "ok", f"{title} executable is available.")
return Check(check_id, group_for(check_id), title, "warning", f"{title} executable is unavailable.")
def check_processes(config: DoctorConfig) -> Check:
# `qs` is both the long-running shell and every short-lived IPC client.
# Counting it with pgrep races the other parallel health probes and reports
# duplicates whenever one of them happens to call `qs ipc`. The instance
# list is the authoritative view and contains only actual shells.
quickshell = run_command(("qs", "list"), config)
if quickshell.state == "ok":
quickshell_count = sum(
line.startswith("Instance ") for line in quickshell.stdout.splitlines()
)
elif quickshell.state == "failed":
quickshell_count = 0
else:
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe is unavailable.")
counts: list[int] = [quickshell_count]
for name in PROCESS_NAMES:
result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config)
if result.state == "ok":
pids = result.stdout.splitlines()
if not pids or any(not pid.isdecimal() for pid in pids):
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe returned an invalid result.")
counts.append(len(pids))
elif result.state == "failed":
counts.append(0)
else:
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe is unavailable.")
if any(count > 1 for count in counts):
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Duplicate Panama desktop processes detected.")
if counts[0] == 0:
return Check("panama.processes", "panama-tools", "Panama processes", "error", "Quickshell process is not running.")
return Check("panama.processes", "panama-tools", "Panama processes", "ok", "Panama desktop process counts are normal.")
def check_caffeine(config: DoctorConfig) -> Check:
result = run_command(("systemd-inhibit", "--list", "--no-pager", "--no-legend"), config)
if result.state != "ok":
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe is unavailable.")
inhibitor_rows = parse_caffeine_rows(result.stdout, str(os.getuid()))
if inhibitor_rows is None:
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe returned an invalid result.")
inhibitors = len(dict.fromkeys(int(row[3]) for row in inhibitor_rows))
if inhibitors > 1:
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Duplicate Panama Caffeine inhibitors detected.", Action("repair", "Release duplicate inhibitors"))
if inhibitors == 1:
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "ok", "One Panama Caffeine inhibitor is active.")
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "ok", "No Panama Caffeine inhibitor is active.")
def parse_version(result: CommandResult, pattern: re.Pattern[str] = VERSION_PATTERN) -> str:
match = pattern.search(result.stdout) if result.state == "ok" else None
return match.group(0) if match else "unavailable"
def context_versions(config: DoctorConfig) -> list[dict[str, str]]:
hyprland = run_command(("hyprctl", "version"), config)
quickshell = run_command(("qs", "--version"), config)
revision = run_command(("git", "rev-parse", "--short", "HEAD"), config, config.root)
fedora = "unavailable"
try:
match = re.search(r"^VERSION_ID=\"?([^\n\"]+)", Path("/etc/os-release").read_text(encoding="utf-8"), re.MULTILINE)
if match and re.fullmatch(r"[0-9.]+", match.group(1)):
fedora = match.group(1)
except OSError:
pass
return [{"id": "hyprland", "version": parse_version(hyprland)}, {"id": "quickshell", "version": parse_version(quickshell)}, {"id": "fedora", "version": fedora}, {"id": "panama", "version": parse_version(revision, REVISION_PATTERN)}]
def unavailable_check(check_id: str) -> Check:
return Check(check_id, group_for(check_id), CHECK_TITLES[check_id], "warning", "Diagnostic probe could not be completed.")
def unavailable_versions() -> list[dict[str, str]]:
return [{"id": name, "version": "unavailable"} for name in ("hyprland", "quickshell", "fedora", "panama")]
def collect_checks(config: DoctorConfig) -> list[Check]:
probes: dict[str, Callable[[], Check]] = {
"desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config),
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.hyprlock": lambda: check_hyprlock(config), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
"input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config),
"integration.nextcloud": lambda: check_nextcloud(config), "integration.rustdesk": lambda: check_rustdesk(config), "integration.kdeconnect": lambda: check_kdeconnect(config), "integration.bluebubbles": lambda: check_bluebubbles(config), "integration.home-assistant": lambda: check_home_assistant(config), "integration.calendar": lambda: check_calendar(config),
"panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config),
}
with ThreadPoolExecutor(max_workers=8) as executor:
futures = {check_id: executor.submit(probes[check_id]) for check_id in CHECK_ORDER}
checks: list[Check] = []
for check_id in CHECK_ORDER:
try:
checks.append(futures[check_id].result())
except Exception:
checks.append(unavailable_check(check_id))
return checks
def snapshot(config: DoctorConfig) -> dict[str, object]:
try:
checks = collect_checks(config)
except Exception:
checks = [unavailable_check(check_id) for check_id in CHECK_ORDER]
counts = {status: sum(check.status == status for check in checks) for status in ("ok", "warning", "error", "unconfigured")}
overall: Literal["healthy", "warning", "error"] = "error" if counts["error"] else "warning" if counts["warning"] else "healthy"
session = "hyprland" if "hyprland" in os.environ.get("XDG_CURRENT_DESKTOP", "").casefold() else "other"
try:
versions = context_versions(config)
except Exception:
versions = unavailable_versions()
return {"schemaVersion": 1, "generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "summary": {"status": overall, "healthy": counts["ok"], "warnings": counts["warning"], "errors": counts["error"], "unconfigured": counts["unconfigured"]}, "context": {"session": session, "versions": versions}, "checks": [check_json(check) for check in checks]}
def repair_authored_command(check_id: str, config: DoctorConfig) -> RepairResult:
command = REPAIR_COMMANDS[check_id]
exit_code, _ = run_repair_command(command, config)
message = "Repair completed. A fresh health check will verify recovery." if exit_code == 0 \
else "The authored repair command could not be completed."
return RepairResult(check_id, True, exit_code, message)
def lexical_path(path: Path) -> Path:
"""Normalize dot segments without following any filesystem symlink."""
return Path(os.path.abspath(os.fspath(path)))
def lexical_link_target(destination: Path) -> Path:
target = Path(os.readlink(destination))
return lexical_path(target if target.is_absolute() else destination.parent / target)
def renameat2(source: Path, destination: Path, flags: int) -> None:
"""Call Linux renameat2 with fixed flags selected by authored code."""
libc = ctypes.CDLL(None, use_errno=True)
function = getattr(libc, "renameat2", None)
if function is None:
raise OSError(errno.ENOSYS, "renameat2 is unavailable")
function.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
function.restype = ctypes.c_int
result = function(
AT_FDCWD,
os.fsencode(source),
AT_FDCWD,
os.fsencode(destination),
flags,
)
if result != 0:
error = ctypes.get_errno()
raise OSError(error, os.strerror(error), destination)
def rename_exchange(source: Path, destination: Path) -> None:
renameat2(source, destination, RENAME_EXCHANGE)
def rename_noreplace(source: Path, destination: Path) -> None:
renameat2(source, destination, RENAME_NOREPLACE)
def create_symlink_candidate(destination: Path, source: Path) -> Path:
"""Create one unpredictable authored sibling candidate symlink."""
for _ in range(32):
candidate = destination.with_name(
f".panama-link-{destination.name}-{os.getpid()}-{secrets.token_hex(8)}"
)
try:
os.symlink(source, candidate, target_is_directory=True)
return candidate
except FileExistsError:
continue
raise OSError("Could not allocate an authored temporary link")
def cleanup_candidate(candidate: Path) -> None:
try:
candidate.unlink()
except FileNotFoundError:
pass
def install_absent_symlink(destination: Path, source: Path) -> Literal["repaired", "blocked", "failed"]:
candidate = create_symlink_candidate(destination, source)
try:
try:
rename_noreplace(candidate, destination)
except FileExistsError:
return "blocked"
except OSError:
return "failed"
return "repaired"
finally:
cleanup_candidate(candidate)
def exchange_owned_symlink(
destination: Path,
source: Path,
authored_sources: frozenset[Path],
) -> Literal["repaired", "blocked", "failed"]:
"""Exchange first, then validate the exact object removed from destination."""
candidate = create_symlink_candidate(destination, source)
exchanged = False
rolled_back = False
try:
try:
rename_exchange(candidate, destination)
exchanged = True
except OSError:
return "failed"
try:
old_is_authored = candidate.is_symlink() \
and lexical_link_target(candidate) in authored_sources
except OSError:
old_is_authored = False
if old_is_authored:
cleanup_candidate(candidate)
return "repaired"
try:
rename_exchange(candidate, destination)
rolled_back = True
except OSError:
# The displaced object remains at the unpredictable candidate path;
# never unlink it when rollback could not restore ownership.
return "failed"
try:
restored_candidate_is_ours = candidate.is_symlink() \
and lexical_link_target(candidate) == source
except OSError:
restored_candidate_is_ours = False
if not restored_candidate_is_ours:
return "failed"
cleanup_candidate(candidate)
return "blocked"
finally:
if not exchanged or rolled_back:
try:
if candidate.is_symlink() and lexical_link_target(candidate) == source:
cleanup_candidate(candidate)
except OSError:
pass
def repair_runtime_links(config: DoctorConfig) -> RepairResult:
root = lexical_path(config.root)
sources = [(name, lexical_path(config.root / relative_source)) for name, relative_source in RUNTIME_LINK_TARGETS]
if any(not source.is_dir() for _, source in sources):
return RepairResult("panama.runtime-links", True, 1, "Tracked Panama link destinations are unavailable.")
if any(not source.is_relative_to(root) for _, source in sources):
return RepairResult("panama.runtime-links", True, 1, "Tracked Panama link destinations are invalid.")
authored_sources = frozenset(source for _, source in sources)
try:
config.config_home.mkdir(parents=True, exist_ok=True)
except OSError:
return RepairResult("panama.runtime-links", True, 1, "Panama runtime links could not be accessed.")
blocked = False
failed = False
for name, source in sources:
destination = config.config_home / name
try:
if destination.is_symlink():
current_target = lexical_link_target(destination)
if current_target == source:
continue
if current_target not in authored_sources:
blocked = True
continue
outcome = exchange_owned_symlink(destination, source, authored_sources)
blocked = blocked or outcome == "blocked"
failed = failed or outcome == "failed"
elif destination.exists():
# A regular file or directory is user-owned unless proven
# otherwise. Report it, but never replace it.
blocked = True
else:
outcome = install_absent_symlink(destination, source)
blocked = blocked or outcome == "blocked"
failed = failed or outcome == "failed"
except OSError:
failed = True
if failed:
return RepairResult("panama.runtime-links", True, 1, "One or more Panama runtime links could not be recreated.")
if blocked:
return RepairResult("panama.runtime-links", True, 1, "A user-owned runtime path is blocking a Panama link.")
return RepairResult("panama.runtime-links", True, 0, "Panama runtime links were recreated. A fresh health check will verify them.")
def repair_vicinae_commands(config: DoctorConfig) -> RepairResult:
helper = config.root / "setup/scripts/link-vicinae-scripts"
if not helper.is_file():
return RepairResult("panama.vicinae-commands", True, 127, "The authored Vicinae link helper is unavailable.")
exit_code, _ = run_repair_command((str(helper),), config, config.root)
message = "Panama commands were relinked. A fresh health check will verify them." if exit_code == 0 \
else "Panama commands could not be relinked."
return RepairResult("panama.vicinae-commands", True, exit_code, message)
def parse_caffeine_rows(output: str, uid: str) -> list[InhibitorRow] | None:
inhibitor_rows: list[InhibitorRow] = []
for line in output.splitlines():
parts = line.split()
if len(parts) < 2 or parts[0] != "Panama" or parts[1] != uid:
continue
if len(parts) != 8:
if "Caffeine" in parts:
return None
continue
if parts[6] != "Caffeine" or parts[7] != "block":
continue
if not parts[3].isdecimal():
return None
inhibitor_rows.append(tuple(parts))
return inhibitor_rows
def close_pidfds(pidfds: list[int]) -> None:
for pidfd in pidfds:
try:
os.close(pidfd)
except OSError:
pass
def signal_caffeine_pidfds(
pidfds: list[int],
sender: Callable[..., None] | None = None,
) -> Literal["released", "preflight-failed", "incomplete"]:
send = sender or signal.pidfd_send_signal
for pidfd in pidfds:
try:
send(pidfd, 0, None, 0)
except (OSError, ValueError):
return "preflight-failed"
incomplete = False
for pidfd in pidfds:
try:
send(pidfd, signal.SIGTERM, None, 0)
except ProcessLookupError:
continue
except OSError as error:
if error.errno != errno.ESRCH:
incomplete = True
except ValueError:
incomplete = True
return "incomplete" if incomplete else "released"
def repair_caffeine(config: DoctorConfig) -> RepairResult:
list_command = ("systemd-inhibit", "--list", "--no-pager", "--no-legend")
list_exit, output = run_repair_command(list_command, config)
if list_exit != 0:
return RepairResult("panama.caffeine", True, list_exit, "Caffeine inhibitors could not be inspected.")
uid = str(os.getuid())
inhibitor_rows = parse_caffeine_rows(output, uid)
if inhibitor_rows is None:
return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.")
inhibitor_pids = list(dict.fromkeys(int(row[3]) for row in inhibitor_rows))
if len(inhibitor_pids) <= 1:
return RepairResult("panama.caffeine", True, 0, "No duplicate Panama Caffeine inhibitors needed release.")
duplicates = inhibitor_pids[1:]
if not hasattr(os, "pidfd_open") or not hasattr(signal, "pidfd_send_signal"):
return RepairResult("panama.caffeine", True, 1, "Safe Caffeine inhibitor release is unavailable on this system.")
pidfds: list[int] = []
try:
try:
pidfds = [os.pidfd_open(pid, 0) for pid in duplicates]
except (OSError, ValueError):
return RepairResult("panama.caffeine", True, 1, "A duplicate inhibitor changed before it could be safely released.")
second_exit, second_output = run_repair_command(list_command, config)
if second_exit != 0:
return RepairResult("panama.caffeine", True, second_exit, "Caffeine inhibitors could not be revalidated; nothing was released.")
second_rows = parse_caffeine_rows(second_output, uid)
if second_rows is None or second_rows != inhibitor_rows:
return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata changed; nothing was released.")
signal_outcome = signal_caffeine_pidfds(pidfds)
if signal_outcome == "preflight-failed":
return RepairResult("panama.caffeine", True, 1, "A duplicate inhibitor changed before it could be safely released.")
if signal_outcome == "incomplete":
return RepairResult("panama.caffeine", True, 1, "One or more duplicate inhibitors could not be released.")
finally:
close_pidfds(pidfds)
return RepairResult("panama.caffeine", True, 0, "Duplicate Panama Caffeine inhibitors were released. A fresh health check will verify recovery.")
def repair(check_id: str, config: DoctorConfig) -> RepairResult:
if check_id in REPAIR_COMMANDS:
return repair_authored_command(check_id, config)
if check_id == "panama.runtime-links":
return repair_runtime_links(config)
if check_id == "panama.vicinae-commands":
return repair_vicinae_commands(config)
if check_id == "panama.caffeine":
return repair_caffeine(config)
return RepairResult(check_id, False, 2, "This health check has no authored repair.")
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="Panama system diagnostics and bounded repairs")
output = parser.add_mutually_exclusive_group()
output.add_argument("--json", action="store_true")
output.add_argument("--summary", action="store_true")
parser.add_argument("--repair", metavar="CHECK_ID")
args = parser.parse_args(argv)
if args.repair is not None:
if args.repair not in REPAIR_IDS or args.summary:
result = RepairResult(args.repair, False, 2, "This health check has no authored repair.")
else:
try:
result = repair(args.repair, config_from_environment())
except Exception:
result = RepairResult(args.repair, True, 1, "The authored repair could not be completed.")
print(json.dumps(result.as_json(), separators=(",", ":"), sort_keys=False))
return result.exit_code
result = snapshot(config_from_environment())
if args.summary:
summary = result["summary"]
assert isinstance(summary, dict)
print(f"Panama system health: {summary['status']} ({summary['healthy']} ok, {summary['warnings']} warnings, {summary['errors']} errors, {summary['unconfigured']} unconfigured)")
else:
print(json.dumps(result, separators=(",", ":"), sort_keys=False))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+1 -1
View File
@@ -59,7 +59,7 @@ generate() {
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'
printf ' lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run\n'
if [[ "$lock_on_sleep" == "true" ]]; then
printf ' before_sleep_cmd = loginctl lock-session\n'
fi
+100 -5
View File
@@ -25,6 +25,9 @@ PLUGIN_ACTIONS = {
"kdeconnect_share": "share",
}
DEVICE_OBJECT_PREFIX = "/modules/kdeconnect/devices"
DEVICE_OBJECT_LINE = re.compile(
rf"(?P<path>{re.escape(DEVICE_OBJECT_PREFIX)}/(?P<id>[A-Fa-f0-9]{{32,64}}))$"
)
Runner = Callable[..., subprocess.CompletedProcess[str]]
@@ -107,6 +110,13 @@ def parse_string_property(output: str) -> str:
return parts[1] if len(parts) == 2 and parts[0] == "s" else ""
def parse_bool_property(output: str) -> bool | None:
parts = output.split()
if len(parts) != 2 or parts[0] != "b" or parts[1] not in {"true", "false"}:
return None
return parts[1] == "true"
def run_command(
command: list[str],
*,
@@ -179,6 +189,82 @@ def reported_type(device_id: str, runner: Runner = subprocess.run) -> str:
return parse_string_property(result.stdout) if result.returncode == 0 else ""
def device_property(
device_id: str,
member: str,
runner: Runner = subprocess.run,
) -> subprocess.CompletedProcess[str]:
return run_command(
[
"busctl",
"--user",
"get-property",
"org.kde.kdeconnect",
device_object(device_id),
"org.kde.kdeconnect.device",
member,
],
runner=runner,
)
def dbus_device_ids(runner: Runner = subprocess.run) -> list[str]:
try:
result = run_command(
["busctl", "--user", "tree", "org.kde.kdeconnect"],
runner=runner,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
if result.returncode != 0:
return []
return [
match.group("id")
for line in result.stdout.splitlines()
if (match := DEVICE_OBJECT_LINE.search(line.strip())) is not None
]
def dbus_devices(runner: Runner = subprocess.run) -> list[dict[str, object]]:
devices: list[dict[str, object]] = []
for device_id in dbus_device_ids(runner):
try:
name_result = device_property(device_id, "name", runner)
type_result = device_property(device_id, "type", runner)
paired_result = device_property(device_id, "isPaired", runner)
reachable_result = device_property(device_id, "isReachable", runner)
except (FileNotFoundError, subprocess.TimeoutExpired):
continue
name = parse_string_property(name_result.stdout) if name_result.returncode == 0 else ""
device_type = parse_string_property(type_result.stdout) if type_result.returncode == 0 else ""
paired = parse_bool_property(paired_result.stdout) if paired_result.returncode == 0 else None
reachable = parse_bool_property(reachable_result.stdout) if reachable_result.returncode == 0 else None
if not name or paired is not True or reachable is None:
continue
try:
plugins = device_plugins(device_id, runner)
except (FileNotFoundError, subprocess.TimeoutExpired):
plugins = []
actions = sorted(
{
action
for plugin, action in PLUGIN_ACTIONS.items()
if plugin in plugins
}
)
devices.append(
{
"id": device_id,
"name": name,
"type": device_type or inferred_type(name),
"paired": paired,
"reachable": reachable,
"actions": actions,
}
)
return devices
def collect_status(runner: Runner = subprocess.run) -> dict[str, object]:
try:
listing = run_command(
@@ -200,15 +286,24 @@ def collect_status(runner: Runner = subprocess.run) -> dict[str, object]:
continue
device_id = match.group("id")
try:
device = normalize_device_line(
line,
device_plugins(device_id, runner),
reported_type(device_id, runner),
)
plugins = device_plugins(device_id, runner)
device_type = reported_type(device_id, runner)
except (FileNotFoundError, subprocess.TimeoutExpired):
plugins = []
device_type = ""
try:
device = normalize_device_line(line, plugins, device_type)
except ValueError:
continue
devices.append(device)
known_ids = {str(device["id"]) for device in devices}
devices.extend(
device
for device in dbus_devices(runner)
if str(device["id"]) not in known_ids
)
devices.sort(
key=lambda device: (
not bool(device["reachable"]),
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env bash
# Generates Panama's hyprlock configuration into the state directory. The
# tracked config is never rewritten and remains the fallback if generation
# fails, so a malformed preference can never leave the session without a
# working locker.
set -euo pipefail
config_home="${XDG_CONFIG_HOME:-$HOME/.config}"
state_home="${XDG_STATE_HOME:-$HOME/.local/state}"
settings="$config_home/panama/settings.json"
state_dir="$state_home/panama"
generated="$state_dir/hyprlock.conf"
status_file="$state_dir/hyprlock-status.json"
fallback="$config_home/hypr/hyprlock.conf"
temporary="$generated.tmp.$$"
status_temporary="$status_file.tmp.$$"
settings_valid=false
cleanup() {
rm -f "$temporary" "$status_temporary" 2>/dev/null || true
}
trap cleanup EXIT
read_string() {
local key="$1" default="$2" value
if [[ "$settings_valid" != true ]]; then
printf '%s' "$default"
return
fi
value="$(jq -er --arg key "$key" \
'if has($key) and (.[$key] | type) == "string" then .[$key] else empty end' \
"$settings" 2>/dev/null)" || value="$default"
printf '%s' "$value"
}
read_bool() {
local key="$1" default="$2" value
if [[ "$settings_valid" != true ]]; then
printf '%s' "$default"
return
fi
value="$(jq -er --arg key "$key" \
'if has($key) and (.[$key] | type) == "boolean" then (.[$key] | tostring) else empty end' \
"$settings" 2>/dev/null)" || value="$default"
printf '%s' "$value"
}
read_int() {
local key="$1" default="$2" low="$3" high="$4" value
if [[ "$settings_valid" != true ]]; then
printf '%s' "$default"
return
fi
value="$(jq -er --arg key "$key" \
'if has($key) and (.[$key] | type) == "number" and (.[$key] | floor) == .[$key]
then (.[$key] | tostring) else empty end' "$settings" 2>/dev/null)" || value="$default"
if [[ ! "$value" =~ ^-?[0-9]+$ ]] || (( value < low || value > high )); then
value="$default"
fi
printf '%s' "$value"
}
read_object() {
local key="$1"
if [[ "$settings_valid" != true ]]; then
printf '{}'
return
fi
jq -c --arg key "$key" \
'if has($key) and (.[$key] | type) == "object" then .[$key] else {} end' \
"$settings" 2>/dev/null || printf '{}'
}
valid_path() {
[[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* ]]
}
load_preferences() {
if [[ -r "$settings" ]] && jq -e 'type == "object"' "$settings" >/dev/null 2>&1; then
settings_valid=true
else
settings_valid=false
fi
background_mode="$(read_string lockBackgroundMode screenshot)"
[[ "$background_mode" == screenshot || "$background_mode" == wallpaper || "$background_mode" == solid ]] \
|| background_mode=screenshot
blur_level="$(read_int lockBlurLevel 3 0 5)"
show_clock="$(read_bool lockShowClock true)"
show_date="$(read_bool lockShowDate true)"
show_user="$(read_bool lockShowUser true)"
fade_on_empty="$(read_bool lockFadeOnEmpty false)"
use_24_hour="$(read_bool use24Hour false)"
color_scheme="$(read_string colorScheme dark)"
[[ "$color_scheme" == dark || "$color_scheme" == light ]] || color_scheme=dark
wallpaper_mode="$(read_string wallpaperMode single)"
[[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \
|| wallpaper_mode=single
wallpaper_path="$(read_string wallpaperPath '')"
if ! valid_path "$wallpaper_path"; then
wallpaper_path="$HOME/Pictures/Wallpapers/faroe_islands.jpg"
fi
wallpaper_assignments="$(read_object wallpaperPerMonitor)"
case "$blur_level" in
0) blur_passes=0; blur_size=1 ;;
1) blur_passes=1; blur_size=3 ;;
2) blur_passes=2; blur_size=5 ;;
3) blur_passes=3; blur_size=8 ;;
4) blur_passes=4; blur_size=10 ;;
5) blur_passes=5; blur_size=12 ;;
esac
if [[ "$color_scheme" == light ]]; then
background_color='rgba(225, 226, 231, 1.0)'
foreground_color='rgba(55, 96, 191, 1.0)'
dim_color='rgba(97, 114, 176, 1.0)'
accent_color='rgba(46, 125, 233, 1.0)'
accent_ring_color='rgba(46, 125, 233, 0.9)'
error_color='rgba(245, 42, 101, 1.0)'
field_color='rgba(208, 213, 227, 0.85)'
dim_hex='6172b0'
error_hex='f52a65'
else
background_color='rgba(34, 36, 54, 1.0)'
foreground_color='rgba(200, 211, 245, 1.0)'
dim_color='rgba(130, 139, 184, 1.0)'
accent_color='rgba(130, 170, 255, 1.0)'
accent_ring_color='rgba(130, 170, 255, 0.9)'
error_color='rgba(255, 117, 127, 1.0)'
field_color='rgba(46, 47, 61, 0.85)'
dim_hex='828bb8'
error_hex='ff757f'
fi
}
monitor_names() {
hyprctl -j monitors 2>/dev/null \
| jq -r '.[]? | .name | select(type == "string") | select(test("^[A-Za-z0-9_.-]+$"))' \
2>/dev/null || true
}
emit_background() {
local monitor="$1" path="$2"
printf 'background {\n'
printf ' monitor = %s\n' "$monitor"
if [[ -n "$path" ]]; then
printf ' path = %s\n' "$path"
fi
printf ' blur_passes = %s\n' "$blur_passes"
printf ' blur_size = %s\n' "$blur_size"
printf ' noise = 0.0117\n'
printf ' contrast = 0.9\n'
printf ' brightness = 0.8\n'
printf ' vibrancy = 0.17\n'
printf ' vibrancy_darkness = 0.05\n'
printf ' color = %s\n' "$background_color"
printf ' zindex = -1\n'
printf '}\n\n'
}
emit_backgrounds() {
local output assigned saw_output=false
case "$background_mode" in
screenshot)
emit_background '' screenshot
;;
solid)
emit_background '' ''
;;
wallpaper)
while IFS= read -r output; do
[[ -n "$output" ]] || continue
saw_output=true
assigned="$wallpaper_path"
if [[ "$wallpaper_mode" == per-monitor ]]; then
candidate="$(jq -r --arg output "$output" \
'if has($output) and (.[$output] | type) == "string" then .[$output] else "" end' \
<<<"$wallpaper_assignments" 2>/dev/null || true)"
if valid_path "$candidate"; then
assigned="$candidate"
fi
fi
emit_background "$output" "$assigned"
done < <(monitor_names)
if [[ "$saw_output" != true ]]; then
emit_background '' "$wallpaper_path"
fi
;;
esac
}
emit_config() {
printf '# Generated by Panama. Do not edit.\n\n'
printf 'general {\n'
printf ' hide_cursor = true\n'
printf ' fractional_scaling = 2\n'
printf ' screencopy_mode = 0\n'
printf ' fail_timeout = 2000\n'
printf '}\n\n'
printf 'auth {\n'
printf ' pam:enabled = true\n'
printf ' pam:module = hyprlock\n'
printf '}\n\n'
emit_backgrounds
if [[ "$show_clock" == true ]]; then
printf 'label {\n'
printf ' monitor =\n'
if [[ "$use_24_hour" == true ]]; then
printf ' text = cmd[update:1000] date +"%%H:%%M"\n'
else
printf ' text = cmd[update:1000] date +"%%-I:%%M"\n'
fi
printf ' color = %s\n' "$foreground_color"
printf ' font_size = 120\n'
printf ' font_family = Adwaita Sans Light\n'
printf ' position = 0, 260\n'
printf ' halign = center\n'
printf ' valign = center\n'
printf '}\n\n'
fi
if [[ "$show_date" == true ]]; then
printf 'label {\n'
printf ' monitor =\n'
printf ' text = cmd[update:60000] date +"%%A, %%B %%-d"\n'
printf ' color = %s\n' "$dim_color"
printf ' font_size = 24\n'
printf ' font_family = Adwaita Sans\n'
printf ' position = 0, 160\n'
printf ' halign = center\n'
printf ' valign = center\n'
printf '}\n\n'
fi
printf 'input-field {\n'
printf ' monitor =\n'
printf ' size = 340, 52\n'
printf ' position = 0, -40\n'
printf ' halign = center\n'
printf ' valign = center\n'
printf ' outline_thickness = 2\n'
printf ' rounding = 26\n'
printf ' outer_color = %s\n' "$accent_ring_color"
printf ' inner_color = %s\n' "$field_color"
printf ' font_color = %s\n' "$foreground_color"
printf ' check_color = %s\n' "$accent_color"
printf ' fail_color = %s\n' "$error_color"
printf ' dots_size = 0.25\n'
printf ' dots_spacing = 0.3\n'
printf ' dots_center = true\n'
printf ' placeholder_text = <span foreground="##%s"><i>Password</i></span>\n' "$dim_hex"
printf ' fail_text = <span foreground="##%s"><i>$FAIL ($ATTEMPTS)</i></span>\n' "$error_hex"
printf ' fade_on_empty = %s\n' "$fade_on_empty"
printf ' hide_input = false\n'
printf '}\n\n'
if [[ "$show_user" == true ]]; then
printf 'label {\n'
printf ' monitor =\n'
printf ' text = $USER\n'
printf ' color = %s\n' "$foreground_color"
printf ' font_size = 16\n'
printf ' font_family = Adwaita Sans\n'
printf ' position = 0, -110\n'
printf ' halign = center\n'
printf ' valign = center\n'
printf '}\n'
fi
}
write_status() {
local generated_value="$1" path_value="$2" fallback_value="$3" error_value="$4"
mkdir -p "$state_dir" 2>/dev/null || return 0
[[ -w "$state_dir" ]] || return 0
jq -nc --argjson generated "$generated_value" --arg path "$path_value" \
--argjson fallback "$fallback_value" --arg error "$error_value" \
'{generated:$generated,path:$path,fallback:$fallback,error:$error}' \
>"$status_temporary" 2>/dev/null || return 0
mv "$status_temporary" "$status_file" 2>/dev/null || true
}
generate() {
load_preferences
mkdir -p "$state_dir" || return 1
if ! emit_config >"$temporary"; then
rm -f "$temporary" 2>/dev/null || true
write_status false "$fallback" true "The lock-screen configuration could not be generated."
return 1
fi
if [[ ! -s "$temporary" ]] \
|| ! rg -q '^auth \{' "$temporary" \
|| ! rg -q '^background \{' "$temporary" \
|| ! rg -q '^input-field \{' "$temporary"; then
rm -f "$temporary"
write_status false "$fallback" true "The lock-screen configuration could not be generated."
return 1
fi
if ! mv "$temporary" "$generated"; then
rm -f "$temporary" 2>/dev/null || true
write_status false "$fallback" true "The lock-screen configuration could not be generated."
return 1
fi
write_status true "$generated" false ""
}
status() {
if [[ -r "$status_file" ]] \
&& jq -e '.generated | type == "boolean"' "$status_file" >/dev/null 2>&1 \
&& jq -e '.path | type == "string"' "$status_file" >/dev/null 2>&1; then
jq -c '{generated,path,fallback:(.fallback == true),error:(.error // "")}' "$status_file"
elif [[ -s "$generated" ]]; then
jq -nc --arg path "$generated" '{generated:true,path:$path,fallback:false,error:""}'
else
jq -nc --arg path "$fallback" \
'{generated:false,path:$path,fallback:true,error:"The generated lock-screen configuration is unavailable."}'
fi
}
case "${1:-status}" in
generate)
generate
;;
status)
status
;;
run)
if generate >/dev/null 2>&1; then
exec hyprlock -c "$generated"
fi
write_status false "$fallback" true "The lock-screen configuration could not be generated."
exec hyprlock -c "$fallback"
;;
*)
printf 'usage: panama-lock [generate|status|run]\n' >&2
exit 2
;;
esac
@@ -31,6 +31,59 @@ case "$scheme" in
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
esac
# ── hyprlock ─────────────────────────────────────────────────────────────────
# The lock screen. hyprlock is launched fresh on every lock (`pidof hyprlock ||
# hyprlock`), so it reads this file each time and needs no restart.
#
# It takes rgba(r, g, b, a) in DECIMAL, not hex, so the palette is expressed as
# "R, G, B" triples here rather than the hex used everywhere else. The two
# _HEX values are the exception: they sit inside Pango markup, where hyprlock
# wants ##rrggbb.
lock_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hypr"
lock_template="$lock_dir/hyprlock.conf.template"
if [[ "$scheme" == "light" ]]; then
lock_fg="55, 96, 191" # #3760bf
lock_muted="97, 114, 176" # #6172b0
lock_accent="46, 125, 233" # #2e7de9
lock_error="245, 42, 101" # #f52a65
lock_bg="225, 226, 231" # #e1e2e7
lock_field="208, 213, 227" # #d0d5e3
lock_muted_hex="6172b0"
lock_error_hex="f52a65"
else
lock_fg="200, 211, 245" # #c8d3f5
lock_muted="130, 139, 184" # #828bb8
lock_accent="130, 170, 255" # #82aaff
lock_error="255, 117, 127" # #ff757f
lock_bg="34, 36, 54" # #222436
lock_field="46, 47, 61" # #2e2f3d
lock_muted_hex="828bb8"
lock_error_hex="ff757f"
fi
status_hyprlock="skipped"
if [[ -r "$lock_template" ]]; then
# Written atomically: a lock triggered mid-write would otherwise read a
# truncated config and fall back to hyprlock's own defaults, which is a
# bright grey screen with none of this desktop's identity.
if sed -e "s/@FG@/$lock_fg/g" \
-e "s/@MUTED@/$lock_muted/g" \
-e "s/@ACCENT@/$lock_accent/g" \
-e "s/@ERROR@/$lock_error/g" \
-e "s/@BG@/$lock_bg/g" \
-e "s/@FIELD@/$lock_field/g" \
-e "s/@MUTED_HEX@/$lock_muted_hex/g" \
-e "s/@ERROR_HEX@/$lock_error_hex/g" \
"$lock_template" >"$lock_dir/hyprlock.conf.tmp" 2>/dev/null \
&& mv "$lock_dir/hyprlock.conf.tmp" "$lock_dir/hyprlock.conf" 2>/dev/null; then
status_hyprlock="written"
else
rm -f "$lock_dir/hyprlock.conf.tmp"
status_hyprlock="failed"
fi
fi
# ── tmux ─────────────────────────────────────────────────────────────────────
# Generated like kitty's: tmux.conf sources current-theme.conf, and that file is
# machine state rather than configuration. Running servers are re-sourced so an
@@ -146,4 +199,5 @@ jq -cn \
--arg gtk "$status_gtk" \
--arg btop "$status_btop" \
--arg tmux "$status_tmux" \
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux}'
--arg hyprlock "$status_hyprlock" \
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux, hyprlock: $hyprlock}'
@@ -25,25 +25,14 @@ 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 bool busy: runner.running || root.pending.length > 0
readonly property string cursorTheme: DesktopPreferences.get("cursorTheme")
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: []
@@ -71,7 +60,6 @@ Singleton {
}
Component.onCompleted: {
themeQuery.running = true;
settle.restart();
}
@@ -102,10 +90,7 @@ Singleton {
["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]);
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
root.enqueue(commands);
}
}
@@ -8,6 +8,8 @@ import Quickshell
import Quickshell.Services.Pipewire
import QtQuick
import "AudioStreams.js" as AudioStreams
Singleton {
id: root
@@ -18,6 +20,13 @@ Singleton {
!node.isStream
&& (node.type & PwNodeType.AudioSource) === PwNodeType.AudioSource)
readonly property var playbackStreams: Pipewire.nodes.values.filter(node =>
node.ready && node.audio
&& (node.type & PwNodeType.AudioOutStream) === PwNodeType.AudioOutStream)
readonly property var applications: AudioStreams.group(
root.playbackStreams, PwNodeType.AudioOutStream)
function nodes(output: bool): var {
return output ? root.outputs : root.inputs;
}
@@ -40,4 +49,20 @@ Singleton {
return "Unknown device";
return node.description || node.nickname || node.name || "Unknown device";
}
function applicationVolume(application: var): real {
return AudioStreams.volume(application);
}
function applicationMuted(application: var): bool {
return AudioStreams.muted(application);
}
function setApplicationVolume(application: var, value: real): bool {
return AudioStreams.setVolume(application, value);
}
function setApplicationMuted(application: var, muted: bool): bool {
return AudioStreams.setMuted(application, muted);
}
}
@@ -0,0 +1,72 @@
function property(node, key) {
const value = node && node.properties ? node.properties[key] : "";
return typeof value === "string" ? value.trim() : "";
}
function groupKey(node) {
return property(node, "application.id")
|| property(node, "application.process.binary")
|| property(node, "application.name")
|| `node:${node.id}`;
}
function label(node) {
return property(node, "application.name")
|| String(node.description || "").trim()
|| property(node, "media.name")
|| "Unknown application";
}
function icon(node) {
return property(node, "application.icon_name")
|| "audio-x-generic-symbolic";
}
function group(nodes, audioOutStreamFlag) {
const groups = [];
const byKey = {};
for (const node of nodes || []) {
if (!node || node.ready !== true || !node.audio
|| (node.type & audioOutStreamFlag) !== audioOutStreamFlag)
continue;
const key = groupKey(node);
if (!byKey[key]) {
byKey[key] = { key, label: label(node), icon: icon(node), nodes: [] };
groups.push(byKey[key]);
}
byKey[key].nodes.push(node);
}
return groups;
}
function audioNodes(application) {
return (application && application.nodes || []).filter(node => node && node.audio);
}
function volume(application) {
const nodes = audioNodes(application);
return nodes.length === 0 ? 0
: nodes.reduce((sum, node) => sum + node.audio.volume, 0) / nodes.length;
}
function muted(application) {
const nodes = audioNodes(application);
return nodes.length > 0 && nodes.every(node => node.audio.muted === true);
}
function setVolume(application, value) {
const next = Math.max(0, Math.min(1, Number(value)));
if (!Number.isFinite(next)) return false;
const nodes = audioNodes(application);
for (const node of nodes) {
node.audio.muted = false;
node.audio.volume = next;
}
return nodes.length > 0;
}
function setMuted(application, mutedValue) {
const nodes = audioNodes(application);
for (const node of nodes) node.audio.muted = mutedValue === true;
return nodes.length > 0;
}
@@ -24,6 +24,11 @@ Singleton {
readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
// A neutral contrast role, not an accent. The focused Prism border belongs
// to the visual theme and must remain untouched when this role changes.
readonly property string inactiveBorderDark: "rgba(3b426199)"
readonly property string inactiveBorderLight: "rgba(a8aecb99)"
readonly property string inactiveBorder: root.dark ? root.inactiveBorderDark : root.inactiveBorderLight
property string lastError: ""
// Applied one command at a time: Process runs a single command, and several
@@ -99,12 +104,10 @@ Singleton {
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
];
// Unfocused window borders. The focused border is the prism gradient and
// is already scheme-independent; the inactive one is a flat neutral that
// would be invisible against the opposite background.
const inactive = root.dark ? "rgba(3b426199)" : "rgba(a8aecb99)";
// Unfocused window borders need scheme-relative contrast. The focused
// Prism border is deliberately owned by the accent/theme layer.
commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { inactive_border = "${inactive}" } } })`]);
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
// Applications that predate org.freedesktop.appearance and carry their
// own palettes -- terminals, chiefly. Everything that reads the portal
@@ -33,11 +33,16 @@ Singleton {
}
readonly property var wiredDevice: {
let fallback = null;
for (const device of Networking.devices.values) {
if (device.type === DeviceType.Wired)
if (device.type !== DeviceType.Wired)
continue;
if (device.connected)
return device;
if (!fallback)
fallback = device;
}
return null;
return fallback;
}
readonly property var adapter: Bluetooth.defaultAdapter
@@ -98,5 +98,16 @@ Singleton {
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]);
}
function addAutostart(desktopId: string): void {
if (root.busy)
return;
if (!root.knownDesktopId(desktopId)) {
root.lastError = "Choose an installed application.";
return;
}
root.lastError = "";
mutationProcess.exec([root.helper, "add-autostart", desktopId]);
}
Component.onCompleted: root.refresh()
}
@@ -0,0 +1,260 @@
pragma Singleton
// Application-facing desktop style.
//
// Panama owns the durable choices; gsettings is an output boundary for GTK
// and applications that follow GNOME's desktop schemas. Commands are arrays,
// values are validated before storage, and no user text is ever sent through a
// shell. Hyprland's pointer setting stays live through Accessibility.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-desktop-style"
// SearchPicker consumes [{ value, label, detail }]. Keep the raw names as
// a separate allow-list so a caller cannot smuggle a display label into a
// stored theme name.
property var cursorThemes: []
property var iconThemes: []
property var cursorThemeNames: []
property var iconThemeNames: []
property bool catalogLoaded: false
property bool scanning: false
property bool startupApplied: false
property string lastError: ""
property var pending: []
readonly property bool busy: root.scanning || catalogProcess.running
|| runner.running || root.pending.length > 0
readonly property int preferenceRevision: DesktopPreferences.revision
readonly property string cursorTheme: DesktopPreferences.get("cursorTheme")
readonly property string iconTheme: DesktopPreferences.get("iconTheme")
readonly property string applicationFont: DesktopPreferences.get("applicationFont")
readonly property string documentFont: DesktopPreferences.get("documentFont")
readonly property string monospaceFont: DesktopPreferences.get("monospaceFont")
Process {
id: catalogProcess
stdout: StdioCollector {
onStreamFinished: root.acceptCatalog(this.text)
}
onExited: (exitCode, exitStatus) => {
root.scanning = false;
if (exitCode !== 0) {
root.catalogLoaded = false;
root.lastError = "Installed icon and pointer themes could not be read.";
}
}
}
Process {
id: runner
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "One desktop style setting could not be applied.";
root.drain();
}
}
Component.onCompleted: {
root.ensureStarted();
// Accessing the singleton here keeps its existing hyprctl setcursor
// path alive for cursor-theme changes as well as cursor-size changes.
Accessibility.applyAll();
}
Timer {
id: startupApply
interval: 1200
onTriggered: {
root.startupApplied = true;
root.applyAll();
}
}
Connections {
target: DesktopPreferences
function onRevisionChanged(): void { applyCoalesce.restart(); }
}
Timer {
id: applyCoalesce
interval: 180
onTriggered: root.applyAll()
}
function ensureStarted(): void {
if (!root.catalogLoaded && !root.scanning)
root.refreshCatalog();
if (!root.startupApplied && !startupApply.running)
startupApply.restart();
}
function refreshCatalog(): void {
if (catalogProcess.running)
return;
root.scanning = true;
catalogProcess.exec([root.helperPath]);
}
function acceptCatalog(text: string): void {
try {
const parsed = JSON.parse(text);
if (!parsed || !Array.isArray(parsed.cursorThemes) || !Array.isArray(parsed.iconThemes))
throw new Error("invalid catalog shape");
const cursors = parsed.cursorThemes.filter(name =>
typeof name === "string" && PreferenceSchema.coerce("cursorTheme", name) !== undefined);
const icons = parsed.iconThemes.filter(name =>
typeof name === "string" && PreferenceSchema.coerce("iconTheme", name) !== undefined);
root.cursorThemeNames = cursors;
root.iconThemeNames = icons;
root.cursorThemes = cursors.map(name => ({
value: name,
label: name,
detail: "Pointer theme"
}));
root.iconThemes = icons.map(name => ({
value: name,
label: name,
detail: "Application icon theme"
}));
root.catalogLoaded = true;
root.lastError = "";
} catch (error) {
root.cursorThemes = [];
root.iconThemes = [];
root.cursorThemeNames = [];
root.iconThemeNames = [];
root.catalogLoaded = false;
root.lastError = "Installed icon and pointer themes could not be read.";
}
root.scanning = false;
}
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 {
// A fresh revision supersedes commands that have not started yet. The
// currently running command is allowed to finish, then the newest full
// state is replayed in a deterministic order.
root.pending = commands;
root.drain();
}
// GVariant accepts JSON-style quoted strings. JSON.stringify escapes every
// quote, backslash, and control character, and the schema patterns further
// constrain stored font/theme names. Arguments still travel directly to
// gsettings rather than through a shell.
function gvariant(value: var): string {
if (typeof value === "boolean")
return value ? "true" : "false";
if (typeof value === "number")
return String(value);
return JSON.stringify(String(value));
}
function fontName(familyKey: string, sizeKey: string): string {
return `${DesktopPreferences.get(familyKey)} ${DesktopPreferences.get(sizeKey)}`;
}
function buttonLayout(): string {
const side = DesktopPreferences.get("titlebarButtonSide");
const maximize = DesktopPreferences.get("titlebarMaximizeButton") === true;
// Tokens are fixed. Only their side and whether maximize is present
// vary, so preference data can never become command syntax.
if (side === "left")
return (maximize ? "close,maximize" : "close") + ":appmenu";
return "appmenu:" + (maximize ? "maximize,close" : "close");
}
function setting(schema: string, key: string, value: var): var {
return ["gsettings", "set", schema, key, root.gvariant(value)];
}
function applyAll(): void {
root.lastError = "";
root.enqueue([
root.setting("org.gnome.desktop.interface", "icon-theme", root.iconTheme),
root.setting("org.gnome.desktop.interface", "cursor-theme", root.cursorTheme),
root.setting("org.gnome.desktop.interface", "font-name",
root.fontName("applicationFont", "applicationFontSize")),
root.setting("org.gnome.desktop.interface", "document-font-name",
root.fontName("documentFont", "documentFontSize")),
root.setting("org.gnome.desktop.interface", "monospace-font-name",
root.fontName("monospaceFont", "monospaceFontSize")),
root.setting("org.gnome.desktop.interface", "font-hinting",
DesktopPreferences.get("fontHinting")),
root.setting("org.gnome.desktop.interface", "font-antialiasing",
DesktopPreferences.get("fontAntialiasing")),
root.setting("org.gnome.desktop.interface", "gtk-enable-primary-paste",
DesktopPreferences.get("middleClickPaste")),
root.setting("org.gnome.desktop.wm.preferences", "button-layout", root.buttonLayout()),
root.setting("org.gnome.desktop.wm.preferences", "action-double-click-titlebar",
DesktopPreferences.get("titlebarDoubleClick"))
]);
}
function storeCatalogChoice(key: string, value: string, allowed: var, kind: string): bool {
if (!root.catalogLoaded || allowed.indexOf(value) < 0) {
root.lastError = `That ${kind} theme is not installed.`;
return false;
}
if (!DesktopPreferences.set(key, value)) {
root.lastError = `That ${kind} theme name could not be saved.`;
return false;
}
root.lastError = "";
return true;
}
function setCursorTheme(value: string): bool {
return root.storeCatalogChoice("cursorTheme", value, root.cursorThemeNames, "pointer");
}
function setIconTheme(value: string): bool {
return root.storeCatalogChoice("iconTheme", value, root.iconThemeNames, "icon");
}
function storeFont(key: string, family: string, allowed: var, kind: string): bool {
if (allowed.indexOf(family) < 0) {
root.lastError = `That ${kind} font is not installed.`;
return false;
}
if (!DesktopPreferences.set(key, family)) {
root.lastError = `That ${kind} font name could not be saved.`;
return false;
}
root.lastError = "";
return true;
}
function setApplicationFont(family: string): bool {
return root.storeFont("applicationFont", family, Fonts.interfaceFonts, "application");
}
function setDocumentFont(family: string): bool {
return root.storeFont("documentFont", family, Fonts.interfaceFonts, "document");
}
function setMonospaceFont(family: string): bool {
return root.storeFont("monospaceFont", family, Fonts.monospaceFonts, "monospace");
}
}
@@ -0,0 +1,166 @@
function logicalSize(record) {
if (!record)
return { width: 0, height: 0 };
const width = Number(record.width);
const height = Number(record.height);
const scale = Number(record.scale);
const transform = Number(record.transform);
if (!Number.isFinite(width) || !Number.isFinite(height)
|| !Number.isFinite(scale) || scale <= 0)
return { width: 0, height: 0 };
const rotated = transform === 1 || transform === 3;
return {
width: (rotated ? height : width) / scale,
height: (rotated ? width : height) / scale
};
}
function validCoordinate(value) {
return Number.isFinite(value) && Number.isInteger(value)
&& value >= -100000 && value <= 100000;
}
function validate(layout) {
if (!Array.isArray(layout) || layout.length === 0)
return false;
const names = {};
let primaryCount = 0;
for (const record of layout) {
if (!record || typeof record.name !== "string"
|| !/^[A-Za-z0-9_.-]+$/.test(record.name)
|| names[record.name])
return false;
names[record.name] = true;
if (!Number.isFinite(record.width) || record.width <= 0
|| !Number.isFinite(record.height) || record.height <= 0
|| !Number.isFinite(record.scale) || record.scale <= 0
|| !Number.isInteger(record.transform)
|| record.transform < 0 || record.transform > 3
|| !validCoordinate(record.x) || !validCoordinate(record.y)
|| typeof record.primary !== "boolean")
return false;
const size = logicalSize(record);
if (!Number.isFinite(size.width) || size.width <= 0
|| !Number.isFinite(size.height) || size.height <= 0)
return false;
if (record.primary)
primaryCount += 1;
}
return primaryCount === 1;
}
function cloneLayout(layout) {
return (layout || []).map(record => Object.assign({}, record));
}
function normalize(layout) {
const result = cloneLayout(layout);
const primary = result.find(record => record.primary === true);
if (!primary)
return result;
const anchorX = primary.x;
const anchorY = primary.y;
for (const record of result) {
record.x -= anchorX;
record.y -= anchorY;
}
return result;
}
function bounds(layout) {
if (!Array.isArray(layout) || layout.length === 0)
return { x: 0, y: 0, width: 0, height: 0 };
let left = Infinity;
let top = Infinity;
let right = -Infinity;
let bottom = -Infinity;
for (const record of layout) {
const size = logicalSize(record);
left = Math.min(left, record.x);
top = Math.min(top, record.y);
right = Math.max(right, record.x + size.width);
bottom = Math.max(bottom, record.y + size.height);
}
return { x: left, y: top, width: right - left, height: bottom - top };
}
function snap(layout, movingName, threshold) {
const result = cloneLayout(layout);
const moving = result.find(record => record.name === movingName);
if (!moving)
return result;
const limit = Number.isFinite(threshold) && threshold >= 0 ? threshold : 16;
const movingSize = logicalSize(moving);
const movingXEdges = [moving.x, moving.x + movingSize.width];
const movingYEdges = [moving.y, moving.y + movingSize.height];
const stationary = result
.filter(record => record.name !== movingName)
.sort((left, right) => left.name.localeCompare(right.name));
let bestX = null;
let bestY = null;
for (const record of stationary) {
const size = logicalSize(record);
const xEdges = [record.x, record.x + size.width];
const yEdges = [record.y, record.y + size.height];
for (const source of movingXEdges) {
for (const target of xEdges) {
const delta = target - source;
const distance = Math.abs(delta);
if (distance <= limit && (bestX === null || distance < bestX.distance))
bestX = { delta, distance };
}
}
for (const source of movingYEdges) {
for (const target of yEdges) {
const delta = target - source;
const distance = Math.abs(delta);
if (distance <= limit && (bestY === null || distance < bestY.distance))
bestY = { delta, distance };
}
}
}
if (bestX !== null)
moving.x += bestX.delta;
if (bestY !== null)
moving.y += bestY.delta;
return result;
}
function canvasRects(layout, canvasWidth, canvasHeight, padding) {
const desktopBounds = bounds(layout);
const inset = Math.max(0, Number(padding) || 0);
const availableWidth = Math.max(0, Number(canvasWidth) - inset * 2);
const availableHeight = Math.max(0, Number(canvasHeight) - inset * 2);
const scale = desktopBounds.width > 0 && desktopBounds.height > 0
? Math.min(availableWidth / desktopBounds.width,
availableHeight / desktopBounds.height)
: 0;
const contentWidth = desktopBounds.width * scale;
const contentHeight = desktopBounds.height * scale;
const originX = inset + (availableWidth - contentWidth) / 2;
const originY = inset + (availableHeight - contentHeight) / 2;
return {
bounds: desktopBounds,
scale,
rects: (layout || []).map(record => {
const size = logicalSize(record);
return {
name: record.name,
x: originX + (record.x - desktopBounds.x) * scale,
y: originY + (record.y - desktopBounds.y) * scale,
width: size.width * scale,
height: size.height * scale,
primary: record.primary === true
};
})
};
}
+198 -81
View File
@@ -21,6 +21,7 @@ import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import "DisplayLayout.js" as DisplayLayout
Singleton {
id: root
@@ -31,25 +32,25 @@ Singleton {
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 var pendingPreviousLayout: null
property var pendingRequestedLayout: null
property bool pendingVerified: false
property bool revertQueued: false
property var revertExpected: null
property var revertExpectedLayout: null
property string revertReason: ""
property bool revertVerificationActive: false
property int operationGeneration: 0
property int revertGeneration: -1
property bool externalChangeBlocked: false
property int secondsLeft: 0
property bool identifying: false
readonly property bool awaitingConfirmation: root.pendingOutput !== ""
readonly property bool awaitingConfirmation: root.pendingRequestedLayout !== null
readonly property bool canConfirm: root.awaitingConfirmation
&& root.pendingVerified
&& !root.busy
readonly property bool busy: query.running || applyRun.running || revertRun.running
|| root.revertExpected !== null
|| root.revertExpectedLayout !== null
readonly property int confirmSeconds: 15
@@ -123,9 +124,24 @@ Singleton {
return false;
}
function identify(): void {
root.identifying = true;
identifyTimer.restart();
}
function parse(text: string, generation: int): void {
try {
const raw = JSON.parse(text);
const stored = DesktopPreferences.get("displays");
const persisted = stored && typeof stored === "object" ? stored : {};
const persistedPrimaries = raw.filter(monitor => {
const entry = persisted[monitor.name ?? ""];
return root.isPersistedLayoutEntry(entry) && entry.primary === true;
});
const origin = raw.find(monitor => monitor.x === 0 && monitor.y === 0);
const primaryName = persistedPrimaries.length === 1
? persistedPrimaries[0].name
: (origin?.name ?? raw[0]?.name ?? "");
root.monitors = raw.map(monitor => {
const modes = root.normaliseModes(monitor.availableModes ?? []);
const width = monitor.width ?? 0;
@@ -144,31 +160,35 @@ Singleton {
mode: current?.mode ?? `${width}x${height}@${refreshRate}`,
scale: monitor.scale ?? 1,
transform: monitor.transform ?? 0,
x: Number.isInteger(monitor.x) ? monitor.x : 0,
y: Number.isInteger(monitor.y) ? monitor.y : 0,
primary: monitor.name === primaryName,
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)) {
if (root.awaitingConfirmation && root.pendingRequestedLayout
&& generation === root.operationGeneration
&& root.matchesLayout(root.monitors, root.pendingRequestedLayout)) {
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)) {
&& root.revertExpectedLayout
&& root.matchesLayout(root.monitors, root.revertExpectedLayout)) {
revertVerifyTimer.stop();
root.revertVerificationActive = false;
root.revertGeneration = -1;
root.revertExpected = null;
root.revertExpectedLayout = null;
if (root.revertReason === "")
root.lastError = "";
else
root.lastError = root.revertReason;
root.revertReason = "";
} else if (!root.awaitingConfirmation && !root.revertExpected && (
} else if (!root.awaitingConfirmation && !root.revertExpectedLayout && (
root.lastError === "Could not read the connected displays."
|| root.lastError === "The display list could not be read.")) {
root.lastError = "";
@@ -216,6 +236,17 @@ Singleton {
return root.monitors.find(monitor => monitor.name === name) ?? null;
}
function isPersistedLayoutEntry(entry: var): bool {
return !!entry && typeof entry === "object"
&& root.modeParts(entry.mode) !== null
&& Number.isFinite(entry.scale) && entry.scale > 0
&& Number.isInteger(entry.transform)
&& entry.transform >= 0 && entry.transform <= 3
&& Number.isInteger(entry.x) && entry.x >= -100000 && entry.x <= 100000
&& Number.isInteger(entry.y) && entry.y >= -100000 && entry.y <= 100000
&& typeof entry.primary === "boolean";
}
function modeParts(mode: string): var {
const match = String(mode).match(/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
if (!match)
@@ -250,16 +281,41 @@ Singleton {
choices[0]);
}
function matchesRequest(monitor: var, requested: var): bool {
if (!monitor || !requested || monitor.name !== requested.output)
function currentLayout(): var {
return root.monitors.map(monitor => ({
name: monitor.name,
width: monitor.width,
height: monitor.height,
refreshRate: monitor.refreshRate,
mode: monitor.mode,
scale: monitor.scale,
transform: monitor.transform,
x: monitor.x,
y: monitor.y,
primary: monitor.primary === true
}));
}
function matchesLayout(monitors: var, layout: var): bool {
if (!Array.isArray(monitors) || !Array.isArray(layout)
|| monitors.length !== layout.length)
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;
const expected = Array.from(layout).sort((a, b) => a.name.localeCompare(b.name));
const actual = Array.from(monitors).sort((a, b) => a.name.localeCompare(b.name));
for (let index = 0; index < expected.length; index++) {
const requested = expected[index];
const monitor = actual[index];
const parts = root.modeParts(requested.mode);
if (!parts || monitor.name !== requested.name
|| 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
|| monitor.x !== requested.x || monitor.y !== requested.y)
return false;
}
return true;
}
function modeIsCurrent(monitor: var, candidate: var): bool {
@@ -269,10 +325,48 @@ Singleton {
&& 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 validRequestedLayout(layout: var): bool {
if (!DisplayLayout.validate(layout) || layout.length !== root.monitors.length)
return false;
const currentNames = root.monitors.map(monitor => monitor.name).sort();
const requestedNames = layout.map(record => record.name).sort();
if (JSON.stringify(currentNames) !== JSON.stringify(requestedNames))
return false;
return layout.every(record => {
const monitor = root.monitorNamed(record.name);
const parts = root.modeParts(record.mode);
return !!monitor && !!parts
&& record.width === parts.width && record.height === parts.height
&& monitor.modes.some(candidate => candidate.mode === record.mode)
&& root.isScaleClean(record.mode, record.scale)
&& root.transforms.some(candidate => candidate.value === record.transform);
});
}
// One-field controls remain callers of the complete-layout transaction.
// Their edit is cloned into the current layout so every output's position
// participates in apply, verification, and rollback.
function apply(output: string, mode: string, scale: real, transform: int): bool {
if (root.externalChangeBlocked) {
const layout = root.currentLayout();
const record = layout.find(candidate => candidate.name === output);
const parts = root.modeParts(mode);
if (!record || !parts) {
root.lastError = record ? "That display does not offer that mode." : "That display is not connected.";
return false;
}
record.mode = mode;
record.width = parts.width;
record.height = parts.height;
record.refreshRate = parts.refresh;
record.scale = scale;
record.transform = transform;
return root.applyLayout(layout);
}
// Applies immediately and starts the countdown. Nothing is stored yet: the
// complete connected layout is only written by confirm().
function applyLayout(layout: var, protectedOperation: bool): bool {
if (root.externalChangeBlocked && protectedOperation !== true) {
root.lastError = "Wait for Settings to finish restoring before changing a display.";
return false;
}
@@ -284,58 +378,53 @@ Singleton {
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.";
const normalized = DisplayLayout.normalize(layout);
if (!root.validRequestedLayout(normalized)) {
root.lastError = "That complete display layout is not valid for the connected displays.";
return false;
}
root.pendingPrevious = {
output: output,
mode: monitor.mode,
scale: monitor.scale,
transform: monitor.transform
};
root.pendingPreviousLayout = root.currentLayout();
root.operationGeneration++;
root.pendingRequested = {
output: output,
mode: mode,
scale: scale,
transform: transform
};
root.pendingOutput = output;
root.pendingRequestedLayout = normalized;
root.pendingVerified = false;
root.revertQueued = false;
root.secondsLeft = root.confirmSeconds;
root.lastError = "";
countdown.restart();
root.push(output, mode, scale, transform);
root.pushLayout(normalized, applyRun);
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} })`]);
// Settings restore holds the external-change lock while it proves a
// snapshot. This narrow entry point authorizes that one transaction while
// keeping every user-facing control blocked until restore settles.
function applyProtectedLayout(layout: var): bool {
return root.applyLayout(layout, true);
}
function makePrimary(output: string): bool {
const layout = root.currentLayout();
if (!layout.some(record => record.name === output)) {
root.lastError = "That display is not connected.";
return false;
}
for (const record of layout)
record.primary = record.name === output;
return root.applyLayout(DisplayLayout.normalize(layout));
}
function pushLayout(layout: var, runner: var): void {
const payload = layout.map(record =>
`hl.monitor({ output = "${record.name}", mode = "${record.mode}", position = "${record.x}x${record.y}", scale = ${record.scale}, transform = ${record.transform} })`
).join("; ");
runner.exec(["hyprctl", "eval", payload]);
}
function confirm(): bool {
if (!root.canConfirm || !root.matchesRequest(
root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
if (!root.canConfirm
|| !root.matchesLayout(root.monitors, root.pendingRequestedLayout)) {
if (root.awaitingConfirmation)
root.lastError = "Wait for the display to finish applying before keeping it.";
return false;
@@ -343,11 +432,16 @@ Singleton {
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
};
for (const record of root.pendingRequestedLayout) {
next[record.name] = {
mode: record.mode,
scale: record.scale,
transform: record.transform,
x: record.x,
y: record.y,
primary: record.primary
};
}
if (!DesktopPreferences.set("displays", next)) {
root.lastError = "That display setting could not be saved. Revert it and try again.";
return false;
@@ -361,9 +455,8 @@ Singleton {
function clearPending(): void {
countdown.stop();
verifyTimer.stop();
root.pendingOutput = "";
root.pendingPrevious = null;
root.pendingRequested = null;
root.pendingPreviousLayout = null;
root.pendingRequestedLayout = null;
root.pendingVerified = false;
root.revertQueued = false;
root.secondsLeft = 0;
@@ -390,16 +483,25 @@ Singleton {
}
function performRevert(): void {
const previous = root.pendingPrevious;
const connected = {};
for (const monitor of root.monitors)
connected[monitor.name] = true;
const previous = (root.pendingPreviousLayout || [])
.filter(record => connected[record.name])
.map(record => Object.assign({}, record));
if (previous.length > 0 && !previous.some(record => record.primary)) {
const origin = previous.find(record => record.x === 0 && record.y === 0);
(origin || previous[0]).primary = true;
}
root.operationGeneration++;
root.revertGeneration = root.operationGeneration;
root.revertExpected = previous;
root.revertExpectedLayout = previous.length > 0 ? previous : null;
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} })`]);
}
if (previous.length > 0)
root.pushLayout(previous, revertRun);
else
root.lastError = root.revertReason;
}
// Clears any stored override for an output so it returns to the value
@@ -418,6 +520,26 @@ Singleton {
return !!(stored && typeof stored === "object" && stored[output] !== undefined);
}
function verificationTimedOut(): void {
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
}
function revertVerificationTimedOut(): void {
revertVerifyTimer.stop();
root.revertVerificationActive = false;
root.revertGeneration = -1;
root.revertExpectedLayout = null;
root.revertReason = "";
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
}
Timer {
id: identifyTimer
interval: 3000
repeat: false
onTriggered: root.identifying = false
}
Timer {
id: verifyTimer
property int attempts: 0
@@ -427,7 +549,7 @@ Singleton {
onTriggered: {
ticks++;
if (ticks > 50) {
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
root.verificationTimedOut();
return;
}
if (root.refresh())
@@ -444,12 +566,7 @@ Singleton {
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.";
root.revertVerificationTimedOut();
return;
}
if (root.refresh())
+444
View File
@@ -0,0 +1,444 @@
pragma Singleton
// The health helper is deliberately not a state owner. This singleton accepts
// complete, typed snapshots and keeps the last valid one available while a
// later scan or repair fails.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property var snapshot: ({})
property var checks: []
property var summary: ({ status: "healthy", healthy: 0, warnings: 0, errors: 0, unconfigured: 0 })
property string status: "healthy"
property bool diagnosticUnavailable: false
property bool queuedRefresh: false
property int generation: 0
property int acceptedGeneration: 0
property string lastError: ""
property string repairingId: ""
property var lastRepair: ({})
property string lastCopyResult: ""
property bool startupScanEnabled: true
property bool postRepairScanPending: false
readonly property bool actionable: root.status === "warning" || root.status === "error"
readonly property bool busy: scanProcess.running || repairProcess.running || root.postRepairScanPending
readonly property string helperPath: Quickshell.env("PANAMA_HEALTH_HELPER")
|| Quickshell.shellDir + "/scripts/panama-doctor"
readonly property var statuses: ["ok", "warning", "error", "unconfigured"]
readonly property var groups: ["desktop-foundation", "input-media", "integrations", "panama-tools"]
readonly property var overallStatuses: ["healthy", "warning", "error"]
readonly property var settingsTargets: ["home-phone", "datetime"]
readonly property var instructionTargets: ["ddc-permissions"]
Process {
id: scanProcess
property int scanGeneration: 0
property string outputText: ""
property int exitCode: -1
property bool exited: false
property bool streamFinished: false
property bool settled: false
stdout: StdioCollector {
id: scanOutput
property int generation: 0
onStreamFinished: {
scanProcess.outputText = this.text;
scanProcess.scanGeneration = generation;
scanProcess.streamFinished = true;
root.settleScan();
}
}
onExited: (exitCode, exitStatus) => {
scanProcess.exitCode = exitCode;
scanProcess.exited = true;
root.settleScan();
}
}
Process {
id: repairProcess
property string checkId: ""
property bool external: false
property string outputText: ""
property int exitCode: -1
property bool exited: false
property bool streamFinished: false
property bool settled: false
stdout: StdioCollector {
onStreamFinished: {
repairProcess.outputText = this.text;
repairProcess.streamFinished = true;
root.settleRepair();
}
}
onExited: (exitCode, exitStatus) => {
repairProcess.exitCode = exitCode;
repairProcess.exited = true;
root.settleRepair();
}
}
Process {
id: copyProcess
property string payload: ""
onStarted: copyProcess.write(copyProcess.payload)
onExited: (exitCode, exitStatus) => {
root.lastCopyResult = exitCode === 0
? "Report copied."
: "Could not copy the health report."
copyProcess.payload = ""
}
}
Process {
id: failureNotification
}
Timer {
id: startupScan
interval: 2200
repeat: false
running: root.startupScanEnabled
onTriggered: root.refresh()
}
function refresh(): bool {
if (root.postRepairScanPending)
return false;
if (scanProcess.running || repairProcess.running) {
root.queuedRefresh = true;
return false;
}
root.generation += 1;
scanProcess.scanGeneration = root.generation;
scanProcess.outputText = "";
scanProcess.exitCode = -1;
scanProcess.exited = false;
scanProcess.streamFinished = false;
scanProcess.settled = false;
scanOutput.generation = root.generation;
scanProcess.exec([root.helperPath, "--json"]);
return true;
}
function settleScan(): void {
if (scanProcess.settled || !scanProcess.exited || !scanProcess.streamFinished)
return;
scanProcess.settled = true;
root.finishScan(scanProcess.exitCode, scanProcess.scanGeneration, scanProcess.outputText);
}
function finishScan(exitCode: int, scanGeneration: int, text: string): void {
if (exitCode === 0)
root.consumeSnapshot(text, scanGeneration);
else
root.rejectSnapshot("Panama diagnostics could not be read. Try refreshing.");
if (!root.queuedRefresh)
return;
root.queuedRefresh = false;
root.refresh();
}
function consumeSnapshot(text: string, scanGeneration: int): bool {
if (scanGeneration < root.acceptedGeneration)
return false;
let candidate;
try {
candidate = JSON.parse(text.trim());
} catch (error) {
root.rejectSnapshot("Panama diagnostics returned an unreadable response.");
return false;
}
if (!root.validSnapshot(candidate)) {
root.rejectSnapshot("Panama diagnostics returned an invalid response.");
return false;
}
const accepted = root.safeSnapshot(candidate);
root.snapshot = accepted;
root.checks = accepted.checks;
root.summary = accepted.summary;
root.status = accepted.summary.status;
root.acceptedGeneration = scanGeneration;
root.diagnosticUnavailable = false;
root.lastError = "";
return true;
}
function rejectSnapshot(message: string): void {
root.diagnosticUnavailable = true;
root.lastError = message;
}
function safeSnapshot(candidate: var): var {
return {
schemaVersion: 1,
generatedAt: candidate.generatedAt,
summary: {
status: candidate.summary.status,
healthy: candidate.summary.healthy,
warnings: candidate.summary.warnings,
errors: candidate.summary.errors,
unconfigured: candidate.summary.unconfigured
},
context: {
session: candidate.context.session,
versions: candidate.context.versions.map(version => ({
id: version.id,
version: version.version
}))
},
checks: candidate.checks.map(check => root.safeCheck(check))
};
}
function safeCheck(candidate: var): var {
const check = {
id: candidate.id,
group: candidate.group,
title: candidate.title,
status: candidate.status,
detail: candidate.detail
};
if (candidate.action !== undefined)
check.action = root.safeAction(candidate.action);
return check;
}
function safeAction(candidate: var): var {
const action = {
kind: candidate.kind,
label: candidate.label,
confirm: candidate.confirm
};
if (Object.prototype.hasOwnProperty.call(candidate, "target"))
action.target = candidate.target;
return action;
}
function repair(id: string, external: bool): bool {
if (root.busy)
return false;
const check = root.checks.find(candidate => candidate.id === id);
if (!check || !check.action || check.action.kind !== "repair")
return false;
if (external && check.action.confirm)
return false;
root.repairingId = id;
root.lastError = "";
repairProcess.checkId = id;
repairProcess.external = external;
repairProcess.outputText = "";
repairProcess.exitCode = -1;
repairProcess.exited = false;
repairProcess.streamFinished = false;
repairProcess.settled = false;
repairProcess.exec([root.helperPath, "--repair", id, "--json"]);
return true;
}
function settleRepair(): void {
if (repairProcess.settled || !repairProcess.exited || !repairProcess.streamFinished)
return;
repairProcess.settled = true;
root.finishRepair(
repairProcess.exitCode,
repairProcess.checkId,
repairProcess.external,
repairProcess.outputText
);
}
function finishRepair(exitCode: int, id: string, external: bool, text: string): void {
let candidate;
try {
candidate = JSON.parse(text.trim());
} catch (error) {
candidate = null;
}
const result = root.validRepairResult(candidate, id, exitCode)
? {
schemaVersion: 1,
checkId: candidate.checkId,
accepted: candidate.accepted,
exitCode: candidate.exitCode,
message: candidate.message
}
: {
schemaVersion: 1,
checkId: id,
accepted: false,
exitCode: exitCode,
message: "Panama returned an invalid repair response."
};
const failed = !result.accepted || result.exitCode !== 0;
root.repairingId = "";
root.lastRepair = result;
root.lastError = failed ? result.message : "";
if (failed && external && !failureNotification.running) {
failureNotification.exec([
"notify-send", "-a", "Panama", "-i", "dialog-error-symbolic",
"Panama action failed", "The requested health repair could not be completed."
]);
}
// Any refresh requested while the repair was running is satisfied by
// this one observed post-repair scan. Process exit alone never changes
// the accepted check rows.
root.queuedRefresh = false;
root.postRepairScanPending = true;
Qt.callLater(root.startPostRepairScan);
}
function startPostRepairScan(): void {
root.postRepairScanPending = false;
root.queuedRefresh = false;
root.refresh();
}
function validRepairResult(candidate: var, id: string, processExitCode: int): bool {
if (!root.plainObject(candidate))
return false;
const keys = Object.keys(candidate).sort();
const expectedKeys = ["accepted", "checkId", "exitCode", "message", "schemaVersion"];
if (keys.length !== expectedKeys.length
|| !keys.every((key, index) => key === expectedKeys[index]))
return false;
return candidate.schemaVersion === 1
&& candidate.checkId === id
&& typeof candidate.accepted === "boolean"
&& Number.isInteger(candidate.exitCode)
&& candidate.exitCode === processExitCode
&& typeof candidate.message === "string"
&& candidate.message.length > 0;
}
function copyReport(): bool {
if (copyProcess.running)
return false;
copyProcess.payload = JSON.stringify(root.snapshot, null, 2);
root.lastCopyResult = "";
copyProcess.exec(["wl-copy"]);
return true;
}
function diagnostics(): var {
return {
status: root.status,
summary: root.summary,
busy: root.busy,
diagnosticUnavailable: root.diagnosticUnavailable,
queuedRefresh: root.queuedRefresh,
generation: root.generation,
acceptedGeneration: root.acceptedGeneration,
repairingId: root.repairingId,
lastRepair: root.lastRepair,
lastError: root.lastError,
checks: root.checks.map(check => check.id),
checkStates: root.checks.map(check => ({ id: check.id, status: check.status }))
};
}
function validSnapshot(candidate: var): bool {
if (!root.plainObject(candidate)
|| candidate.schemaVersion !== 1
|| typeof candidate.generatedAt !== "string" || candidate.generatedAt.length === 0
|| !root.validSummary(candidate.summary)
|| !root.validContext(candidate.context)
|| !Array.isArray(candidate.checks) || candidate.checks.length === 0)
return false;
const ids = {};
const counts = { ok: 0, warning: 0, error: 0, unconfigured: 0 };
for (const check of candidate.checks) {
if (!root.validCheck(check) || ids[check.id])
return false;
ids[check.id] = true;
counts[check.status] += 1;
}
const computedStatus = counts.error > 0 ? "error" : counts.warning > 0 ? "warning" : "healthy";
return candidate.summary.healthy === counts.ok
&& candidate.summary.warnings === counts.warning
&& candidate.summary.errors === counts.error
&& candidate.summary.unconfigured === counts.unconfigured
&& candidate.summary.status === computedStatus;
}
function validSummary(candidate: var): bool {
if (!root.plainObject(candidate) || root.overallStatuses.indexOf(candidate.status) < 0)
return false;
for (const key of ["healthy", "warnings", "errors", "unconfigured"]) {
if (!Number.isInteger(candidate[key]) || candidate[key] < 0)
return false;
}
return true;
}
function validContext(candidate: var): bool {
if (!root.plainObject(candidate)
|| ["hyprland", "other"].indexOf(candidate.session) < 0
|| !Array.isArray(candidate.versions))
return false;
return candidate.versions.every(version => root.plainObject(version)
&& typeof version.id === "string" && version.id.length > 0
&& typeof version.version === "string" && version.version.length > 0);
}
function validCheck(candidate: var): bool {
if (!root.plainObject(candidate)
|| !/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/.test(candidate.id)
|| root.groups.indexOf(candidate.group) < 0
|| root.statuses.indexOf(candidate.status) < 0
|| typeof candidate.title !== "string" || candidate.title.length === 0
|| typeof candidate.detail !== "string" || candidate.detail.length === 0)
return false;
return candidate.action === undefined || root.validAction(candidate.action);
}
function validAction(candidate: var): bool {
if (!root.plainObject(candidate)
|| ["repair", "open", "instructions"].indexOf(candidate.kind) < 0
|| typeof candidate.label !== "string" || candidate.label.length === 0
|| typeof candidate.confirm !== "boolean")
return false;
const allowedKeys = ["kind", "label", "confirm", "target"];
if (!Object.keys(candidate).every(key => allowedKeys.indexOf(key) >= 0))
return false;
const hasTarget = Object.prototype.hasOwnProperty.call(candidate, "target");
if (!hasTarget)
return true;
if (typeof candidate.target !== "string" || candidate.target.length === 0 || candidate.kind === "repair")
return false;
return candidate.kind === "open"
? root.settingsTargets.indexOf(candidate.target) >= 0
: root.instructionTargets.indexOf(candidate.target) >= 0;
}
function plainObject(value: var): bool {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
}
@@ -0,0 +1,132 @@
pragma Singleton
// Generated lock-screen state. Visual preferences are coalesced into one
// helper invocation, while the last valid status remains visible if a helper
// response is malformed.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-lock"
property bool generated: false
property string path: ""
property bool fallback: true
property string lastError: ""
property string watchedSignature: ""
property bool regenerateAfterCurrent: false
readonly property bool busy: generateProcess.running || statusProcess.running
function preferenceSignature(): string {
DesktopPreferences.revision;
return JSON.stringify([
DesktopPreferences.get("lockBackgroundMode"),
DesktopPreferences.get("lockBlurLevel"),
DesktopPreferences.get("lockShowClock"),
DesktopPreferences.get("lockShowDate"),
DesktopPreferences.get("lockShowUser"),
DesktopPreferences.get("lockFadeOnEmpty"),
DesktopPreferences.get("use24Hour"),
DesktopPreferences.get("colorScheme"),
DesktopPreferences.get("wallpaperPath"),
DesktopPreferences.get("wallpaperMode"),
DesktopPreferences.get("wallpaperPerMonitor")
]);
}
function applyStatus(text: string): void {
try {
const state = JSON.parse(text);
if (!state || typeof state.generated !== "boolean"
|| typeof state.path !== "string" || state.path.length === 0)
throw new Error("invalid lock status");
root.generated = state.generated;
root.path = state.path;
root.fallback = state.fallback === true;
root.lastError = typeof state.error === "string" ? state.error : "";
} catch (error) {
root.lastError = "The lock-screen configuration could not be read.";
}
}
function refresh(): void {
if (!statusProcess.running)
statusProcess.exec([root.helperPath, "status"]);
}
function regenerate(): void {
if (generateProcess.running) {
root.regenerateAfterCurrent = true;
return;
}
generateProcess.exec([root.helperPath, "generate"]);
}
Process {
id: statusProcess
property bool parsed: false
onRunningChanged: {
if (running)
parsed = false;
}
stdout: StdioCollector {
onStreamFinished: {
statusProcess.parsed = true;
root.applyStatus(this.text);
}
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0 && !statusProcess.parsed)
root.lastError = "The lock-screen configuration could not be read.";
}
}
Process {
id: generateProcess
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "The lock-screen configuration could not be generated.";
root.refresh();
if (root.regenerateAfterCurrent) {
root.regenerateAfterCurrent = false;
regenerateTimer.restart();
}
}
}
Connections {
target: DesktopPreferences
function onRevisionChanged(): void {
const signature = root.preferenceSignature();
if (signature === root.watchedSignature)
return;
root.watchedSignature = signature;
regenerateTimer.restart();
}
}
Timer {
id: regenerateTimer
interval: 250
onTriggered: root.regenerate()
}
Component.onCompleted: {
root.watchedSignature = root.preferenceSignature();
root.refresh();
regenerateTimer.restart();
}
}
+120 -16
View File
@@ -41,20 +41,28 @@ Singleton {
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 readLiveDisplayLayout: function() { return Displays.currentLayout(); }
property var applyDisplayLayout: function(layout) { return Displays.applyProtectedLayout(layout); }
property var displayCanConfirm: function() { return Displays.canConfirm; }
property var confirmDisplayLayout: function() { return Displays.confirm(); }
property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
property var applyIdle: function() { IdleLock.apply(); }
property var idleBusy: function() { return IdleLock.busy; }
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 applyWallpaperPolicy: function() { Wallpaper.applyCurrentPolicy(false); }
property var wallpaperBusy: function() { return Wallpaper.busy; }
property var regenerateLock: function() { LockScreen.regenerate(); }
property var lockBusy: function() { return LockScreen.busy; }
property var reloadShell: function() { Quickshell.reload(false); }
property var protectedDisplays: ({})
property var protectedDisplayLayout: []
property var pendingRestoredLayout: null
readonly property bool busy: listQuery.running || actionRun.running
|| applyRestoredState.running || settleReload.running
|| settleDisplayRestore.running || applyRestoredState.running || settleReload.running
Process {
id: listQuery
@@ -89,18 +97,21 @@ Singleton {
if (actionRun.restoring) {
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
root.protectedDisplayLayout = [];
}
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) {
const restoreAccepted = root.handleRestoreOutput(actionRun.outputText);
if (restoreAccepted)
root.lastError = "";
else if (root.lastError === "")
root.lastError = "Desktop settings were restored, but Home favourites could not be reloaded.";
if (!restoreAccepted) {
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
root.protectedDisplayLayout = [];
}
} else
root.lastError = "";
@@ -108,6 +119,28 @@ Singleton {
}
}
Timer {
id: settleDisplayRestore
property int attempts: 0
interval: 100
repeat: true
onTriggered: {
attempts++;
if (root.displayCanConfirm()) {
stop();
if (!root.confirmDisplayLayout()) {
root.failDisplayRestore("The restored display layout could not be confirmed.");
return;
}
root.pendingRestoredLayout = null;
root.beginRestoredStateReplay();
} else if (!root.displayBusy() || attempts >= 180) {
stop();
root.failDisplayRestore("The restored display layout could not be verified.");
}
}
}
Timer {
id: applyRestoredState
interval: 80
@@ -116,9 +149,11 @@ Singleton {
// 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.applyIdle();
root.regenerateLock();
root.applyWallpaperPolicy();
root.reloadKeybinds();
root.applyWallpaper(root.currentWallpaper());
root.applyCompositor();
settleReload.attempts = 0;
settleReload.restart();
@@ -135,10 +170,12 @@ Singleton {
// 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) {
if ((!root.idleBusy() && !root.keybindsReloading() && !root.systemBusy()
&& !root.wallpaperBusy() && !root.lockBusy()) || attempts >= 30) {
stop();
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
root.protectedDisplayLayout = [];
root.reloadShell();
}
}
@@ -177,12 +214,77 @@ Singleton {
if (!root.reloadHomeState(text))
return false;
root.reloadDesktop();
if (!root.protectDisplays(root.protectedDisplays))
return false;
applyRestoredState.restart();
const restoredLayout = root.layoutFromStoredDisplays(root.readDisplays());
if (restoredLayout === null || root.layoutsEqual(
restoredLayout, root.protectedDisplayLayout)) {
root.beginRestoredStateReplay();
return true;
}
root.pendingRestoredLayout = restoredLayout;
if (!root.applyDisplayLayout(restoredLayout))
return root.failDisplayRestore("The restored display layout was rejected.");
settleDisplayRestore.attempts = 0;
settleDisplayRestore.restart();
return true;
}
function beginRestoredStateReplay(): void {
applyRestoredState.restart();
}
function layoutFromStoredDisplays(stored: var): var {
if (!stored || typeof stored !== "object")
return null;
const current = root.readLiveDisplayLayout();
if (!Array.isArray(current) || current.length === 0)
return null;
const layout = [];
for (const live of current) {
const entry = stored[live.name];
const match = String(entry?.mode ?? "").match(
/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
if (!entry || !match || !Number.isFinite(entry.scale) || entry.scale <= 0
|| !Number.isInteger(entry.transform)
|| entry.transform < 0 || entry.transform > 3
|| !Number.isInteger(entry.x) || !Number.isInteger(entry.y)
|| typeof entry.primary !== "boolean")
return null;
layout.push(Object.assign({}, live, {
width: Number(match[1]),
height: Number(match[2]),
refreshRate: Number(match[3]),
mode: entry.mode,
scale: entry.scale,
transform: entry.transform,
x: entry.x,
y: entry.y,
primary: entry.primary
}));
}
return layout.filter(record => record.primary).length === 1 ? layout : null;
}
function layoutsEqual(left: var, right: var): bool {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
return false;
const fields = ["name", "mode", "scale", "transform", "x", "y", "primary"];
const a = Array.from(left).sort((x, y) => x.name.localeCompare(y.name));
const b = Array.from(right).sort((x, y) => x.name.localeCompare(y.name));
return a.every((record, index) => fields.every(
field => record[field] === b[index][field]));
}
function failDisplayRestore(message: string): bool {
root.pendingRestoredLayout = null;
if (!root.protectDisplays(root.protectedDisplays))
message += " The original display preference also could not be restored.";
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
root.protectedDisplayLayout = [];
root.lastError = message;
return false;
}
// 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.
@@ -250,6 +352,8 @@ Singleton {
const currentDisplays = root.readDisplays();
root.protectedDisplays = JSON.parse(JSON.stringify(
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
root.protectedDisplayLayout = JSON.parse(JSON.stringify(
root.readLiveDisplayLayout() ?? []));
root.setDisplayBlocked(true);
actionRun.restoring = true;
actionRun.exec([root.helperPath, "restore", name]);
@@ -17,15 +17,24 @@ import qs.config
Singleton {
id: root
// SettingsSearch is part of the always-constructed sidebar. Touching the
// desktop-style service here gives application preferences their startup
// replay even when Appearance is not the page that opens first.
Component.onCompleted: DesktopStyle.ensureStarted()
// 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",
"typography": "appearance",
"themes": "appearance",
"titlebar": "appearance",
"windows": "appearance",
"effects": "appearance",
"wallpaper": "appearance",
"lockAppearance": "appearance",
"dock": "desktop",
"focus": "desktop",
"display": "displays",
@@ -36,7 +45,10 @@ Singleton {
"pointer": "mouse",
"touchpad": "mouse",
"multitasking": "desktop",
"weather": "appearance",
"edges": "desktop",
"master": "desktop",
"notices": "desktop",
"weather": "home",
"notifications": "notifications",
"capture": "screen-intelligence"
})
@@ -52,7 +64,15 @@ Singleton {
{ 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" }
{ label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" },
{ label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" },
{ label: "Copy health report", detail: "Copy a redacted Panama doctor report", page: "services" },
{ label: "Lock screen background", detail: "Choose a blurred desktop, wallpaper, or solid colour", page: "appearance" },
{ label: "Password field", detail: "Choose whether the empty lock-screen field stays visible", page: "appearance" },
{ label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance" },
{ label: "Arrange displays", detail: "Drag connected displays into their physical positions", page: "displays" },
{ label: "Monitor position", detail: "Set where each display sits in the desktop", page: "displays" },
{ label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" }
]
function pageFor(group: string): string {
@@ -80,7 +100,8 @@ Singleton {
for (const entry of PreferenceSchema.entries) {
if (entry.internal)
continue;
const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group}`.toLowerCase();
const optionLabels = (entry.options ?? []).map(option => option.label).join(" ");
const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group} ${optionLabels}`.toLowerCase();
if (haystack.indexOf(needle) >= 0)
add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting");
}
@@ -97,8 +118,16 @@ Singleton {
// Exact prefix matches first: typing "blur" should put "Blur" above
// "Blur radius", and both above a setting that merely mentions blur in
// its explanation.
// its explanation. An exact enum option also leads: "slideshow" is a
// mode choice, so Wallpaper mode belongs above the interval row that
// merely explains it.
return results.sort((a, b) => {
const aSpec = PreferenceSchema.entries.find(entry => entry.label === a.label);
const bSpec = PreferenceSchema.entries.find(entry => entry.label === b.label);
const ao = (aSpec?.options ?? []).some(option => option.label.toLowerCase() === needle);
const bo = (bSpec?.options ?? []).some(option => option.label.toLowerCase() === needle);
if (ao !== bo)
return ao ? -1 : 1;
const al = a.label.toLowerCase();
const bl = b.label.toLowerCase();
const ap = al === needle ? 0 : (al.indexOf(needle) === 0 ? 1 : 2);
+104 -10
View File
@@ -42,6 +42,8 @@ Singleton {
property var reloadKeybinds: function() { Keybinds.applyReload(); }
property var keybindsReloading: function() { return Keybinds.reloading; }
property var applyWallpaper: function(path) { Wallpaper.set(path); }
property var regenerateLock: function() { LockScreen.regenerate(); }
property var lockBusy: function() { return LockScreen.busy; }
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|| configWrite.running || configVerify.running || bluebubblesQuery.running
@@ -273,6 +275,15 @@ Singleton {
// decides, and config/dot/hypr/prefs.lua does the same conversion via
// prefs.getInt so both sides agree.
function hyprValue(entry: var, value: var): var {
// Some options are phrased as a negative by the compositor -- the four
// Hyprland notices are all `disable_x` -- while the setting reads as
// "show x", because a switch labelled "Disable splash text" that must
// be ON to hide something is a small cruelty. `invert` bridges the two,
// in exactly one place, so nothing downstream has to remember which
// options are backwards.
if (entry.hypr.invert === true && typeof value === "boolean")
value = !value;
if (typeof value === "boolean" && entry.hypr.readAs !== "bool")
return value ? 1 : 0;
return value;
@@ -300,6 +311,24 @@ Singleton {
return value ? "true" : "false";
if (typeof value === "number")
return String(value);
// A gradient is the one setting whose Lua form is not a scalar. The
// stubs declare it as `string|{colors:string[], angle?:number}`, and
// the string form only ever carries ONE stop -- writing
// "rgba(a) rgba(b) 45deg" as a string is accepted and silently keeps
// the previous value, which is how a two-stop write looks like it
// worked and did nothing. Multi-stop must be the table form.
if (value && typeof value === "object" && Array.isArray(value.colors)) {
const stops = value.colors
.map(stop => `"${String(stop).replace(/["\\]/g, "")}"`)
.join(", ");
const angle = Number(value.angle);
return `{ colors = { ${stops} }` + (isFinite(angle) ? `, angle = ${angle} }` : ` }`);
}
// A vec2 reaches Lua as a two-element table.
if (Array.isArray(value) && value.length === 2)
return `{ ${Number(value[0])}, ${Number(value[1])} }`;
// Strings only reach here after the schema's pattern check; quoting is
// belt-and-braces rather than the primary defence.
return `"${String(value).replace(/["\\]/g, "")}"`;
@@ -309,7 +338,15 @@ Singleton {
const parts = [];
for (const name in node) {
const child = node[name];
parts.push(`${name} = ${typeof child === "string" ? child : root.serialiseTable(child)}`);
// A leaf arrives pre-serialised as a string; anything else is
// either a nested section or a structured value (gradient, vec2)
// that serialiseValue knows how to render.
const rendered = typeof child === "string"
? child
: (Array.isArray(child) || (child && child.colors !== undefined)
? root.serialiseValue(child)
: root.serialiseTable(child));
parts.push(`${name} = ${rendered}`);
}
return `{ ${parts.join(", ")} }`;
}
@@ -353,6 +390,59 @@ Singleton {
root.drainQueue();
}
// Gradients are written in one notation and read back in another, so they
// cannot be compared directly the way every other type can.
//
// written: { colors = { "rgba(3b426199)" }, angle = 45 }
// read: "993b4261 45deg"
//
// The stops swap to AARRGGBB order, lose their wrapper, and the angle is
// always appended even when it was never given. Comparing the raw strings
// reports every gradient write as rejected, which is what would have
// happened had this been added with readAs: "str".
function gradientMatches(expected: var, observed: string): bool {
if (typeof observed !== "string")
return false;
return root.normaliseGradient(expected) === root.normaliseGradient(observed);
}
// Both notations reduced to "aarrggbb aarrggbb Ndeg".
function normaliseGradient(value: var): string {
const stops = [];
let angle = 0;
const readStop = function (text: string): void {
const rgba = String(text).match(/rgba?\(\s*([0-9a-fA-F]{6,8})\s*\)/);
if (rgba) {
let hex = rgba[1].toLowerCase();
// rgb() has no alpha; the compositor reports it as fully opaque.
if (hex.length === 6)
hex = hex + "ff";
// RRGGBBAA in, AARRGGBB out.
stops.push(hex.slice(6, 8) + hex.slice(0, 6));
return;
}
const bare = String(text).match(/^([0-9a-fA-F]{8})$/);
if (bare) {
stops.push(bare[1].toLowerCase());
return;
}
const deg = String(text).match(/^(-?[0-9.]+)deg$/);
if (deg)
angle = Number(deg[1]);
};
if (value && typeof value === "object" && Array.isArray(value.colors)) {
value.colors.forEach(readStop);
if (value.angle !== undefined && isFinite(Number(value.angle)))
angle = Number(value.angle);
} else {
String(value).trim().split(/\s+/).forEach(readStop);
}
return stops.join(" ") + " " + angle + "deg";
}
function matchesObserved(entry: var, value: var, answer: var): bool {
if (!answer)
return false;
@@ -370,6 +460,12 @@ Singleton {
case "css":
// Gaps read back as a box, e.g. "10 10 10 10".
return Number(String(answer.css).trim().split(/\s+/)[0]) === expected;
case "gradient":
return root.gradientMatches(expected, answer.gradient);
case "vec2":
return Array.isArray(answer.vec2) && Array.isArray(expected)
&& Number(answer.vec2[0]) === Number(expected[0])
&& Number(answer.vec2[1]) === Number(expected[1]);
}
return false;
}
@@ -413,16 +509,13 @@ Singleton {
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;
}
// Do not apply geometry during a reset: doing so would need the same
// visible confirmation transaction as the Displays page. Clearing the
// stored records is still important, though, so the next session uses
// Panama's shipped DP-2 placement and automatic placement elsewhere.
// Home accessories keep their own store (panama-home.json), so a reset
// that only cleared the schema store would silently leave a customised
@@ -443,6 +536,7 @@ Singleton {
root.applyPersistedDisplayPolicy();
root.reloadKeybinds();
root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));
root.regenerateLock();
resetRelease.attempts = 0;
resetRelease.restart();
}
@@ -455,7 +549,7 @@ Singleton {
repeat: true
onTriggered: {
attempts++;
if ((!root.keybindsReloading() && !root.busy) || attempts >= 50) {
if ((!root.keybindsReloading() && !root.busy && !root.lockBusy()) || attempts >= 50) {
stop();
root.setDisplayBlocked(false);
}
+336 -80
View File
@@ -1,43 +1,55 @@
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.
// Verified wallpaper policy application. hyprpaper 0.8 applies one output per
// IPC call, so Panama queues every connected output and persists a policy only
// after listactive confirms the complete map.
import Quickshell
import Quickshell.Io
import QtQuick
import "WallpaperPolicy.js" as WallpaperPolicy
import qs.config
Singleton {
id: root
// Absolute paths of candidate images, newest first.
property var available: []
property string active: ""
property var activeByOutput: ({})
property string lastError: ""
property bool scanning: false
property var transaction: null
property bool startupRestoreEnabled: true
property var outputOverride: null
property var candidateOverride: null
property string slideshowPath: ""
property int slideshowIndex: -1
property var shuffleBag: []
property int slideshowIntervalOverrideMs: 0
property bool pendingHotplugReapply: false
readonly property string configured: DesktopPreferences.get("wallpaperPath")
readonly property string shippedPath: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg`
readonly property string active: {
const outputs = root.outputNames();
if (outputs.length > 0 && root.activeByOutput[outputs[0]])
return root.activeByOutput[outputs[0]];
const paths = Object.values(root.activeByOutput);
return paths.length > 0 ? paths[0] : "";
}
readonly property bool busy: root.transaction !== null
|| applyProcess.running || verifyProcess.running
readonly property var slideshowCollection: WallpaperPolicy.validCollection(
DesktopPreferences.get("wallpaperSlideshowPaths") ?? [], root.candidates([]))
readonly property bool slideshowTimerRunning: slideshowTimer.running
readonly property string outputSignature: root.outputNames().slice().sort().join("|")
property var outputNames: function() {
if (Array.isArray(root.outputOverride))
return root.outputOverride.slice();
return Quickshell.screens.map(screen => screen.name).filter(name => !!name);
}
// 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`,
@@ -47,20 +59,14 @@ Singleton {
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.available = this.text.split("\n")
.map(line => line.trim()).filter(line => line.length > 0);
root.scanning = false;
}
}
@@ -71,62 +77,81 @@ Singleton {
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() : "";
const parsed = root.parseActive(this.text);
if (parsed !== null)
root.activeByOutput = parsed;
}
}
}
// 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: []
id: applyProcess
onExited: (exitCode, exitStatus) => {
if (root.transaction === null)
return;
if (exitCode !== 0) {
root.lastError = "hyprpaper could not load that image.";
apply.remaining = [];
root.lastError = "Hyprpaper did not apply that background.";
root.transaction = null;
root.schedulePendingHotplug();
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();
root.drainTransaction();
}
}
Process {
id: verifyProcess
property string outputText: ""
onStarted: outputText = ""
stdout: StdioCollector {
onStreamFinished: verifyProcess.outputText = this.text
}
onExited: (exitCode, exitStatus) => root.finishVerification(exitCode)
}
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);
if (root.startupRestoreEnabled)
root.applyCurrentPolicy(false);
}
}
Timer {
id: slideshowTimer
interval: root.slideshowIntervalOverrideMs > 0
? root.slideshowIntervalOverrideMs
: DesktopPreferences.get("wallpaperIntervalMinutes") * 60000
repeat: true
running: DesktopPreferences.get("wallpaperMode") === "slideshow"
&& root.slideshowCollection.length >= 2 && !root.busy
onTriggered: root.advanceSlideshow()
}
Timer {
id: outputSettle
interval: 350
onTriggered: {
if (root.busy) {
root.pendingHotplugReapply = true;
return;
}
root.applyCurrentPolicy(false);
}
}
onOutputSignatureChanged: {
if (Object.keys(root.activeByOutput).length > 0)
outputSettle.restart();
}
function rescan(): void {
if (scan.running)
return;
@@ -135,37 +160,268 @@ Singleton {
}
function refreshActive(): void {
if (!activeQuery.running)
if (!activeQuery.running && !root.busy)
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.";
function candidates(extra: var): var {
const result = [];
const discovered = Array.isArray(root.candidateOverride)
? root.candidateOverride : root.available;
for (const path of discovered.concat([root.shippedPath, root.configured]).concat(extra || [])) {
if (typeof path === "string" && /^\/[^,\n]+$/.test(path) && !result.includes(path))
result.push(path);
}
return result;
}
function currentPolicy(): var {
return {
mode: DesktopPreferences.get("wallpaperMode") ?? "single",
globalPath: root.configured === "" ? root.shippedPath : root.configured,
collection: DesktopPreferences.get("wallpaperSlideshowPaths") ?? [],
intervalMinutes: DesktopPreferences.get("wallpaperIntervalMinutes") ?? 30,
shuffle: DesktopPreferences.get("wallpaperShuffle") !== false,
assignments: DesktopPreferences.get("wallpaperPerMonitor") ?? ({}),
slideshowPath: root.slideshowPath !== "" ? root.slideshowPath : root.active
};
}
function normalisePolicy(policy: var): var {
if (!policy || typeof policy !== "object")
return null;
const mode = ["single", "slideshow", "per-monitor"].includes(policy.mode)
? policy.mode : "single";
const rawGlobal = policy.globalPath === "" ? root.shippedPath : policy.globalPath;
const candidatePaths = root.candidates([rawGlobal]
.concat(policy.collection || [])
.concat(Object.values(policy.assignments || {}))
.concat([policy.slideshowPath || ""]));
if (!WallpaperPolicy.validPath(rawGlobal, candidatePaths))
return null;
return {
mode,
globalPath: rawGlobal,
storedPath: rawGlobal === root.shippedPath ? "" : rawGlobal,
collection: WallpaperPolicy.validCollection(policy.collection || [], candidatePaths),
intervalMinutes: Math.max(5, Math.min(1440, Number(policy.intervalMinutes) || 30)),
shuffle: policy.shuffle !== false,
assignments: WallpaperPolicy.validAssignments(policy.assignments || {}, candidatePaths),
slideshowPath: WallpaperPolicy.validPath(policy.slideshowPath, candidatePaths)
? policy.slideshowPath : rawGlobal,
nextShuffleBag: Array.isArray(policy.nextShuffleBag)
? policy.nextShuffleBag.slice() : root.shuffleBag.slice(),
nextSlideshowIndex: Number.isInteger(policy.nextSlideshowIndex)
? policy.nextSlideshowIndex : root.slideshowIndex,
candidates: candidatePaths
};
}
function applyPolicy(policy: var, persist: bool, automatic: bool): bool {
if (root.busy)
return false;
const normalised = root.normalisePolicy(policy);
if (normalised === null) {
root.lastError = "That wallpaper policy is not valid.";
return false;
}
if (apply.running)
return false;
apply.requested = effectivePath;
apply.storedValue = path;
const outputs = Quickshell.screens.map(screen => screen.name).filter(name => !!name);
const outputs = root.outputNames();
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}`]);
const expected = WallpaperPolicy.effectiveMap(
normalised.mode, normalised.globalPath, normalised.slideshowPath,
normalised.assignments, outputs, normalised.candidates);
if (Object.keys(expected).length !== outputs.length
|| Object.values(expected).some(path => path === "")) {
root.lastError = "That wallpaper policy is not valid.";
return false;
}
root.lastError = "";
root.transaction = {
expected,
remaining: outputs.slice(),
policy: normalised,
persist: persist === true,
automatic: automatic === true
};
root.drainTransaction();
return true;
}
// The display name for a path: the file's own name, without extension,
// with separators turned into spaces.
function drainTransaction(): void {
if (root.transaction === null || applyProcess.running || verifyProcess.running)
return;
if (root.transaction.remaining.length === 0) {
verifyProcess.exec(["hyprctl", "hyprpaper", "listactive"]);
return;
}
const output = root.transaction.remaining[0];
root.transaction.remaining = root.transaction.remaining.slice(1);
applyProcess.exec([
"hyprctl", "hyprpaper", "wallpaper",
`${output},${root.transaction.expected[output]}`
]);
}
function parseActive(text: string): var {
const result = {};
const lines = String(text).split("\n").map(line => line.trim()).filter(line => line !== "");
for (const line of lines) {
const match = line.match(/^([A-Za-z0-9_.-]+): (\/[^,\n]+)$/);
if (!match || result[match[1]] !== undefined)
return null;
result[match[1]] = match[2];
}
return result;
}
function finishVerification(exitCode: int): void {
if (root.transaction === null)
return;
const observed = exitCode === 0 ? root.parseActive(verifyProcess.outputText) : null;
const expected = root.transaction.expected;
const matches = observed !== null
&& Object.keys(observed).length === Object.keys(expected).length
&& Object.keys(expected).every(output => observed[output] === expected[output]);
if (!matches) {
root.lastError = "Hyprpaper did not confirm that background.";
root.transaction = null;
root.schedulePendingHotplug();
return;
}
const completed = root.transaction;
root.activeByOutput = observed;
root.transaction = null;
root.lastError = "";
if (completed.automatic) {
root.slideshowPath = completed.policy.slideshowPath;
root.shuffleBag = completed.policy.nextShuffleBag;
root.slideshowIndex = completed.policy.nextSlideshowIndex;
}
if (completed.persist)
root.persistPolicy(completed.policy);
root.schedulePendingHotplug();
}
function persistPolicy(policy: var): void {
DesktopPreferences.set("wallpaperMode", policy.mode);
DesktopPreferences.set("wallpaperPath", policy.storedPath);
DesktopPreferences.set("wallpaperSlideshowPaths", policy.collection);
DesktopPreferences.set("wallpaperIntervalMinutes", policy.intervalMinutes);
DesktopPreferences.set("wallpaperShuffle", policy.shuffle);
DesktopPreferences.set("wallpaperPerMonitor", policy.assignments);
}
function applyCurrentPolicy(persist: bool): bool {
return root.applyPolicy(root.currentPolicy(), persist === true, false);
}
function schedulePendingHotplug(): void {
if (!root.pendingHotplugReapply)
return;
root.pendingHotplugReapply = false;
outputSettle.restart();
}
function advanceSlideshow(): bool {
if (root.busy)
return false;
const collection = root.slideshowCollection;
if (collection.length < 2)
return false;
const current = root.slideshowPath !== ""
? root.slideshowPath
: (root.active !== "" ? root.active : collection[0]);
const policy = root.currentPolicy();
policy.mode = "slideshow";
policy.collection = collection;
if (DesktopPreferences.get("wallpaperShuffle") !== false) {
const next = WallpaperPolicy.shuffledNext(
collection, root.shuffleBag, current, Math.random);
policy.slideshowPath = next.path;
policy.nextShuffleBag = next.bag;
policy.nextSlideshowIndex = collection.indexOf(next.path);
} else {
policy.slideshowPath = WallpaperPolicy.orderedNext(collection, current);
policy.nextShuffleBag = [];
policy.nextSlideshowIndex = collection.indexOf(policy.slideshowPath);
}
return root.applyPolicy(policy, false, true);
}
function setSingle(path: string): bool {
const effectivePath = path === "" ? root.shippedPath : path;
const allowed = root.candidates([]);
if (!WallpaperPolicy.validPath(effectivePath, allowed)) {
root.lastError = "That file path cannot be used as a wallpaper.";
return false;
}
const policy = root.currentPolicy();
policy.mode = "single";
policy.globalPath = effectivePath;
policy.slideshowPath = effectivePath;
return root.applyPolicy(policy, true, false);
}
function setMode(mode: string): bool {
if (!["single", "slideshow", "per-monitor"].includes(mode))
return false;
const policy = root.currentPolicy();
policy.mode = mode;
if (mode === "slideshow") {
const collection = WallpaperPolicy.validCollection(
policy.collection, root.candidates([]));
if (!collection.includes(policy.slideshowPath))
policy.slideshowPath = collection[0] || policy.globalPath;
}
return root.applyPolicy(policy, true, false);
}
function setAssignment(output: string, path: string): bool {
if (!root.outputNames().includes(output)
|| !WallpaperPolicy.validPath(path, root.candidates([])))
return false;
const policy = root.currentPolicy();
policy.mode = "per-monitor";
policy.assignments = Object.assign({}, policy.assignments);
policy.assignments[output] = path;
return root.applyPolicy(policy, true, false);
}
function toggleSlideshowPath(path: string): bool {
if (!WallpaperPolicy.validPath(path, root.candidates([])))
return false;
const policy = root.currentPolicy();
const collection = WallpaperPolicy.validCollection(policy.collection, root.candidates([]));
policy.collection = collection.includes(path)
? collection.filter(candidate => candidate !== path)
: collection.concat([path]);
policy.mode = "slideshow";
if (!policy.collection.includes(policy.slideshowPath))
policy.slideshowPath = policy.collection[0] || policy.globalPath;
return root.applyPolicy(policy, true, false);
}
function setIntervalMinutes(minutes: int): bool {
const value = PreferenceSchema.coerce("wallpaperIntervalMinutes", minutes);
return value !== undefined
&& DesktopPreferences.set("wallpaperIntervalMinutes", value);
}
function setShuffle(enabled: bool): bool {
return DesktopPreferences.set("wallpaperShuffle", enabled === true);
}
// Compatibility boundary used by backup/reset and the existing picker.
function set(path: string): bool {
return root.setSingle(path);
}
function titleFor(path: string): string {
const file = String(path).split("/").pop();
return file.replace(/\.[^.]+$/, "").replace(/[_-]+/g, " ");
@@ -0,0 +1,97 @@
function validPath(path, candidates) {
if (typeof path !== "string" || !/^\/[^,\n]+$/.test(path))
return false;
return (candidates || []).includes(path);
}
function validCollection(paths, candidates) {
const result = [];
const seen = {};
for (const path of paths || []) {
if (!validPath(path, candidates) || seen[path])
continue;
seen[path] = true;
result.push(path);
}
return result;
}
function validAssignments(assignments, candidates) {
const result = {};
if (!assignments || typeof assignments !== "object" || Array.isArray(assignments))
return result;
for (const output of Object.keys(assignments)) {
if (!/^[A-Za-z0-9_.-]+$/.test(output))
continue;
const path = assignments[output];
if (validPath(path, candidates))
result[output] = path;
}
return result;
}
function effectiveMap(mode, globalPath, slideshowPath, assignments, outputs, candidates) {
const result = {};
const fallback = validPath(globalPath, candidates)
? globalPath
: ((candidates || [])[0] || "");
const selectedAssignments = validAssignments(assignments, candidates);
const slideshow = validPath(slideshowPath, candidates) ? slideshowPath : fallback;
for (const output of outputs || []) {
if (typeof output !== "string" || !/^[A-Za-z0-9_.-]+$/.test(output))
continue;
if (mode === "per-monitor")
result[output] = selectedAssignments[output] || fallback;
else if (mode === "slideshow")
result[output] = slideshow;
else
result[output] = fallback;
}
return result;
}
function orderedNext(collection, current) {
const paths = collection || [];
if (paths.length === 0)
return "";
const index = paths.indexOf(current);
return paths[(index + 1 + paths.length) % paths.length];
}
function shuffledBag(collection, random) {
const result = Array.from(collection || []);
const nextRandom = typeof random === "function" ? random : Math.random;
for (let index = result.length - 1; index > 0; index--) {
const swap = Math.floor(Math.max(0, Math.min(0.999999999, nextRandom())) * (index + 1));
const value = result[index];
result[index] = result[swap];
result[swap] = value;
}
return result;
}
function shuffledNext(collection, bag, current, random) {
const paths = Array.from(collection || []);
if (paths.length === 0)
return { path: "", bag: [] };
const remaining = [];
const seen = {};
for (const path of bag || []) {
if (!paths.includes(path) || seen[path])
continue;
seen[path] = true;
remaining.push(path);
}
if (remaining.length === 0)
remaining.push(...shuffledBag(paths, random));
if (remaining.length > 1 && remaining[0] === current) {
const replacement = remaining.findIndex(path => path !== current);
const value = remaining[0];
remaining[0] = remaining[replacement];
remaining[replacement] = value;
}
return { path: remaining[0], bag: remaining.slice(1) };
}
@@ -0,0 +1,134 @@
pragma Singleton
// Alt-Tab, with something on screen while you do it.
//
// Named WindowSwitcherState rather than WindowSwitcher: the overlay component
// in modules/switcher already owns that name, and a singleton sharing it is
// silently shadowed wherever both are imported -- the same failure that made an
// earlier Locale singleton resolve to QML's built-in type instead.
//
// Super+Tab already cycled windows; nothing was drawn, so you were choosing
// blind and could only confirm by arriving. This holds the selection while a
// switch is in progress and lets the overlay render it.
//
// MOST-RECENTLY-USED ORDER
//
// The list is ordered by when each window last had focus, not by when it was
// opened, because that is what makes the gesture useful: one Tab returns to the
// window you just came from, which is the overwhelmingly common case. Creation
// order would send you to whichever window happens to be first in Hyprland's
// list, which is arbitrary from the user's point of view.
//
// Hyprland does not report an MRU order, so it is tracked here: every time a
// toplevel becomes active it moves to the front. Addresses are used as the key
// because they are stable for a window's lifetime, where titles and app ids are
// not.
//
// HOW A SWITCH ENDS
//
// The compositor fires a bind on Super RELEASE, which commits. That is the only
// way to know the gesture is over -- there is no "modifier released" signal
// otherwise. It means close() runs on every Super release in the session, so it
// must be cheap and a no-op when nothing is open.
import Quickshell
import Quickshell.Hyprland
import QtQuick
Singleton {
id: root
property bool open: false
property int index: 0
// Window addresses, most recently focused first.
property var recent: []
// The switch candidates, resolved fresh each time the gesture starts.
property var windows: []
readonly property var selected: (root.index >= 0 && root.index < root.windows.length)
? root.windows[root.index] : null
// Ordered by the MRU list, with anything unseen appended in Hyprland's own
// order so a brand new window is still reachable.
function orderedWindows(): var {
const all = (Hyprland.toplevels?.values ?? []).filter(t => t && t.wayland && t.wayland.appId);
const byAddress = {};
for (const toplevel of all)
byAddress[String(toplevel.address)] = toplevel;
const ordered = [];
for (const address of root.recent) {
const match = byAddress[address];
if (match) {
ordered.push(match);
delete byAddress[address];
}
}
for (const toplevel of all)
if (byAddress[String(toplevel.address)])
ordered.push(toplevel);
return ordered;
}
// Starts the gesture if it is not already running, then steps. The first
// Tab lands on the PREVIOUS window rather than the current one, which is
// what every other implementation of this gesture does.
function step(forward: bool): void {
if (!root.open) {
root.windows = root.orderedWindows();
if (root.windows.length < 2)
return;
root.open = true;
root.index = forward ? 1 : root.windows.length - 1;
return;
}
if (root.windows.length === 0)
return;
const count = root.windows.length;
root.index = forward
? (root.index + 1) % count
: (root.index - 1 + count) % count;
}
// Runs on every Super release in the session, so it does as little as
// possible when no switch is in progress.
function commit(): void {
if (!root.open)
return;
const target = root.selected;
root.open = false;
root.windows = [];
root.index = 0;
if (target && target.wayland)
target.wayland.activate();
}
function cancel(): void {
root.open = false;
root.windows = [];
root.index = 0;
}
// Focus changes maintain the MRU order. This runs whether or not a switch
// is in progress, because ordinary clicking between windows is most of how
// the order is established.
Connections {
target: Hyprland
function onActiveToplevelChanged(): void {
const active = Hyprland.activeToplevel;
if (!active || !active.address)
return;
const address = String(active.address);
const next = [address];
for (const existing of root.recent)
if (existing !== address)
next.push(existing);
// Bounded: a session can accumulate a lot of closed addresses, and
// this list is only ever used to order what is currently open.
root.recent = next.slice(0, 64);
}
}
}
@@ -15,7 +15,21 @@ ShellRoot {
property var homeFavorites: []
property bool displayOperationBusy: false
property bool displayBlocked: false
property var displayGeneration: ({ "DP-2": { mode: "4500x3000@60", scale: 1.5, transform: 0 } })
property bool displayApplyAccepted: true
property bool displayConfirmationReady: false
property var originalDisplays: ({
"DP-2": { mode: "4500x3000@60", scale: 1.5, transform: 0, x: 0, y: 0, primary: true },
"HDMI-A-1": { mode: "2560x1440@60", scale: 1, transform: 0, x: 3000, y: 0, primary: false }
})
property var restoredDisplays: ({
"DP-2": { mode: "4500x3000@60", scale: 1.5, transform: 0, x: -2560, y: 0, primary: false },
"HDMI-A-1": { mode: "2560x1440@60", scale: 1, transform: 0, x: 0, y: 0, primary: true }
})
property var displayGeneration: originalDisplays
property var liveLayout: [
{ name: "DP-2", width: 4500, height: 3000, refreshRate: 60, mode: "4500x3000@60", scale: 1.5, transform: 0, x: 0, y: 0, primary: true },
{ name: "HDMI-A-1", width: 2560, height: 1440, refreshRate: 60, mode: "2560x1440@60", scale: 1, transform: 0, x: 3000, y: 0, primary: false }
]
function record(name: string): void {
const next = root.calls.slice();
@@ -45,7 +59,10 @@ ShellRoot {
root.homeFavorites = root.homeFavorites.map(favorite =>
favorite.id === id ? { id: id, alias: alias } : favorite);
};
SettingsBackup.reloadDesktop = function() { root.record("desktop.reload"); };
SettingsBackup.reloadDesktop = function() {
root.record("desktop.reload");
root.displayGeneration = JSON.parse(JSON.stringify(root.restoredDisplays));
};
SettingsBackup.readDisplays = function() { return root.displayGeneration; };
SettingsBackup.protectDisplays = function(value) {
root.record("display.protect:" + JSON.stringify(value));
@@ -53,16 +70,39 @@ ShellRoot {
return true;
};
SettingsBackup.displayBusy = function() { return root.displayOperationBusy; };
SettingsBackup.readLiveDisplayLayout = function() {
return root.liveLayout.map(record => Object.assign({}, record));
};
SettingsBackup.applyDisplayLayout = function(layout) {
root.record("display.apply:" + JSON.stringify(layout));
if (!root.displayApplyAccepted)
return false;
root.liveLayout = layout.map(record => Object.assign({}, record));
root.displayOperationBusy = true;
root.displayConfirmationReady = true;
return true;
};
SettingsBackup.displayCanConfirm = function() { return root.displayConfirmationReady; };
SettingsBackup.confirmDisplayLayout = function() {
root.record("display.confirm");
root.displayOperationBusy = false;
root.displayConfirmationReady = false;
return true;
};
SettingsBackup.setDisplayBlocked = function(blocked) {
root.record("display.block:" + blocked);
root.displayBlocked = blocked;
};
SettingsBackup.applyIdle = function() { root.record("idle.apply"); };
SettingsBackup.idleBusy = function() { return false; };
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.applyWallpaperPolicy = function() { root.record("wallpaper.apply-policy"); };
SettingsBackup.wallpaperBusy = function() { return false; };
SettingsBackup.regenerateLock = function() { root.record("lock.regenerate"); };
SettingsBackup.lockBusy = function() { return false; };
SettingsBackup.reloadShell = function() { root.record("shell.reload"); };
}
@@ -75,7 +115,15 @@ ShellRoot {
root.homeFavorites = [];
root.displayOperationBusy = false;
root.displayBlocked = false;
root.displayApplyAccepted = true;
root.displayConfirmationReady = false;
root.displayGeneration = JSON.parse(JSON.stringify(root.originalDisplays));
root.liveLayout = [
{ name: "DP-2", width: 4500, height: 3000, refreshRate: 60, mode: "4500x3000@60", scale: 1.5, transform: 0, x: 0, y: 0, primary: true },
{ name: "HDMI-A-1", width: 2560, height: 1440, refreshRate: 60, mode: "2560x1440@60", scale: 1, transform: 0, x: 3000, y: 0, primary: false }
];
SettingsBackup.protectedDisplays = root.displayGeneration;
SettingsBackup.protectedDisplayLayout = root.liveLayout;
}
function apply(output: string): bool {
@@ -88,6 +136,11 @@ ShellRoot {
return SettingsBackup.restore("settings-20260818-010203004.json");
}
function applyDisplayFailure(output: string): bool {
root.displayApplyAccepted = false;
return SettingsBackup.handleRestoreOutput(output);
}
function status(): string {
return JSON.stringify({
calls: root.calls,
@@ -46,6 +46,8 @@ ShellRoot {
SystemSettings.reloadKeybinds = function() { root.recordReset("keybinds.reload"); };
SystemSettings.keybindsReloading = function() { return false; };
SystemSettings.applyWallpaper = function(path) { root.recordReset("wallpaper.set:" + path); };
SystemSettings.regenerateLock = function() { root.recordReset("lock.regenerate"); };
SystemSettings.lockBusy = function() { return false; };
}
IpcHandler {
+60
View File
@@ -28,6 +28,7 @@ import qs.config
import qs.services
import qs.modules.bar
import qs.modules.dock
import qs.modules.switcher
import qs.modules.overview
import qs.modules.quicksettings
import qs.modules.notifications
@@ -68,6 +69,13 @@ ShellRoot {
Dock {}
}
// The Alt-Tab overlay. Present only while a switch is in progress; the
// Loader inside it keeps the window unbuilt the rest of the time.
Variants {
model: Quickshell.screens
WindowSwitcher {}
}
Variants {
model: Quickshell.screens
SignalGlass {}
@@ -78,6 +86,8 @@ ShellRoot {
Osd {}
}
DisplayIdentify {}
// ── Single-instance overlays ────────────────────────────────────────────
// These are always constructed but only *visible* when ShellState says so.
// They're cheap while hidden, and keeping them alive means opening the
@@ -103,6 +113,18 @@ ShellRoot {
// Every parameter AND the return type must be annotated, or Quickshell
// silently declines to register the function — it will not warn you.
// Driven entirely from keybinds: Super+Tab steps, and a bind on Super
// RELEASE commits. `commit` therefore runs on every Super release in the
// session, so it returns immediately when no switch is open.
IpcHandler {
target: "switcher"
function next(): void { WindowSwitcherState.step(true); }
function previous(): void { WindowSwitcherState.step(false); }
function commit(): void { WindowSwitcherState.commit(); }
function cancel(): void { WindowSwitcherState.cancel(); }
}
IpcHandler {
target: "overview"
function toggle(): void { ShellState.toggle("overview"); }
@@ -145,6 +167,44 @@ ShellRoot {
}
}
IpcHandler {
target: "health"
function refresh(): bool { return Health.refresh(); }
function status(): string {
return JSON.stringify({
summary: Health.summary,
busy: Health.busy,
generation: Health.generation,
acceptedGeneration: Health.acceptedGeneration,
checks: Health.checks.map(check => ({ id: check.id, status: check.status }))
});
}
function open(): void {
ShellState.openSettings("services");
Health.refresh();
}
function repair(id: string): bool { return Health.repair(id, true); }
}
// Health checks the wallpaper service through the same typed IPC boundary
// as capture and clipboard. This is deliberately read-only: choosing an
// image remains an explicit Settings action.
IpcHandler {
target: "wallpaper"
function refresh(): void { Wallpaper.refreshActive(); }
function status(): string {
return JSON.stringify({
active: Wallpaper.active,
activeByOutput: Wallpaper.activeByOutput,
configured: Wallpaper.configured,
availableCount: Wallpaper.available.length,
lastError: Wallpaper.lastError
});
}
}
// A small diagnostics surface doubles as a deterministic contract harness.
// Real producers call StatusEvents.publish() directly; fixtures never run
// unless explicitly requested over IPC by the test suite.
+52 -1
View File
@@ -9,11 +9,55 @@ import qs.services
import qs.modules.settings
ShellRoot {
id: root
QtObject {
id: fixtureAudio
property real volume: 0.42
property bool muted: false
}
QtObject {
id: fixtureNode
property QtObject audio: fixtureAudio
}
readonly property var fixtureApplication: ({
key: "fixture.player",
label: "Fixture Player",
icon: "audio-x-generic-symbolic",
nodes: [fixtureNode]
})
SoundPage {
width: 760
height: 900
}
ApplicationMixer {
id: populatedMixer
width: 620
visible: false
applications: [root.fixtureApplication]
pipewireReady: true
}
ApplicationMixer {
id: emptyMixer
width: 620
visible: false
applications: []
pipewireReady: true
}
ApplicationMixer {
id: unavailableMixer
width: 620
visible: false
applications: []
pipewireReady: false
}
PwObjectTracker {
objects: AudioDevices.outputs.concat(AudioDevices.inputs)
}
@@ -26,8 +70,15 @@ ShellRoot {
ready: Pipewire.ready,
outputs: AudioDevices.outputs.length,
inputs: AudioDevices.inputs.length,
applications: AudioDevices.applications.length,
defaultOutput: AudioDevices.label(AudioDevices.current(true)),
defaultInput: AudioDevices.label(AudioDevices.current(false))
defaultInput: AudioDevices.label(AudioDevices.current(false)),
populatedRows: populatedMixer.rowCount,
populatedStatus: populatedMixer.statusText,
emptyRows: emptyMixer.rowCount,
emptyStatus: emptyMixer.statusText,
unavailableRows: unavailableMixer.rowCount,
unavailableStatus: unavailableMixer.statusText
});
}
}
@@ -0,0 +1,58 @@
import Quickshell
import Quickshell.Io
import QtQuick
import "services/WallpaperPolicy.js" as WallpaperPolicy
ShellRoot {
readonly property var candidates: ["/images/a.jpg", "/images/b.jpg", "/images/c.jpg"]
readonly property var outputs: ["DP-2", "HDMI-A-1"]
IpcHandler {
target: "wallpaper-policy-test"
function status(): string {
const collection = WallpaperPolicy.validCollection([
"/images/a.jpg", "/invalid.jpg", "/images/b.jpg",
"/images/a.jpg", "relative.jpg", "/images/c.jpg"
], candidates);
const assignments = WallpaperPolicy.validAssignments({
"DP-2": "/images/b.jpg",
"HDMI-A-1": "/invalid.jpg",
"bad connector!": "/images/c.jpg"
}, candidates);
return JSON.stringify({
validAbsolute: WallpaperPolicy.validPath("/images/a.jpg", candidates),
invalidComma: WallpaperPolicy.validPath("/images/a,b.jpg", candidates),
invalidUnknown: WallpaperPolicy.validPath("/images/nope.jpg", candidates),
collection,
assignments,
single: WallpaperPolicy.effectiveMap(
"single", "/images/a.jpg", "", assignments, outputs, candidates),
perMonitor: WallpaperPolicy.effectiveMap(
"per-monitor", "/images/a.jpg", "", assignments, outputs, candidates),
slideshow: WallpaperPolicy.effectiveMap(
"slideshow", "/images/a.jpg", "/images/c.jpg", assignments, outputs, candidates),
ordered: [
WallpaperPolicy.orderedNext(collection, "/images/a.jpg"),
WallpaperPolicy.orderedNext(collection, "/images/b.jpg"),
WallpaperPolicy.orderedNext(collection, "/images/c.jpg")
]
});
}
function shuffle(): string {
const values = [0.8, 0.1, 0.6, 0.2, 0.9, 0.4];
let index = 0;
const random = () => values[index++ % values.length];
let state = { bag: [], current: "" };
const emitted = [];
for (let count = 0; count < 4; count++) {
state = WallpaperPolicy.shuffledNext(candidates, state.bag, state.current, random);
emitted.push(state.path);
state.current = state.path;
}
return JSON.stringify({ emitted, remaining: state.bag });
}
}
}
@@ -0,0 +1,70 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import qs.services
ShellRoot {
Component.onCompleted: {
Wallpaper.outputOverride = ["DP-2", "HDMI-A-1"];
Wallpaper.candidateOverride = ["/images/a.jpg", "/images/b.jpg", "/images/c.jpg"];
Wallpaper.startupRestoreEnabled = false;
Wallpaper.slideshowIntervalOverrideMs = 60000;
Wallpaper.available = ["/images/a.jpg", "/images/b.jpg", "/images/c.jpg"];
}
IpcHandler {
target: "wallpaper-service-test"
function applyPerMonitor(): bool {
return Wallpaper.applyPolicy({
mode: "per-monitor",
globalPath: "/images/a.jpg",
collection: [],
intervalMinutes: 30,
shuffle: true,
assignments: { "HDMI-A-1": "/images/b.jpg" },
slideshowPath: ""
}, true);
}
function applySingle(path: string): bool {
return Wallpaper.set(path);
}
function seedSlideshow(paths: string, shuffle: bool): void {
DesktopPreferences.set("wallpaperMode", "slideshow");
DesktopPreferences.set("wallpaperPath", "/images/a.jpg");
DesktopPreferences.set("wallpaperSlideshowPaths", JSON.parse(paths));
DesktopPreferences.set("wallpaperShuffle", shuffle);
Wallpaper.slideshowPath = "/images/a.jpg";
Wallpaper.shuffleBag = [];
}
function advanceSlideshow(): bool {
return Wallpaper.advanceSlideshow();
}
function rapidOutputs(): void {
Wallpaper.outputOverride = ["DP-2"];
Wallpaper.outputOverride = ["HDMI-A-1"];
Wallpaper.outputOverride = ["DP-2", "HDMI-A-1"];
}
function status(): string {
return JSON.stringify({
busy: Wallpaper.busy,
lastError: Wallpaper.lastError,
activeByOutput: Wallpaper.activeByOutput,
active: Wallpaper.active,
mode: DesktopPreferences.get("wallpaperMode"),
path: DesktopPreferences.get("wallpaperPath"),
assignments: DesktopPreferences.get("wallpaperPerMonitor"),
slideshowPath: Wallpaper.slideshowPath,
shuffleBag: Wallpaper.shuffleBag,
slideshowTimerRunning: Wallpaper.slideshowTimerRunning
});
}
}
}
@@ -0,0 +1,73 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.modules.settings
ShellRoot {
id: root
property var calls: []
function record(call: string): void {
root.calls = root.calls.concat([call]);
}
WallpaperControls {
id: controls
width: 620
outputs: ["DP-2", "HDMI-A-1"]
mode: "per-monitor"
setModeAction: mode => root.record("mode:" + mode)
setIntervalAction: minutes => root.record("interval:" + minutes)
setShuffleAction: enabled => root.record("shuffle:" + enabled)
}
WallpaperPicker {
id: picker
width: 620
selectedOutput: "DP-2"
mode: "single"
activeByOutput: ({ "DP-2": "/images/a.jpg", "HDMI-A-1": "/images/b.jpg" })
slideshowPaths: ["/images/b.jpg", "/images/c.jpg"]
assignments: ({ "DP-2": "/images/c.jpg" })
setSingleAction: path => root.record("single:" + path)
toggleSlideshowAction: path => root.record("toggle:" + path)
setAssignmentAction: (output, path) => root.record("assign:" + output + ":" + path)
}
IpcHandler {
target: "wallpaper-settings-test"
function activate(mode: string, path: string): string {
root.calls = [];
picker.mode = mode;
picker.activate(path);
return JSON.stringify(root.calls);
}
function states(): string {
picker.mode = "single";
const single = {
current: picker.isCurrent("/images/a.jpg"),
selected: picker.selected("/images/a.jpg")
};
picker.mode = "slideshow";
const slideshow = {
current: picker.isCurrent("/images/b.jpg"),
selected: picker.selected("/images/b.jpg")
};
picker.mode = "per-monitor";
const perMonitor = {
current: picker.isCurrent("/images/c.jpg"),
selected: picker.selected("/images/c.jpg")
};
return JSON.stringify({
selectedOutput: controls.selectedOutput,
single,
slideshow,
perMonitor
});
}
}
}
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Panama: Check System Health
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Review Panama services, integrations, and recovery actions.
# @vicinae.keywords ["health", "doctor", "repair", "services"]
exec "$HOME/.config/quickshell/scripts/panama-action" health
@@ -0,0 +1,598 @@
# Panama Health & Recovery 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:** Build a quiet, trustworthy System Health surface that diagnoses Panama-owned desktop functionality, exposes redacted reports, and offers only narrow allow-listed repairs.
**Architecture:** An executable Python helper, `panama-doctor`, is the only operating-system boundary and emits one deterministic JSON schema. A `Health.qml` singleton owns accepted snapshots, scan generations, repair state, and report copying; Settings, the bar, IPC, and Vicinae consume that typed state without constructing commands.
**Tech Stack:** Python 3 standard library, Bash contract tests, Quickshell/QML, QtQuick, Hyprland IPC, Vicinae script commands, Prism design tokens.
**Spec:** `docs/superpowers/specs/2026-08-18-panama-health-recovery-design.md`
## Global Constraints
- Healthy background scans are silent: no notifications, Signal Glass events, animations, or permanent bar ornament.
- Allowed statuses are exactly `ok`, `warning`, `error`, and `unconfigured`; overall status is `healthy`, `warning`, or `error`.
- Optional integrations that have never been configured are `unconfigured`, never warnings.
- The helper never reads or reports secret values, clipboard contents, notification bodies, calendar event data, SSIDs, addresses, or arbitrary command output.
- The helper never installs packages, invokes `sudo`, deletes user data, rewrites arbitrary configuration, or repairs services Panama does not own.
- Probe-derived values may populate observations only; check IDs, groups, titles, actions, commands, and arguments are authored constants.
- All process launches use argument arrays. UI text and report content never become commands.
- Preserve the last valid snapshot on helper failure or malformed JSON.
- Repairs are judged by a fresh observed scan, not by process exit status alone.
- Do not run a state-changing live repair without a genuinely degraded disposable target or explicit user approval.
## File map and stable interfaces
- `config/dot/quickshell/scripts/panama-doctor`: Python CLI and sole diagnostic/repair OS boundary.
- `config/dot/quickshell/services/Health.qml`: snapshot state machine, scan/repair processes, report copy, and fixture seams.
- `config/dot/quickshell/health-harness.qml`: deterministic IPC harness for generations, malformed data, coalescing, and repairs.
- `config/dot/quickshell/modules/settings/HealthPage.qml`: System Health page composition.
- `config/dot/quickshell/modules/settings/HealthSummary.qml`: stable-height summary hero and primary controls.
- `config/dot/quickshell/modules/settings/HealthCheckRow.qml`: one accessible check row with one action.
- `config/dot/quickshell/modules/bar/HealthIndicator.qml`: degraded-only bar entry point.
- `config/local/share/vicinae/scripts/check-system-health.sh`: searchable launcher command.
- `tests/quickshell/fixtures/doctor/`: isolated command, config, state, and runtime fixtures containing no real workstation data.
- `tests/quickshell/panama-doctor-contract.sh`: schema, status, redaction, timeout, ordering, and repair allow-list contract.
- `tests/quickshell/health-service-contract.sh`: QML state-machine contract.
- `tests/quickshell/health-ui-contract.sh`: Settings, footer, report, indicator, IPC, and Vicinae integration contract.
The helper's authored check order is:
```text
desktop.hyprland
desktop.quickshell
desktop.notifications
desktop.portals
desktop.hyprpaper
desktop.hypridle
desktop.vicinae
input.pipewire
input.clipboard
input.wallpaper
input.capture
input.ocr
input.brightness
integration.nextcloud
integration.rustdesk
integration.kdeconnect
integration.bluebubbles
integration.home-assistant
integration.calendar
panama.runtime-links
panama.vicinae-commands
panama.selected-terminal
panama.selected-launcher
panama.processes
panama.caffeine
```
Only these repair IDs are executable in release one:
```text
desktop.hyprpaper -> systemctl --user restart hyprpaper.service
desktop.hypridle -> systemctl --user restart hypridle.service
desktop.vicinae -> systemctl --user restart vicinae.service
desktop.quickshell -> panama-action restart-shell (confirmation required)
panama.runtime-links -> recreate only known Panama-owned broken symlinks
panama.vicinae-commands -> setup/scripts/link-vicinae-scripts
panama.caffeine -> release duplicate Panama/Caffeine inhibitor PIDs only
```
---
### Task 1: Prism health mocks and visual approval
**Approved visual:** A — Diagnostic Ledger. Preserve its restrained issue rail, stable summary hero, two-column healthy ledgers, live sidebar footer, and degraded-only bar capsule.
**Files:**
- Create outside tracked source: `.superpowers/mocks/system-health/index.html`
- Create outside tracked source: `.superpowers/mocks/system-health/panama.css`
- Create outside tracked source: `.superpowers/mocks/system-health/mock.js`
- Create outside tracked source: `.superpowers/mocks/system-health/a-ledger.html`
- Create outside tracked source: `.superpowers/mocks/system-health/b-focus.html`
- Create outside tracked source: `.superpowers/mocks/system-health/c-compact.html`
**Interfaces:**
- Consumes: the existing 272 px Settings sidebar, 48 px titlebar, Tokyo Night Moon Prism tokens, and the approved information architecture.
- Produces: one approved visual composition for healthy, warning, checking, and error states plus the degraded-only bar indicator.
- [ ] **Step 1: Build three static compositions from real copy**
Use the same warning fixture in all three: `Vicinae` is stopped with action `Restart Vicinae`; `External monitor brightness` needs permission with action `View setup instructions`; `BlueBubbles` is `Not set up`. Keep every variant inside the real Settings geometry and include the footer and bar indicator.
```text
A — Diagnostic ledger: one restrained amber issue rail beside calm grouped rows.
B — Focus card: issues receive the visual focus; healthy groups collapse into quieter ledgers below.
C — Compact matrix: dense two-column group cards with the same issue-first ordering.
```
- [ ] **Step 2: Serve and visually inspect the mocks**
Run:
```bash
python3 -m http.server 52780 --directory .superpowers/mocks/system-health
```
Expected: all three variants render at `http://localhost:52780`, keyboard focus is visible, no element overflows at 1360x900, and reduced-motion mode has no continuous animation.
- [ ] **Step 3: Capture the approved direction in the plan**
Add a short `Approved visual: <variant>` note beneath this task after user selection. Production UI work in Task 4 must reproduce that composition using existing QML tokens rather than copying browser-only effects.
### Task 2: Deterministic read-only doctor
**Files:**
- Create: `config/dot/quickshell/scripts/panama-doctor`
- Create: `tests/quickshell/panama-doctor-contract.sh`
- Create: `tests/quickshell/fixtures/doctor/bin/systemctl`
- Create: `tests/quickshell/fixtures/doctor/bin/pgrep`
- Create: `tests/quickshell/fixtures/doctor/bin/busctl`
- Create: `tests/quickshell/fixtures/doctor/bin/qs`
- Create: `tests/quickshell/fixtures/doctor/bin/vicinae`
- Create: `tests/quickshell/fixtures/doctor/bin/systemd-inhibit`
**Interfaces:**
- Consumes: `PANAMA_DOCTOR_ROOT`, `PANAMA_DOCTOR_HOME`, `PANAMA_DOCTOR_CONFIG_HOME`, `PANAMA_DOCTOR_STATE_HOME`, `PANAMA_DOCTOR_RUNTIME_DIR`, `PANAMA_DOCTOR_PATH`, and `PANAMA_DOCTOR_TIMEOUT` test seams; production defaults resolve from the real process environment.
- Produces: `panama-doctor --json`, `panama-doctor --summary`, and a versioned schema with `summary` plus the 25 ordered check objects listed above.
- [ ] **Step 1: Write the failing schema and redaction contract**
The contract must create an isolated home, tracked source tree, runtime tree, and fake command directory, then assert:
```bash
snapshot="$($doctor --json)"
jq -e '.schemaVersion == 1
and (.generatedAt | type == "string")
and (.summary.status | IN("healthy", "warning", "error"))
and (.context.session | IN("hyprland", "other"))
and (.context.versions | type == "array")
and ([.checks[].id] | length == 25)
and ([.checks[].id] | unique | length == 25)
and ([.checks[].status] | all(IN("ok", "warning", "error", "unconfigured")))' <<<"$snapshot"
[[ "$(jq -r '.checks[].id' <<<"$snapshot")" == "$expected_order" ]]
! grep -Fq 'fixture-secret-token' <<<"$snapshot"
! grep -Fq 'fixture clipboard body' <<<"$snapshot"
! grep -Fq 'AA:BB:CC:DD:EE:FF' <<<"$snapshot"
```
Cover a healthy required service, a missing required executable, an unconfigured optional integration, a configured-but-stopped integration, an inaccessible DDC bus, a timed-out probe, duplicate Caffeine inhibitors, malformed probe output, and concise `--summary` output.
- [ ] **Step 2: Run the contract and verify the helper is absent**
Run: `tests/quickshell/panama-doctor-contract.sh`
Expected: FAIL because `config/dot/quickshell/scripts/panama-doctor` does not exist.
- [ ] **Step 3: Implement authored checks and concurrent bounded probes**
Use Python standard-library types and deterministic assembly:
```python
@dataclass(frozen=True)
class Action:
kind: Literal["repair", "open", "instructions"]
label: str
confirm: bool = False
@dataclass(frozen=True)
class Check:
id: str
group: Literal["desktop-foundation", "input-media", "integrations", "panama-tools"]
title: str
status: Literal["ok", "warning", "error", "unconfigured"]
detail: str
action: Action | None = None
```
Run independent probes through `ThreadPoolExecutor(max_workers=8)`. Every subprocess call must use a constant argument tuple, `capture_output=True`, `text=True`, and the configured timeout. Convert timeout, non-zero status, and parse failure into a check result. Assemble checks by the authored ID tuple after futures settle; never emit completion order.
Configuration checks may inspect existence and file type only. Home Assistant is configured when both expected variable names are present, but their values are never retained. Calendar is configured from enabled EDS source count only; event commands are never called. BlueBubbles is configured from the Flatpak installation check. DDC uses only `panama-brightness list` and retains display count plus its authored error classification, never connector names.
The top-level `context` contains only an authored session class and an ordered array of parsed Hyprland, Quickshell, Fedora, and Panama revision versions. `panama.processes` counts only exact authored process names and flags duplicate Quickshell, Vicinae, Hyprpaper, or Hypridle instances without exposing command lines. Integration actions are authored too: Nextcloud, RustDesk, KDE Connect, and BlueBubbles may offer their exact Open action; Home Assistant and calendar failures route to `home-phone` and `datetime`; never derive an application or page name from probe output.
- [ ] **Step 4: Run the doctor contract**
Run: `tests/quickshell/panama-doctor-contract.sh`
Expected: `panama doctor contract: PASS`.
- [ ] **Step 5: Commit the read-only engine**
```bash
git add config/dot/quickshell/scripts/panama-doctor tests/quickshell/panama-doctor-contract.sh tests/quickshell/fixtures/doctor
git commit -m "Add Panama system health diagnostics"
```
### Task 3: Health singleton and typed IPC state machine
**Files:**
- Create: `config/dot/quickshell/services/Health.qml`
- Create: `config/dot/quickshell/health-harness.qml`
- Create: `tests/quickshell/health-service-contract.sh`
- Modify: `config/dot/quickshell/shell.qml`
**Interfaces:**
- Consumes: `panama-doctor --json` and `panama-doctor --repair CHECK_ID --json`.
- Produces: `Health.snapshot`, `Health.checks`, `Health.summary`, `Health.status`, `Health.actionable`, `Health.busy`, `Health.diagnosticUnavailable`, `Health.lastError`, `Health.repairingId`, `Health.lastRepair`, `Health.lastCopyResult`, `Health.refresh()`, `Health.repair(id, external)`, `Health.copyReport()`, and IPC target `health` with `refresh`, `status`, `open`, and `repair(id)`.
- [ ] **Step 1: Write the failing QML state contract**
The harness exposes fixture methods that call the real singleton's pure consumption seams:
```qml
function accept(text: string, generation: int): bool { return Health.consumeSnapshot(text, generation); }
function queue(): void { Health.refresh(); Health.refresh(); }
function status(): string { return JSON.stringify(Health.diagnostics()); }
```
Assert that a valid warning snapshot is accepted, an older generation is ignored, malformed JSON preserves the prior checks and marks the engine unavailable, two refreshes while running schedule exactly one follow-up, a valid repair triggers one rescan, and unknown/non-repairable IDs start no process.
- [ ] **Step 2: Run the contract and verify it fails**
Run: `tests/quickshell/health-service-contract.sh`
Expected: FAIL because `Health.qml` and the harness do not exist.
- [ ] **Step 3: Implement the singleton state machine**
Define the stable state shape:
```qml
property var snapshot: ({})
property var checks: []
property var summary: ({ status: "healthy", healthy: 0, warnings: 0, errors: 0, unconfigured: 0 })
property string status: "healthy"
property bool diagnosticUnavailable: false
property bool queuedRefresh: false
property int generation: 0
property int acceptedGeneration: 0
property string repairingId: ""
property var lastRepair: ({})
property string lastCopyResult: ""
readonly property bool actionable: status === "warning" || status === "error"
readonly property bool busy: scanProcess.running || repairProcess.running
```
Use `Process.exec([root.helperPath, "--json"])`; attach the current generation to the collector before launch. `consumeSnapshot(text, generation)` validates schema version, summary keys, context shape, unique IDs, groups, statuses, titles, details, and action shapes before replacing state. A 2200 ms one-shot startup timer requests the initial scan. A running scan sets `queuedRefresh`; exit consumes at most one queued follow-up. `copyReport()` sends only `JSON.stringify(root.snapshot, null, 2)` to `wl-copy` through a `Process` stdin buffer and writes success or failure to `lastCopyResult` without touching the clipboard service's history model.
The `health` IPC `status()` returns only the already-redacted summary, busy flags, generation, and check IDs/statuses. `open()` calls `ShellState.openSettings("services")` then refreshes. IPC `repair(id)` calls `Health.repair(id, true)` and returns a Boolean acceptance result; Settings calls `Health.repair(id, false)`. A failed externally-originated repair uses an argument-array `notify-send` process with the existing `Panama action failed` title, while Settings failures remain inline.
- [ ] **Step 4: Run service and IPC contracts**
Run:
```bash
tests/quickshell/health-service-contract.sh
tests/quickshell/settings-window-contract.sh
```
Expected: both PASS.
- [ ] **Step 5: Commit the service layer**
```bash
git add config/dot/quickshell/services/Health.qml config/dot/quickshell/health-harness.qml tests/quickshell/health-service-contract.sh config/dot/quickshell/shell.qml
git commit -m "Add Panama health state service"
```
### Task 4: Approved System Health Settings page
**Files:**
- Create: `config/dot/quickshell/modules/settings/HealthPage.qml`
- Create: `config/dot/quickshell/modules/settings/HealthSummary.qml`
- Create: `config/dot/quickshell/modules/settings/HealthCheckRow.qml`
- Modify: `config/dot/quickshell/modules/settings/SettingsShell.qml`
- Modify: `config/dot/quickshell/modules/settings/SettingsSidebar.qml`
- Modify: `config/dot/quickshell/services/SettingsSearch.qml`
- Delete: `config/dot/quickshell/modules/settings/ServicesPage.qml`
- Create: `tests/quickshell/health-ui-contract.sh`
- Modify: `tests/quickshell/settings-pages-contract.sh`
- Modify: `tests/quickshell/settings-search-contract.sh`
**Interfaces:**
- Consumes: all read-only state and methods from `Health.qml`; route remains the stable internal name `services`.
- Produces: System Health summary, issues-first cards, four grouped ledgers, live clickable sidebar footer, Copy Report feedback, and confirmation requests for disruptive repairs.
- [ ] **Step 1: Write failing Settings and accessibility assertions**
Assert static structure and fixture-rendered state:
```bash
rg -Fq 'label: "System Health"' config/dot/quickshell/modules/settings/SettingsSidebar.qml
rg -Fq 'onClicked: Health.copyReport()' config/dot/quickshell/modules/settings/HealthSummary.qml
rg -Fq 'onTapped: root.pageRequested("services")' config/dot/quickshell/modules/settings/SettingsSidebar.qml
rg -Fq 'text: "Checking…"' config/dot/quickshell/modules/settings/HealthSummary.qml
rg -Fq 'Health.refresh()' config/dot/quickshell/modules/settings/HealthPage.qml
```
The runtime harness must prove warning rows appear before healthy groups, unconfigured is visible as `Not set up`, every status has text in addition to color, refresh preserves row geometry, keyboard focus reaches both hero actions and row actions, and a Quickshell-restart repair opens a confirmation sheet.
- [ ] **Step 2: Run UI contracts and verify failure**
Run:
```bash
tests/quickshell/health-ui-contract.sh
tests/quickshell/settings-pages-contract.sh
tests/quickshell/settings-search-contract.sh
```
Expected: FAIL because the approved Health components are absent.
- [ ] **Step 3: Implement the approved composition**
Use `SettingsPage`, `SettingsCard`, `SettingsButton`, `Theme`, and `PrismEdge`. Keep the summary hero stable at 126 px and each check row at least 62 px. Derive labels exactly:
```qml
function statusLabel(status: string): string {
if (status === "ok") return "Healthy";
if (status === "warning") return "Needs attention";
if (status === "error") return "Action required";
return "Not set up";
}
```
`HealthPage.Component.onCompleted` calls `Health.refresh()`. Issues are checks with `warning` or `error`. Group cards preserve helper order. Each row exposes at most one action. `open` actions route to exact Settings pages; `instructions` actions reveal authored inline instructions; `repair` actions call `Health.repair(id, false)` after confirmation only when `action.confirm === true`.
The sidebar footer is a 54 px `TapHandler` target with status text derived from Health, not a hardcoded string. It opens `services`; when no scan has completed it says `Checking Panama desktop`. A malformed or failed doctor run retains the last rows, changes only the hero to `Health check unavailable`, and exposes one bounded `Retry` action. The page ends with the approved boundary note and an `Open GNOME Settings` action for networking, printers, users, and other Fedora-owned areas.
- [ ] **Step 4: Run UI contracts and inspect the rendered fixture**
Run:
```bash
tests/quickshell/health-ui-contract.sh
tests/quickshell/settings-pages-contract.sh
tests/quickshell/settings-search-contract.sh
```
Expected: all PASS with zero QML warnings.
- [ ] **Step 5: Commit the Settings experience**
```bash
git add config/dot/quickshell/modules/settings config/dot/quickshell/services/SettingsSearch.qml tests/quickshell/health-ui-contract.sh tests/quickshell/settings-pages-contract.sh tests/quickshell/settings-search-contract.sh
git commit -m "Build the System Health settings page"
```
### Task 5: Quiet bar indicator and launcher entry point
**Files:**
- Create: `config/dot/quickshell/modules/bar/HealthIndicator.qml`
- Modify: `config/dot/quickshell/modules/bar/Bar.qml`
- Create: `config/local/share/vicinae/scripts/check-system-health.sh`
- Modify: `config/dot/quickshell/scripts/panama-action`
- Modify: `tests/quickshell/health-ui-contract.sh`
- Modify: `tests/quickshell/panama-action-contract.sh`
- Modify: `tests/quickshell/panama-commands-contract.sh`
**Interfaces:**
- Consumes: `Health.actionable`, `Health.status`, and `Health.summary`; existing `panama-action` dispatcher and Settings IPC.
- Produces: one degraded-only bar affordance and Vicinae command `Panama: Check System Health`.
- [ ] **Step 1: Extend contracts before production files**
Assert the indicator is absent for healthy/unconfigured-only fixtures, visible amber for warnings, visible red for errors, includes a textual accessible label, and opens `services`. Extend command fixtures so:
```text
panama-action health -> qs ipc call health open
check-system-health.sh title -> Panama: Check System Health
check-system-health.sh exec -> $HOME/.config/quickshell/scripts/panama-action health
```
- [ ] **Step 2: Run focused tests and verify failure**
Run:
```bash
tests/quickshell/health-ui-contract.sh
tests/quickshell/panama-action-contract.sh
tests/quickshell/panama-commands-contract.sh
```
Expected: FAIL on the missing indicator and command.
- [ ] **Step 3: Implement the quiet entry points**
Place `HealthIndicator` in the right-side bar row before `ActivityIndicator`. It has no reserved width while hidden, no animation, and one compact shield/wrench glyph with an issue-count tooltip or accessible description. Use `Theme.warn` only for warnings and `Theme.danger` only for errors. Clicking calls `ShellState.openSettings("services")` and `Health.refresh()`.
Add this dispatcher case and usage token:
```bash
health) qs ipc call health open ;;
```
Create a Vicinae script with schema version 1, silent mode, Panama Settings icon, keywords `health`, `doctor`, `repair`, `services`, and the stable `panama-action health` execution path.
- [ ] **Step 4: Run focused tests**
Run the three commands from Step 2.
Expected: all PASS; command count increases from 17 to 18.
- [ ] **Step 5: Commit the entry points**
```bash
git add config/dot/quickshell/modules/bar config/local/share/vicinae/scripts/check-system-health.sh config/dot/quickshell/scripts/panama-action tests/quickshell
git commit -m "Add quiet System Health entry points"
```
### Task 6: Allow-listed repairs and observed recovery
**Files:**
- Modify: `config/dot/quickshell/scripts/panama-doctor`
- Modify: `tests/quickshell/panama-doctor-contract.sh`
- Modify: `config/dot/quickshell/services/Health.qml`
- Modify: `tests/quickshell/health-service-contract.sh`
- Modify: `config/dot/quickshell/modules/settings/HealthCheckRow.qml`
- Modify: `tests/quickshell/health-ui-contract.sh`
**Interfaces:**
- Consumes: the fixed repair matrix in this plan and current accepted checks from `Health.qml`.
- Produces: `panama-doctor --repair CHECK_ID --json` result `{schemaVersion, checkId, accepted, exitCode, message}`, inline repair state, and one post-repair scan.
- [ ] **Step 1: Add exact repair-command tests**
For every repair ID, use fake commands and isolated paths to assert the exact argv. Assert all of these are rejected before any process or filesystem write:
```text
unknown.check
integration.home-assistant
input.brightness
desktop.notifications
../../escape
desktop.vicinae;touch injected
```
For runtime links, fixtures must prove only these link names are eligible: `hypr`, `quickshell`, `uwsm`, and `vicinae`; a regular user-owned directory is reported but never replaced. For Caffeine, only duplicate rows with application `Panama`, current UID, reason `Caffeine`, and mode `block` may yield numeric PIDs; leave one valid inhibitor alive and release extras.
- [ ] **Step 2: Run repair contracts and verify failure**
Run:
```bash
tests/quickshell/panama-doctor-contract.sh
tests/quickshell/health-service-contract.sh
```
Expected: FAIL because `--repair` is not implemented.
- [ ] **Step 3: Implement the authored repair registry**
Represent commands as immutable constant tuples or dedicated functions:
```python
REPAIR_COMMANDS = {
"desktop.hyprpaper": ("systemctl", "--user", "restart", "hyprpaper.service"),
"desktop.hypridle": ("systemctl", "--user", "restart", "hypridle.service"),
"desktop.vicinae": ("systemctl", "--user", "restart", "vicinae.service"),
"desktop.quickshell": ("panama-action", "restart-shell"),
}
```
Handle runtime links, Vicinae command linking, and duplicate inhibitors in dedicated functions that accept no caller-controlled path or command. Return JSON on every known failure. Unknown IDs exit 2 with `accepted: false` and do not invoke any runner.
`Health.repair(id, external)` requires the ID to exist in the current snapshot with `action.kind === "repair"`, records `repairingId`, runs the helper with an argument array, parses the result, clears the busy row, and requests exactly one fresh scan. Keep the row degraded until that scan reports recovery.
- [ ] **Step 4: Run repair and UI contracts**
Run:
```bash
tests/quickshell/panama-doctor-contract.sh
tests/quickshell/health-service-contract.sh
tests/quickshell/health-ui-contract.sh
```
Expected: all PASS.
- [ ] **Step 5: Commit repairs**
```bash
git add config/dot/quickshell/scripts/panama-doctor config/dot/quickshell/services/Health.qml config/dot/quickshell/modules/settings/HealthCheckRow.qml tests/quickshell
git commit -m "Add bounded Panama recovery actions"
```
### Task 7: Full verification, controller-deferred live audit, and documentation
**Integration note:** `origin/main` added Mouse, Privacy, Region, and Online
Accounts destinations while this feature was in review. Merge commit `4ef2f01`
preserves those routes and the newer Settings navigation architecture, keeps
the stable `services` route rendered by `HealthPage`, and leaves
`ServicesPage.qml` retired. Its useful Fedora handoffs for Users, Sharing,
Colour profiles, and Digital wellbeing now live in the boundary-last System
Health card alongside the existing network handoff, with focused static and
isolated runtime coverage.
**Live-audit handoff:** Per the integration brief, this task does not reload the
daily-driver Quickshell, invoke a live repair, or run the read-only live
doctor/IPC comparison. Those checks remain for the controller after code review.
**Files:**
- Modify: `config/dot/hypr/DESKTOP-PARITY.md`
- Modify: `config/dot/quickshell/modules/settings/README.md`
- Modify: `docs/superpowers/plans/2026-08-18-panama-health-recovery.md`
**Interfaces:**
- Consumes: the complete feature and existing regression suite.
- Produces: current user documentation and final contract evidence. The
redacted live health snapshot and live shell audit are deferred to the
controller after code review.
- [x] **Step 1: Document boundaries and entry points**
Document `Panama: Check System Health`, Settings → System Health, the degraded-only bar indicator, `panama-doctor --summary`, the no-`sudo`/no-package-install boundary, and the fact that GNOME/Fedora tools remain responsible for generic system configuration.
- [ ] **Step 2: Run syntax, focused, and full contracts**
Run:
```bash
python3 -m py_compile config/dot/quickshell/scripts/panama-doctor
bash -n config/dot/quickshell/scripts/panama-action
tests/quickshell/panama-doctor-contract.sh
tests/quickshell/health-service-contract.sh
tests/quickshell/health-ui-contract.sh
for test in tests/quickshell/*contract.sh; do "$test"; done
for test in tests/hypr/*contract.sh; do "$test"; done
```
Expected: every command exits 0. After the latest Settings and installer work,
the current inventory is 66 Quickshell contracts and 2 Hyprland contracts (the original
pre-merge estimate was 58).
Integration result: syntax and all focused Health/Settings contracts pass. Two
complete serial Quickshell runs each passed 63/65, but failed on different
order-sensitive contracts. Run one failed Displays and Settings Hyprland Write;
run two failed Focus Session and Health Service. Each failed contract passed
immediately when rerun alone. Hyprland contracts passed 2/2. No out-of-scope
test or service code was changed to hide this suite-order interference.
- [ ] **Step 3: Controller runs a redacted live read-only comparison**
Deferred to the controller after code review; Task 7 does not produce this
live output.
Run:
```bash
config/dot/quickshell/scripts/panama-doctor --json >"$(mktemp)"
config/dot/quickshell/scripts/panama-doctor --summary
systemctl --user is-active hyprpaper.service hypridle.service vicinae.service pipewire.service
qs ipc call health refresh
qs ipc call health status | jq '{status, busy, checks: [.checks[] | {id, status}]}'
```
Expected: helper and direct service states agree. Do not print details from integrations; copied and IPC reports contain only redacted authored observations.
- [ ] **Step 4: Controller reloads and inspects the live shell**
Deferred to the controller after code review; Task 7 does not reload or inspect
the daily-driver shell.
Run:
```bash
qs reload
sleep 4
journalctl --user --since '-2 minutes' --no-pager | rg -i 'quickshell|qml|panama' | tail -200
```
Expected: the shell returns, System Health opens, the healthy state is silent, and there are no new QML errors or binding-loop warnings. Do not invoke a repair during this step.
- [ ] **Step 5: Final diff and commit**
Run:
```bash
git diff --check
git status --short
git diff --stat origin/main...HEAD
git add config/dot/hypr/DESKTOP-PARITY.md config/dot/quickshell/modules/settings/README.md docs/superpowers/plans/2026-08-18-panama-health-recovery.md
git commit -m "Document Panama health and recovery"
```
Expected: only intentional Health & Recovery files are present and no workstation-specific values appear in the diff.
@@ -0,0 +1,260 @@
# Phase 2 Application Volume 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:** Add a live, non-persistent per-application playback mixer to Panama Settings.
**Architecture:** A small pure JavaScript module groups PipeWire playback nodes and owns aggregate calculations. `AudioDevices.qml` exposes the live groups, while focused QML components render and mutate the real tracked nodes.
**Tech Stack:** Quickshell 0.3, Qt 6 QML/JavaScript, `Quickshell.Services.Pipewire`, Bash contract harnesses
**Spec:** `docs/superpowers/specs/2026-08-18-phase2-expectation-gaps-design.md`
## Global Constraints
- PipeWire remains the source of truth; do not persist stream identity or volume.
- Include only ready `PwNodeType.AudioOutStream` nodes with writable audio state.
- Group by `application.id`, binary, name, then node ID, in that order.
- No polling, shell commands, or logging of stream metadata.
- Preserve the existing output, input, balance, and Fedora device-profile controls.
---
### Task 1: Pure application-stream model
**Files:**
- Create: `config/dot/quickshell/services/AudioStreams.js`
- Create: `config/dot/quickshell/audio-streams-harness.qml`
- Create: `tests/quickshell/application-volume-contract.sh`
**Interfaces:**
- Consumes: PipeWire-shaped nodes with `id`, `ready`, `type`, `properties`, and `audio`.
- Produces: `group(nodes, audioOutStreamFlag)`, `volume(group)`, `muted(group)`, `setVolume(group, value)`, and `setMuted(group, muted)`.
- [ ] **Step 1: Write the failing grouping contract**
Create complete fixture nodes for two Chromium streams, one Spotify stream, an input stream, an unready stream, and a metadata-free stream. Through IPC, assert the literal groups and labels:
```json
[
{"key":"org.chromium.Chromium","label":"Chromium","icon":"chromium","count":2},
{"key":"spotify","label":"Spotify","icon":"audio-x-generic-symbolic","count":1},
{"key":"node:99","label":"Unknown application","icon":"audio-x-generic-symbolic","count":1}
]
```
Also assert Chromium volume `0.6` from fixture values `0.4` and `0.8`, mixed mute reports `false`, setting volume writes both nodes, and setting mute normalizes both nodes.
- [ ] **Step 2: Run the contract and verify RED**
Run: `tests/quickshell/application-volume-contract.sh`
Expected: FAIL because `AudioStreams.js` and the IPC target do not exist.
- [ ] **Step 3: Implement the minimal pure model**
Export these exact functions:
```javascript
function property(node, key) {
const value = node && node.properties ? node.properties[key] : "";
return typeof value === "string" ? value.trim() : "";
}
function groupKey(node) {
return property(node, "application.id")
|| property(node, "application.process.binary")
|| property(node, "application.name")
|| `node:${node.id}`;
}
function label(node) {
return property(node, "application.name")
|| String(node.description || "").trim()
|| property(node, "media.name")
|| "Unknown application";
}
function icon(node) {
return property(node, "application.icon_name")
|| "audio-x-generic-symbolic";
}
function group(nodes, audioOutStreamFlag) {
const groups = [];
const byKey = {};
for (const node of nodes || []) {
if (!node || node.ready !== true || !node.audio
|| (node.type & audioOutStreamFlag) !== audioOutStreamFlag)
continue;
const key = groupKey(node);
if (!byKey[key]) {
byKey[key] = { key, label: label(node), icon: icon(node), nodes: [] };
groups.push(byKey[key]);
}
byKey[key].nodes.push(node);
}
return groups;
}
function audioNodes(application) {
return (application && application.nodes || []).filter(node => node && node.audio);
}
function volume(application) {
const nodes = audioNodes(application);
return nodes.length === 0 ? 0
: nodes.reduce((sum, node) => sum + node.audio.volume, 0) / nodes.length;
}
function muted(application) {
const nodes = audioNodes(application);
return nodes.length > 0 && nodes.every(node => node.audio.muted === true);
}
function setVolume(application, value) {
const next = Math.max(0, Math.min(1, Number(value)));
if (!Number.isFinite(next)) return false;
const nodes = audioNodes(application);
for (const node of nodes) {
node.audio.muted = false;
node.audio.volume = next;
}
return nodes.length > 0;
}
function setMuted(application, mutedValue) {
const nodes = audioNodes(application);
for (const node of nodes) node.audio.muted = mutedValue === true;
return nodes.length > 0;
}
```
Use literal property lookups and `Number.isFinite`; do not import PipeWire into the pure module.
- [ ] **Step 4: Run the contract and verify GREEN**
Run: `tests/quickshell/application-volume-contract.sh`
Expected: PASS for grouping, fallback metadata, aggregate volume, mixed mute, and writes.
- [ ] **Step 5: Commit the model**
```bash
git add config/dot/quickshell/services/AudioStreams.js config/dot/quickshell/audio-streams-harness.qml tests/quickshell/application-volume-contract.sh
git commit -m "Model live application audio streams"
```
### Task 2: Live PipeWire service boundary
**Files:**
- Modify: `config/dot/quickshell/services/AudioDevices.qml`
- Modify: `config/dot/quickshell/audio-streams-harness.qml`
- Modify: `tests/quickshell/application-volume-contract.sh`
**Interfaces:**
- Consumes: `AudioStreams.group(nodes, PwNodeType.AudioOutStream)`.
- Produces: `readonly property var applications` and typed wrappers `applicationVolume`, `applicationMuted`, `setApplicationVolume`, `setApplicationMuted`.
- [ ] **Step 1: Extend the contract for the service API**
Assert the harness can report real `AudioDevices.applications` without starting playback and that every returned group contains only nodes with the AudioOutStream flag. Assert mutator wrappers reject `null` and a group with no live audio nodes.
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/application-volume-contract.sh`
Expected: FAIL because `AudioDevices.applications` is undefined.
- [ ] **Step 3: Add the reactive service properties**
Implement:
```qml
readonly property var playbackStreams: Pipewire.nodes.values.filter(node =>
node.ready && node.audio
&& (node.type & PwNodeType.AudioOutStream) === PwNodeType.AudioOutStream)
readonly property var applications: AudioStreams.group(root.playbackStreams, PwNodeType.AudioOutStream)
```
Delegate aggregate reads and writes to the pure model. Preserve `outputs`, `inputs`, `nodes()`, `current()`, and `select()` unchanged.
- [ ] **Step 4: Run and verify GREEN**
Run: `tests/quickshell/application-volume-contract.sh`
Expected: PASS with zero or more real live applications and no mutation of the live streams.
- [ ] **Step 5: Commit the service boundary**
```bash
git add config/dot/quickshell/services/AudioDevices.qml config/dot/quickshell/audio-streams-harness.qml tests/quickshell/application-volume-contract.sh
git commit -m "Expose live application audio groups"
```
### Task 3: Application mixer UI
**Files:**
- Create: `config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml`
- Create: `config/dot/quickshell/modules/settings/ApplicationMixer.qml`
- Modify: `config/dot/quickshell/modules/settings/SoundPage.qml`
- Modify: `tests/quickshell/application-volume-contract.sh`
- Modify: `tests/quickshell/sound-page-contract.sh`
**Interfaces:**
- Consumes: one `AudioDevices.applications` group per row.
- Produces: a real Sound **Applications** card and the empty-state sentence from the spec.
- [ ] **Step 1: Write the failing UI contract**
Construct `SoundPage.qml` in the existing isolated Settings harness. Assert no QML warnings and these rendered states: application rows when groups exist, **Applications playing sound will appear here** when empty, and **PipeWire is unavailable** when the service is not ready. Assert the advanced handoff label is exactly **Device profiles**.
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/application-volume-contract.sh && tests/quickshell/sound-page-contract.sh`
Expected: FAIL because the mixer components and Applications card are absent.
- [ ] **Step 3: Implement the row and card**
`ApplicationVolumeRow.qml` must declare a `PwObjectTracker` over `application.nodes`, use `ThemedIcon`, `IconButton`, `ValueSlider`, and a tabular percentage. Its only writes are `AudioDevices.setApplicationMuted(root.application, value)` and `AudioDevices.setApplicationVolume(root.application, value)`. `ApplicationMixer.qml` owns the Repeater and empty/unavailable copy. Put the card after Input and before Sound feedback.
- [ ] **Step 4: Run and verify GREEN**
Run: `tests/quickshell/application-volume-contract.sh && tests/quickshell/sound-page-contract.sh`
Expected: PASS with no QML warnings.
- [ ] **Step 5: Commit the finished application mixer**
```bash
git add config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml config/dot/quickshell/modules/settings/ApplicationMixer.qml config/dot/quickshell/modules/settings/SoundPage.qml tests/quickshell/application-volume-contract.sh tests/quickshell/sound-page-contract.sh
git commit -m "Add application volume mixer"
```
### Task 4: Slice verification
**Files:**
- Modify only if verification exposes a defect in the files above.
- [ ] **Step 1: Run the focused static and runtime contracts once**
Run:
```bash
tests/quickshell/application-volume-contract.sh
tests/quickshell/sound-page-contract.sh
tests/quickshell/settings-pages-contract.sh
```
Expected: all PASS; runtime enumeration may report zero applications without failing.
- [ ] **Step 2: Inspect Quickshell output**
The isolated harness output must contain no `ReferenceError`, `TypeError`, binding loop, failed property assignment, or `PwObjectTracker` warning.
- [ ] **Step 3: Review the diff**
Run: `git diff --check && git status --short`
Expected: clean formatting and no uncommitted changes after the Task 3 commit.
@@ -0,0 +1,370 @@
# Phase 2 Display Arrangement 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:** Add safe drag-and-snap multi-monitor positioning and a Panama primary-display role without weakening the existing 15-second rollback contract.
**Architecture:** A pure layout module validates, normalizes, scales, and snaps logical monitor rectangles. `Displays.qml` upgrades its pending operation to a complete-layout transaction, and a focused canvas component edits drafts before invoking that transaction.
**Tech Stack:** Quickshell 0.3, Qt 6 QML/JavaScript, Hyprland 0.56.2 Lua evaluation, Lua startup config, Bash/QML contracts
**Spec:** `docs/superpowers/specs/2026-08-18-phase2-expectation-gaps-design.md`
## Global Constraints
- One connected output is primary; it anchors persisted coordinates at logical `0,0`.
- Primary does not promise where third-party Wayland applications open.
- Every layout operation captures, applies, verifies, confirms, reverts, and verifies the complete connected layout.
- Keep is disabled until every output matches mode, scale, transform, x, and y.
- Stored disconnected outputs remain untouched and do not enter a live transaction.
- Development uses static fixtures; run the existing live display contract once at final completion.
---
### Task 1: Pure display-layout geometry
**Files:**
- Create: `config/dot/quickshell/services/DisplayLayout.js`
- Create: `config/dot/quickshell/display-layout-harness.qml`
- Create: `tests/quickshell/display-layout-contract.sh`
**Interfaces:**
- Consumes: records `{name,width,height,scale,transform,x,y,primary}`.
- Produces: `logicalSize`, `validate`, `normalize`, `snap`, `bounds`, and `canvasRects`.
- [ ] **Step 1: Write the failing geometry contract**
Use literal fixtures:
```json
[
{"name":"DP-2","width":4500,"height":3000,"scale":1.5,"transform":0,"x":140,"y":80,"primary":true},
{"name":"HDMI-A-1","width":2560,"height":1440,"scale":1,"transform":1,"x":3140,"y":80,"primary":false}
]
```
Assert transformed logical sizes, normalized DP-2 position `0,0`, normalized HDMI position `3000,0`, exactly one primary, and canvas rectangles preserving the complete desktop aspect ratio. Move HDMI within 16 logical pixels of DP-2's right edge and assert it snaps to x `3000`; move it 17 pixels away and assert no snap.
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/display-layout-contract.sh`
Expected: FAIL because the geometry module and harness do not exist.
- [ ] **Step 3: Implement geometry functions**
Rules:
```text
transform 0 or 2 -> logical width = width/scale, height = height/scale
transform 1 or 3 -> logical width = height/scale, height = width/scale
coordinates -> finite integers between -100000 and 100000
snap threshold -> 16 logical pixels
canvas padding -> caller supplied; return data only, never QML objects
```
`normalize` subtracts the primary x/y from every record and returns new objects. `snap` compares the moving rectangle's four edges with every stationary rectangle's opposite and same-axis edges; choose the smallest eligible delta, then stable output-name order on ties.
- [ ] **Step 4: Add invalid-layout cases**
Assert rejection of duplicate outputs, zero or two primaries, fractional coordinates, non-positive scale, unsupported transform, non-finite values, and rectangles with zero logical size.
- [ ] **Step 5: Run and verify GREEN**
Run: `tests/quickshell/display-layout-contract.sh`
Expected: PASS for geometry, deterministic snapping, normalization, and invalid cases.
- [ ] **Step 6: Commit layout geometry**
```bash
git add config/dot/quickshell/services/DisplayLayout.js config/dot/quickshell/display-layout-harness.qml tests/quickshell/display-layout-contract.sh
git commit -m "Model multi-monitor layout geometry"
```
### Task 2: Parse and persist extended monitor records
**Files:**
- Modify: `config/dot/quickshell/services/Displays.qml`
- Modify: `config/dot/quickshell/displays-harness.qml`
- Modify: `config/dot/hypr/monitors.lua`
- Modify: `config/dot/quickshell/config/PreferenceSchema.qml`
- Modify: `tests/quickshell/displays-contract.sh`
- Modify: `tests/quickshell/settings-preferences-contract.sh`
**Interfaces:**
- Consumes: `hyprctl -j monitors` x/y values and backward-compatible persisted entries.
- Produces: monitor records with `x`, `y`, `primary`; Lua validation for optional `x`, `y`, `primary`.
- [ ] **Step 1: Extend the static contract and Lua fixture**
Assert parsed monitors retain literal x/y. Feed Lua old, valid new, and malformed records. Expected startup calls:
```lua
DP-2 position = "0x0"
HDMI-A-1 position = "3000x0"
old valid entry position = "auto"
malformed x/y/primary entry = ignored in favor of shipped/auto behavior
```
Assert only one valid persisted primary is honored and DP-2's 10-bit/color policy remains unchanged.
- [ ] **Step 2: Run and verify RED**
Run: `PANAMA_DISPLAYS_STATIC_ONLY=1 tests/quickshell/displays-contract.sh`
Expected: FAIL because x/y/primary are neither parsed nor replayed.
- [ ] **Step 3: Extend monitor parsing**
Read integer `monitor.x` and `monitor.y`. Derive the live primary from the connected persisted primary when valid, otherwise the output at `0,0`, otherwise the first connected monitor. Include the boolean only in the service model; Hyprland receives position, not a nonexistent primary flag.
- [ ] **Step 4: Extend Lua validation**
Add `valid_position(entry)` and `valid_primary(entry)`. An entry is extended only when all three new fields are present and valid; an entry with none remains legacy and uses `position = "auto"`; a partially extended entry is invalid. Render position with `string.format("%dx%d", entry.x, entry.y)`.
- [ ] **Step 5: Update schema documentation**
Change the internal `displays` detail to **Resolution, scale, rotation, position, and primary display**. Do not add a second preference.
- [ ] **Step 6: Run and verify GREEN**
Run: `PANAMA_DISPLAYS_STATIC_ONLY=1 tests/quickshell/displays-contract.sh && tests/quickshell/settings-preferences-contract.sh`
Expected: PASS for old and new records.
- [ ] **Step 7: Commit extended persistence**
```bash
git add config/dot/quickshell/services/Displays.qml config/dot/quickshell/displays-harness.qml config/dot/hypr/monitors.lua config/dot/quickshell/config/PreferenceSchema.qml tests/quickshell/displays-contract.sh tests/quickshell/settings-preferences-contract.sh
git commit -m "Persist complete monitor layouts"
```
### Task 3: Whole-layout transaction and rollback
**Files:**
- Modify: `config/dot/quickshell/services/Displays.qml`
- Modify: `config/dot/quickshell/displays-harness.qml`
- Modify: `tests/quickshell/displays-contract.sh`
- Create: `tests/quickshell/display-transaction-contract.sh`
**Interfaces:**
- Consumes: `DisplayLayout.validate/normalize` and complete current monitor records.
- Produces: public `Displays.currentLayout()`, `Displays.applyLayout(layout)`, `Displays.makePrimary(output)`; internal `matchesLayout(monitors, layout)`; and backward-compatible `apply(output, mode, scale, transform)`.
- [ ] **Step 1: Write the failing fixture transaction contract**
Fake `hyprctl` monitor JSON and `eval`. Request a two-output layout and assert the evaluator receives both literal monitor calls in one argv payload. Assert `canConfirm` remains false when one output has wrong y, becomes true only when both match, and confirm persists both records with one primary.
- [ ] **Step 2: Add rollback and generation cases**
Assert timeout, explicit revert, non-zero apply exit, wrong readback, and a disconnect generation each restore all still-connected outputs. Return a stale pre-operation query after a newer operation starts and prove it cannot confirm or clear the newer transaction. Force wrong revert readback and assert the existing manual-restoration error.
- [ ] **Step 3: Run and verify RED**
Run: `tests/quickshell/display-transaction-contract.sh`
Expected: FAIL because `Displays.qml` tracks only one output per operation.
- [ ] **Step 4: Replace pending records with complete layouts**
Use:
```qml
property var pendingPreviousLayout: null
property var pendingRequestedLayout: null
property var revertExpectedLayout: null
readonly property bool awaitingConfirmation: root.pendingRequestedLayout !== null
```
Retain the existing generation counters and timers. `matchesLayout` requires equal connected output-name sets and exact x/y/transform, with the existing tolerances for refresh and scale.
- [ ] **Step 5: Implement one validated evaluation payload**
Build each call only from compositor-reported output names and validated numeric/mode fields:
```text
hl.monitor({ output = "DP-2", mode = "4500x3000@60.00", position = "0x0", scale = 1.5, transform = 0 }); hl.monitor({ output = "HDMI-A-1", mode = "2560x1440@60.00", position = "3000x0", scale = 1, transform = 0 })
```
Reject quotes or non-connector characters in output names before generation. Preserve sequential Process/readback timing around the single eval.
- [ ] **Step 6: Preserve one-field callers**
Keep `apply(output, mode, scale, transform)` by cloning `currentLayout()`, replacing one output's four existing fields, retaining every position and primary flag, then calling `applyLayout`. This keeps `DisplayModePicker`, scale, rotation, and the existing live contract working.
- [ ] **Step 7: Run and verify GREEN**
Run: `tests/quickshell/display-transaction-contract.sh && PANAMA_DISPLAYS_STATIC_ONLY=1 tests/quickshell/displays-contract.sh`
Expected: PASS for apply, confirm, timeout, all revert paths, disconnect, and stale generations.
- [ ] **Step 8: Commit safe transactions**
```bash
git add config/dot/quickshell/services/Displays.qml config/dot/quickshell/displays-harness.qml tests/quickshell/displays-contract.sh tests/quickshell/display-transaction-contract.sh
git commit -m "Apply monitor layouts transactionally"
```
### Task 4: Arrangement canvas
**Files:**
- Create: `config/dot/quickshell/modules/settings/DisplayArrangement.qml`
- Modify: `config/dot/quickshell/modules/settings/DisplaysPage.qml`
- Create: `tests/quickshell/display-arrangement-contract.sh`
- Modify: `tests/quickshell/settings-pages-contract.sh`
**Interfaces:**
- Consumes: `Displays.currentLayout()`, `Displays.applyLayout()`, `Displays.makePrimary()`, and `DisplayLayout.canvasRects/snap`.
- Produces: selected output, pointer/keyboard draft positioning, **Make primary**, and narrow-layout textual selection.
- [ ] **Step 1: Write the failing visual-behavior contract**
Render two literal monitors at wide and 500 px content widths. Through harness IPC, drag HDMI next to DP-2, release, and assert one `applyLayout` call with snapped logical x. Focus HDMI and send Left plus Shift+Left; assert 10 and 100 logical-pixel draft steps. Activate Make primary and assert normalized DP-2 coordinates become negative while HDMI becomes `0,0`.
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/display-arrangement-contract.sh`
Expected: FAIL because no arrangement component exists.
- [ ] **Step 3: Implement responsive canvas data flow**
Keep `draftLayout` as copied plain records. Recalculate canvas rectangles from `DisplayLayout.canvasRects` when width or monitors change, but never mutate live service records. Use `DragHandler` for the selected tile and call `DisplayLayout.snap` before mapping canvas movement back to logical coordinates.
- [ ] **Step 4: Add keyboard and primary actions**
Each tile is focusable and exposes accessible name **Move DISPLAY_NAME**. Arrow keys modify the draft by 10 logical pixels; Shift uses 100. Enter applies the draft. Escape discards it. **Make primary** changes one boolean, normalizes through the pure module, then applies through the same transaction.
- [ ] **Step 5: Integrate Displays page**
Show the card only for two or more monitors. Keep the existing connected-display ChoiceGrid below/inside the selected-display area for narrow tiled widths. Bind arrangement selection and `root.selectedOutput` both ways without loops.
- [ ] **Step 6: Run and verify GREEN**
Run: `tests/quickshell/display-arrangement-contract.sh && PANAMA_DISPLAYS_STATIC_ONLY=1 tests/quickshell/displays-contract.sh && tests/quickshell/settings-pages-contract.sh`
Expected: PASS at both widths with no QML warnings.
- [ ] **Step 7: Commit the canvas**
```bash
git add config/dot/quickshell/modules/settings/DisplayArrangement.qml config/dot/quickshell/modules/settings/DisplaysPage.qml tests/quickshell/display-arrangement-contract.sh tests/quickshell/settings-pages-contract.sh
git commit -m "Add monitor arrangement canvas"
```
### Task 5: Static display identification overlays
**Files:**
- Create: `config/dot/quickshell/modules/settings/DisplayIdentify.qml`
- Modify: `config/dot/quickshell/modules/settings/DisplayArrangement.qml`
- Modify: `config/dot/quickshell/shell.qml`
- Modify: `tests/quickshell/display-arrangement-contract.sh`
**Interfaces:**
- Consumes: `Displays.identifying` and each `Quickshell.screens` entry.
- Produces: `Displays.identify()` and one non-interactive three-second numbered overlay per screen.
- [ ] **Step 1: Write the failing overlay contract**
Invoke identify through the fixture harness. Assert screen 1 renders connector/name and number 1, screen 2 renders number 2, overlays accept no keyboard focus or pointer input, and all become invisible after one 3,000 ms single-shot timer. Assert repeated invocation restarts that timer without creating more windows.
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/display-arrangement-contract.sh`
Expected: FAIL because identify state and overlays do not exist.
- [ ] **Step 3: Add service state and shell-owned windows**
`Displays.identify()` sets one boolean and restarts one Timer. `DisplayIdentify.qml` uses a `Variants` model over `Quickshell.screens`, one transparent non-focusable `PanelWindow` per screen, centered static number card, and no animations. Instantiate it once from `shell.qml`; the Settings button only calls the service.
- [ ] **Step 4: Run and verify GREEN**
Run: `tests/quickshell/display-arrangement-contract.sh`
Expected: PASS with a fixed window count and no focus-grab warnings.
- [ ] **Step 5: Commit identification**
```bash
git add config/dot/quickshell/modules/settings/DisplayIdentify.qml config/dot/quickshell/modules/settings/DisplayArrangement.qml config/dot/quickshell/shell.qml tests/quickshell/display-arrangement-contract.sh
git commit -m "Add display identification overlays"
```
### Task 6: Search, backup, and ownership integration
**Files:**
- Modify: `config/dot/quickshell/services/SettingsSearch.qml`
- Modify: `config/dot/quickshell/services/SettingsBackup.qml`
- Modify: `config/dot/quickshell/modules/settings/README.md`
- Modify: `tests/quickshell/settings-search-contract.sh`
- Modify: `tests/quickshell/settings-backup-live-contract.sh`
- Modify: `tests/quickshell/settings-commit-reset-contract.sh`
- Modify: `tests/quickshell/settings-ownership-contract.sh`
**Interfaces:**
- Consumes: the existing internal `displays` preference and complete-layout transaction.
- Produces: search terms for arrangement/primary and protected whole-layout restore.
- [ ] **Step 1: Write failing integration assertions**
Assert **arrange displays**, **monitor position**, and **primary display** route to Displays. Restore a two-output snapshot while a current layout is protected; assert complete apply/verify occurs before shell reload and any failed restore retains/proves the original layout. Reset must clear confirmed arrangement fields so startup returns to shipped DP-2 plus automatic placement for other outputs.
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/settings-search-contract.sh && tests/quickshell/settings-backup-live-contract.sh && tests/quickshell/settings-commit-reset-contract.sh`
Expected: FAIL for missing terms and one-output restore assumptions.
- [ ] **Step 3: Update search and restore**
Add three manual search entries because `displays` is internal. Replace any single-output assumptions in SettingsBackup with cloned complete layout objects and wait on the existing `Displays.busy || Displays.awaitingConfirmation` boundary.
- [ ] **Step 4: Update ownership documentation**
Document Displays as the sole owner of mode, scale, rotation, arrangement, and primary role. No mirror entry is added.
- [ ] **Step 5: Run and verify GREEN**
Run: `tests/quickshell/settings-search-contract.sh && tests/quickshell/settings-backup-live-contract.sh && tests/quickshell/settings-commit-reset-contract.sh && tests/quickshell/settings-ownership-contract.sh`
Expected: PASS with no new duplicated controls.
- [ ] **Step 6: Commit integration**
```bash
git add config/dot/quickshell/services/SettingsSearch.qml config/dot/quickshell/services/SettingsBackup.qml config/dot/quickshell/modules/settings/README.md tests/quickshell/settings-search-contract.sh tests/quickshell/settings-backup-live-contract.sh tests/quickshell/settings-commit-reset-contract.sh tests/quickshell/settings-ownership-contract.sh
git commit -m "Integrate complete display layouts"
```
### Task 7: Slice verification
- [ ] **Step 1: Run all static display contracts**
```bash
tests/quickshell/display-layout-contract.sh
tests/quickshell/display-transaction-contract.sh
tests/quickshell/display-arrangement-contract.sh
PANAMA_DISPLAYS_STATIC_ONLY=1 tests/quickshell/displays-contract.sh
tests/quickshell/settings-search-contract.sh
tests/quickshell/settings-backup-live-contract.sh
```
Expected: all PASS without touching the physical display.
- [ ] **Step 2: Validate startup configuration once**
Run: `Hyprland --verify-config`
Expected: `config ok`.
- [ ] **Step 3: Defer the live display test**
Do not run the state-changing portion of `tests/quickshell/displays-contract.sh` here. The master Phase 2 completion gate runs it once after every slice is stable and restores the observed mode, scale, transform, and position.
- [ ] **Step 4: Review branch state**
Run: `git diff --check && git status --short`
Expected: clean after the Task 6 commit.
@@ -0,0 +1,266 @@
# Phase 2 Expectation Gaps 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:** Deliver application volume, managed lock appearance, wallpaper modes, and safe multi-monitor arrangement as one coherent Panama Settings phase.
**Architecture:** Phase 2 is four independent subsystems, so each has its own executable plan and focused commits. This master plan fixes their order, cross-slice integration, and the single conservative live verification gate.
**Tech Stack:** Quickshell 0.3, Qt 6 QML/JavaScript, PipeWire 1.6, Hyprland 0.56.2 Lua, hyprlock 0.9.6, hyprpaper 0.8.4, Bash, jq
**Spec:** `docs/superpowers/specs/2026-08-18-phase2-expectation-gaps-design.md`
## Global Constraints
- Implement the approved **A — Continuity** direction; do not introduce an inspector or extra navigation level.
- Use schema/service boundaries already established by Panama Settings.
- Write and observe a focused failing test before every production behavior.
- Keep live desktop mutations out of development loops.
- Do not start playback, acquire the live lock screen, rotate the live wallpaper through fixtures, or emulate a second physical display.
- Run changed-page QML harnesses and the live display contract once at completion, not after every edit.
- Each subsystem must be independently green and committed before the next begins.
## Plan set
1. `docs/superpowers/plans/2026-08-18-phase2-application-volume.md`
2. `docs/superpowers/plans/2026-08-18-phase2-lock-screen.md`
3. `docs/superpowers/plans/2026-08-18-phase2-wallpaper-modes.md`
4. `docs/superpowers/plans/2026-08-18-phase2-display-arrangement.md`
## Spec coverage map
| Approved requirement | Owning plan/tasks |
|---|---|
| PipeWire application grouping, controls, empty/error states | Application Volume Tasks 1–4 |
| Generated lock config, safe fallback, preview, search, restore, health | Lock Screen Tasks 1–5 |
| Single/slideshow/per-monitor policy, verification, timer, UI, restore | Wallpaper Modes Tasks 1–6 |
| Position, primary role, transaction/revert, canvas, identify, restore | Display Arrangement Tasks 1–7 |
| Shared ownership, search vocabulary, deterministic restore ordering | Master Task 5 |
| Conservative runtime/config/visual verification and publication | Master Tasks 6–7 |
---
### Task 1: Execute the application-volume plan
**Files:** Defined in `2026-08-18-phase2-application-volume.md`.
**Interfaces:**
- Produces: `AudioDevices.applications` and the Sound Applications card.
- Independent of: lock, wallpaper, and display state.
- [ ] **Step 1: Read the spec and application-volume plan completely**
Run: `sed -n '1,520p' docs/superpowers/specs/2026-08-18-phase2-expectation-gaps-design.md && sed -n '1,420p' docs/superpowers/plans/2026-08-18-phase2-application-volume.md`
- [ ] **Step 2: Execute every unchecked application-volume task in order**
Use strict red-green-refactor cycles and the exact commit boundaries in that plan.
- [ ] **Step 3: Confirm the slice head**
Run: `tests/quickshell/application-volume-contract.sh && tests/quickshell/sound-page-contract.sh && git status --short`
Expected: both PASS and no uncommitted files.
### Task 2: Execute the lock-screen plan
**Files:** Defined in `2026-08-18-phase2-lock-screen.md`.
**Interfaces:**
- Consumes: existing schema, wallpaper fallback values, and SettingsBackup settle sequencing.
- Produces: `panama-lock`, `LockScreen.qml`, generated config, Appearance card, and health/restore integration.
- [ ] **Step 1: Read the lock-screen plan completely**
Run: `sed -n '1,520p' docs/superpowers/plans/2026-08-18-phase2-lock-screen.md`
- [ ] **Step 2: Execute every unchecked lock-screen task in order**
Never invoke `panama-lock run` outside its fake-hyprlock fixture.
- [ ] **Step 3: Confirm the slice head**
Run: `tests/quickshell/lock-screen-helper-contract.sh && tests/quickshell/lock-screen-service-contract.sh && tests/quickshell/lock-screen-settings-contract.sh && git status --short`
Expected: all PASS, no live `hyprlock` test process, and no uncommitted files.
### Task 3: Execute the wallpaper-modes plan
**Files:** Defined in `2026-08-18-phase2-wallpaper-modes.md`.
**Interfaces:**
- Consumes: schema and connected output names.
- Produces: policy calculation, verified hyprpaper orchestration, runtime slideshow, controls, and restore integration.
- [ ] **Step 1: Read the wallpaper plan completely**
Run: `sed -n '1,620p' docs/superpowers/plans/2026-08-18-phase2-wallpaper-modes.md`
- [ ] **Step 2: Execute every unchecked wallpaper task in order**
All apply tests use fake hyprpaper IPC and temporary image files.
- [ ] **Step 3: Confirm the slice head**
Run: `tests/quickshell/wallpaper-policy-contract.sh && tests/quickshell/wallpaper-service-contract.sh && tests/quickshell/wallpaper-settings-contract.sh && git status --short`
Expected: all PASS and no uncommitted files.
### Task 4: Execute the display-arrangement plan
**Files:** Defined in `2026-08-18-phase2-display-arrangement.md`.
**Interfaces:**
- Consumes: existing Displays apply/revert contract and SettingsBackup display protection.
- Produces: pure geometry, extended persistence, whole-layout transactions, arrangement UI, identify overlays, and search/restore integration.
- [ ] **Step 1: Read the display plan completely**
Run: `sed -n '1,720p' docs/superpowers/plans/2026-08-18-phase2-display-arrangement.md`
- [ ] **Step 2: Execute every unchecked display task except the live display test**
Use fixture monitor JSON for all multi-monitor cases.
- [ ] **Step 3: Confirm the static slice head**
Run: `tests/quickshell/display-layout-contract.sh && tests/quickshell/display-transaction-contract.sh && tests/quickshell/display-arrangement-contract.sh && PANAMA_DISPLAYS_STATIC_ONLY=1 tests/quickshell/displays-contract.sh && git status --short`
Expected: all PASS and no uncommitted files.
### Task 5: Cross-slice Settings integration audit
**Files:**
- Modify: `config/dot/quickshell/services/SettingsBackup.qml`
- Modify: `config/dot/quickshell/services/SettingsSearch.qml`
- Modify: `config/dot/quickshell/modules/settings/AppearancePage.qml`
- Modify: `config/dot/quickshell/modules/settings/README.md`
- Modify: relevant existing contracts only when the combined behavior requires it.
**Interfaces:**
- Consumes: `LockScreen.regenerate()`, `Wallpaper.applyCurrentPolicy(false)`, and complete display-layout restore.
- Verifies: one deterministic restore sequence and one ownership/search vocabulary.
- [ ] **Step 1: Expand the combined restore fixture before the final audit**
The slice plans already extend `tests/quickshell/settings-backup-live-contract.sh` test-first. Confirm its final fixture now contains lock, slideshow, and two-output layout values and asserts this exact order: preferences reload, display apply/verify, idle apply, lock generate, wallpaper apply/verify, keybind/compositor settle, shell reload. It must also assert bounded failure remains in service state and shell reload waits for the settle cap.
- [ ] **Step 2: Run the combined audit**
Run: `tests/quickshell/settings-backup-live-contract.sh`
Expected: PASS. A failure means the independently green hooks conflict and must be debugged before any consolidation.
- [ ] **Step 3: Consolidate only if the audit exposes duplication**
Keep `SettingsBackup.qml` as the established restore coordinator and its injected wrapper pattern for Displays, Wallpaper, LockScreen, Keybinds, and SystemSettings. If two slices added equivalent settle state, collapse them under the existing timer without changing observable order. Do not create another restore service.
- [ ] **Step 4: Reconcile search and ownership once**
Run the ownership contract against final pages. Manual search entries may exist only for internal JSON controls with no schema row. Remove duplicate terms that the schema already indexes.
- [ ] **Step 5: Run and verify GREEN**
Run: `tests/quickshell/settings-backup-live-contract.sh && tests/quickshell/settings-search-contract.sh && tests/quickshell/settings-ownership-contract.sh`
Expected: all PASS.
- [ ] **Step 6: Commit only audit-driven corrections**
If Step 2 or Step 5 required corrections, stage only those files and commit them as `Integrate Phase 2 desktop settings`. If the audit is already green and no consolidation is needed, leave the branch unchanged; an empty ceremony commit is forbidden.
### Task 6: Consolidated completion gate
**Files:**
- Modify only if verification finds a defect.
- [ ] **Step 1: Run new contracts**
```bash
tests/quickshell/application-volume-contract.sh
tests/quickshell/lock-screen-helper-contract.sh
tests/quickshell/lock-screen-service-contract.sh
tests/quickshell/lock-screen-settings-contract.sh
tests/quickshell/wallpaper-policy-contract.sh
tests/quickshell/wallpaper-service-contract.sh
tests/quickshell/wallpaper-settings-contract.sh
tests/quickshell/display-layout-contract.sh
tests/quickshell/display-transaction-contract.sh
tests/quickshell/display-arrangement-contract.sh
```
Expected: all PASS.
- [ ] **Step 2: Run affected existing contracts**
```bash
tests/quickshell/sound-page-contract.sh
tests/quickshell/settings-pages-contract.sh
tests/quickshell/settings-search-contract.sh
tests/quickshell/settings-ownership-contract.sh
tests/quickshell/settings-preferences-contract.sh
tests/quickshell/settings-commit-reset-contract.sh
tests/quickshell/settings-backup-live-contract.sh
tests/quickshell/migrations-contract.sh
tests/quickshell/panama-doctor-contract.sh
tests/quickshell/schema-hypr-shape-contract.sh
```
Expected: all PASS.
- [ ] **Step 3: Run the one state-changing display contract**
Run: `tests/quickshell/displays-contract.sh`
Expected: PASS and cleanup proves the physical monitor returns to its captured original mode, scale, transform, x, and y. Stop immediately if the preflight reports a dirty display baseline.
- [ ] **Step 4: Run config and source checks**
```bash
bash -n config/dot/quickshell/scripts/panama-lock
bash -n config/dot/quickshell/scripts/panama-idle
git diff --check
Hyprland --verify-config
```
Expected: shell syntax exits 0, diff check is empty, and Hyprland reports `config ok`.
- [ ] **Step 5: Construct changed QML once**
Run the isolated harness collection once and inspect its complete log for `ERROR`, `WARN`, `ReferenceError`, `TypeError`, binding loops, invalid anchors, failed property assignments, and focus-grab failures. Expected: none.
- [ ] **Step 6: Review requirement coverage and branch state**
Compare the final diff with every heading in the approved spec. Run `git status --short --branch` and `git log --oneline origin/main..HEAD`. Expected: only intentional commits and a clean tree.
### Task 7: Merge, activate, and publish
**Files:** None unless final activation exposes a defect.
- [ ] **Step 1: Push the feature branch**
Run: `git push -u origin feat/phase2-expectation-gaps`
Expected: remote branch points to the verified head.
- [ ] **Step 2: Fast-forward clean main**
In `/home/gib/.local/share/Panama`, fetch, prove `main` is clean and not behind an unexpected remote commit, then run `git merge --ff-only feat/phase2-expectation-gaps`.
- [ ] **Step 3: Push main**
Run: `git push origin main`
Expected: `origin/main` equals local `main`.
- [ ] **Step 4: Restart Quickshell once**
Use Panama's verified graceful shell restart action. Do not open repeated harness windows. Confirm `qs list --all` reports one live production instance and inspect only the fresh startup log.
- [ ] **Step 5: Perform one visual review**
Open Settings once and inspect Sound Applications, Appearance Lock screen and Wallpaper, and Displays Arrangement. Confirm narrow tiled layout, empty states, focus order, and preview/canvas geometry. Locking, wallpaper rotation, and adding a monitor remain user-driven real-world follow-ups unless a safe no-op state already exercises them.
- [ ] **Step 6: Report exact delivery evidence**
Provide commit range, pushed branch/main state, contract counts, the live display restoration result, Quickshell instance state, and any capability that could not be exercised without external hardware or acquiring the live lock screen.
@@ -0,0 +1,285 @@
# Phase 2 Lock Screen 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:** Make lock-screen appearance configurable from Appearance without editing the tracked hyprlock file or risking an unlocked session.
**Architecture:** Schema-backed values feed an atomic `panama-lock` generator under `$XDG_STATE_HOME`. Hypridle invokes the helper, a small Quickshell service proactively regenerates on relevant changes, and an ordinary QML preview mirrors the selected roles without launching the real locker.
**Tech Stack:** Bash, jq, hyprlock 0.9.6 hyprlang, Quickshell 0.3, Qt 6 QML
**Spec:** `docs/superpowers/specs/2026-08-18-phase2-expectation-gaps-design.md`
## Global Constraints
- Never rewrite `config/dot/hypr/hyprlock.conf` at runtime.
- Authentication, PAM, arbitrary commands, markup, fonts, and paths are not configurable.
- Generation is atomic; failure preserves the last valid generated config.
- `run` falls back to the tracked config if generation fails.
- Tests never acquire the live session lock.
- Appearance owns visual controls; Power and Privacy retain their established timing controls.
---
### Task 1: Schema and deterministic generator
**Files:**
- Modify: `config/dot/quickshell/config/PreferenceSchema.qml`
- Create: `config/dot/quickshell/scripts/panama-lock`
- Create: `tests/quickshell/lock-screen-helper-contract.sh`
**Interfaces:**
- Consumes: `lockBackgroundMode`, `lockBlurLevel`, `lockShowClock`, `lockShowDate`, `lockShowUser`, `lockFadeOnEmpty`, `use24Hour`, `colorScheme`, and wallpaper policy from `settings.json`.
- Produces: `panama-lock generate`, `panama-lock status`, and `panama-lock run`.
- [ ] **Step 1: Write the failing helper contract**
Use temporary `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, `HOME`, fake `hyprctl`, and fake `hyprlock`. Cover literal dark defaults and every background mode. Parse the generated file and assert:
```text
background.path = screenshot
background.blur_passes = 3
background.blur_size = 8
clock command = date +"%-I:%M"
date label present = true
user label present = true
input-field.fade_on_empty = false
```
For light solid mode assert `rgba(245, 246, 250, 1.0)`. For wallpaper mode with two fake outputs assert one background block per output and the per-monitor path fallback. Prove invalid enum, blur, boolean, JSON, and path values return to shipped defaults.
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/lock-screen-helper-contract.sh`
Expected: FAIL because `panama-lock` does not exist.
- [ ] **Step 3: Add the six schema entries**
Use exact types and defaults:
```qml
{
key: "lockBackgroundMode", type: "enum", def: "screenshot", group: "lockAppearance",
label: "Background", detail: "What appears behind the lock screen",
options: [
{ value: "screenshot", label: "Blurred desktop" },
{ value: "wallpaper", label: "Current wallpaper" },
{ value: "solid", label: "Solid color" }
]
},
{
key: "lockBlurLevel", type: "int", def: 3, min: 0, max: 5, step: 1,
group: "lockAppearance", label: "Background blur", detail: "Softens what is behind the password field"
},
{ key: "lockShowClock", type: "bool", def: true, group: "lockAppearance", label: "Show clock", detail: "Use the desktop's 12 or 24-hour format" },
{ key: "lockShowDate", type: "bool", def: true, group: "lockAppearance", label: "Show date", detail: "Show the weekday and full date" },
{ key: "lockShowUser", type: "bool", def: true, group: "lockAppearance", label: "Show user name", detail: "Identify the signed-in account" },
{ key: "lockFadeOnEmpty", type: "bool", def: false, group: "lockAppearance", label: "Hide password field until typing", detail: "Keep the empty field out of the way" }
```
Insert these entries using the repository's existing schema object shape; do not add a new schema feature.
- [ ] **Step 4: Implement `panama-lock`**
Commands and results:
```text
panama-lock generate -> atomically writes state/panama/hyprlock.conf
panama-lock status -> {"generated":true,"path":"...","fallback":false,"error":""}
panama-lock run -> exec hyprlock -c generated; fallback to config/hypr/hyprlock.conf
```
Read values with `jq`, validate again in Bash, map blur levels exactly as:
```text
0 -> passes 0, size 1
1 -> passes 1, size 3
2 -> passes 2, size 5
3 -> passes 3, size 8
4 -> passes 4, size 10
5 -> passes 5, size 12
```
Write `generated.tmp`, validate that it is non-empty and contains `auth`, `background`, and `input-field` blocks, then `mv` it into place. On failure remove only the temporary file.
- [ ] **Step 5: Extend the contract for atomicity and fallback**
Seed a valid generated file, force generation failure through an unwritable fixture target, and prove its checksum is unchanged. Make fake `hyprlock` record argv and prove `run` uses the generated path after success and the tracked fallback after forced failure.
- [ ] **Step 6: Run and verify GREEN**
Run: `tests/quickshell/lock-screen-helper-contract.sh`
Expected: PASS for modes, visibility, clock format, validation, atomicity, and fallback.
- [ ] **Step 7: Commit the generator**
```bash
git add config/dot/quickshell/config/PreferenceSchema.qml config/dot/quickshell/scripts/panama-lock tests/quickshell/lock-screen-helper-contract.sh
git commit -m "Generate managed lock screen configuration"
```
### Task 2: Make every lock path use the generator
**Files:**
- Modify: `config/dot/hypr/hypridle.conf`
- Modify: `config/dot/quickshell/scripts/panama-idle`
- Create: `config/dot/quickshell/services/LockScreen.qml`
- Create: `config/dot/quickshell/lock-screen-harness.qml`
- Create: `tests/quickshell/lock-screen-service-contract.sh`
- Modify: `tests/quickshell/lock-screen-helper-contract.sh`
**Interfaces:**
- Consumes: `panama-lock generate/status` and `DesktopPreferences.revision`.
- Produces: `LockScreen.generated`, `LockScreen.path`, `LockScreen.lastError`, `LockScreen.busy`, `LockScreen.regenerate()`, and `LockScreen.refresh()`.
- [ ] **Step 1: Write the failing service and idle-path contract**
Assert both hypridle sources emit exactly:
```text
lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run
```
Through the QML harness, change one lock preference three times inside 250 ms and prove exactly one helper generation starts. Return malformed status JSON and prove the previous valid path remains while `lastError` becomes **The lock-screen configuration could not be read.**
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/lock-screen-service-contract.sh`
Expected: FAIL because the idle paths still invoke `hyprlock` directly and the service is absent.
- [ ] **Step 3: Update shipped and generated hypridle commands**
Change only `lock_cmd`; preserve blanking, sleep, and lock timing behavior. Keep both shipped/generated idle-command assertions in `tests/quickshell/lock-screen-service-contract.sh`; this repository has no separate idle-lock contract.
- [ ] **Step 4: Implement `LockScreen.qml`**
Use one `Process` for generation and one for status. Coalesce `DesktopPreferences.revision` through a 250 ms single-shot Timer. On generation exit, call status; accept only JSON with boolean `generated` and string `path`. Keep the last valid status on malformed output.
- [ ] **Step 5: Run and verify GREEN**
Run: `tests/quickshell/lock-screen-helper-contract.sh && tests/quickshell/lock-screen-service-contract.sh`
Expected: PASS with one coalesced generation and no live locker process.
- [ ] **Step 6: Commit lock invocation and service state**
```bash
git add config/dot/hypr/hypridle.conf config/dot/quickshell/scripts/panama-idle config/dot/quickshell/services/LockScreen.qml config/dot/quickshell/lock-screen-harness.qml tests/quickshell/lock-screen-helper-contract.sh tests/quickshell/lock-screen-service-contract.sh
git commit -m "Route session locking through Panama"
```
### Task 3: Appearance card and representative preview
**Files:**
- Create: `config/dot/quickshell/modules/settings/LockScreenPreview.qml`
- Modify: `config/dot/quickshell/modules/settings/AppearancePage.qml`
- Modify: `config/dot/quickshell/services/SettingsSearch.qml`
- Create: `tests/quickshell/lock-screen-settings-contract.sh`
- Modify: `tests/quickshell/settings-search-contract.sh`
- Modify: `config/dot/quickshell/modules/settings/README.md`
**Interfaces:**
- Consumes: schema values, `Wallpaper` effective preview path, `Theme`, and `LockScreen.lastError`.
- Produces: Appearance **Lock screen** card and search routes for background, blur, clock, date, user name, and password-field behavior.
- [ ] **Step 1: Write the failing UI and routing contract**
Construct the real Appearance page in an isolated QML harness for screenshot, wallpaper, and solid modes. Assert the preview changes background source and visibility without starting `hyprlock`. Assert the six settings route to `appearance`, and no new duplicated setting row appears on Power or Privacy.
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/lock-screen-settings-contract.sh && tests/quickshell/settings-search-contract.sh && tests/quickshell/settings-ownership-contract.sh`
Expected: FAIL because the preview, card, and group route are absent.
- [ ] **Step 3: Implement the preview**
Use a clipped `Rectangle` with one asynchronous, decode-bounded `Image` only in wallpaper mode. Screenshot mode uses a static themed approximation with the configured blur level; solid uses `Theme.bg`. Render clock/date/user/password-field elements from the same preferences. Do not use ShaderEffect, live screencopy, pulse, shimmer, or a repeating animation.
- [ ] **Step 4: Add the Appearance controls**
Place the card after Wallpaper and before Typography. Use `ChoiceRow` for background, `SliderRow` with `zeroLabel: "Off"` for blur, and `ToggleRow` for the four booleans. Show `LockScreen.lastError` in the card only when non-empty.
- [ ] **Step 5: Add search and ownership documentation**
Map `lockAppearance` to `appearance`; add manual terms **lock screen background** and **password field** only if the schema labels do not already find them. Document that lock visuals belong to Appearance while lock timing belongs to Power and the existing Privacy mirror.
- [ ] **Step 6: Run and verify GREEN**
Run: `tests/quickshell/lock-screen-settings-contract.sh && tests/quickshell/settings-search-contract.sh && tests/quickshell/settings-ownership-contract.sh`
Expected: PASS with zero QML warnings and no additional mirrors.
- [ ] **Step 7: Commit the lock-screen Settings experience**
```bash
git add config/dot/quickshell/modules/settings/LockScreenPreview.qml config/dot/quickshell/modules/settings/AppearancePage.qml config/dot/quickshell/services/SettingsSearch.qml config/dot/quickshell/modules/settings/README.md tests/quickshell/lock-screen-settings-contract.sh tests/quickshell/settings-search-contract.sh
git commit -m "Add lock screen appearance settings"
```
### Task 4: Backup, reset, and diagnostics integration
**Files:**
- Modify: `config/dot/quickshell/services/SettingsBackup.qml`
- Modify: `config/dot/quickshell/scripts/panama-doctor`
- Modify: `tests/quickshell/settings-backup-live-contract.sh`
- Modify: `tests/quickshell/settings-commit-reset-contract.sh`
- Modify: `tests/quickshell/panama-doctor-contract.sh`
- Modify: `tests/quickshell/lock-screen-service-contract.sh`
**Interfaces:**
- Consumes: `LockScreen.regenerate()` and `panama-lock status`.
- Produces: lock regeneration during restore and a redacted `desktop.hyprlock` health check.
- [ ] **Step 1: Write failing restore and health assertions**
Restore a fixture snapshot with non-default lock values and assert regeneration happens after preferences reload and before shell reload. Reset and assert screenshot background, blur level 3, clock/date/user visible, and the empty password field visible, followed by one regeneration. For doctor fixtures, assert `desktop.hyprlock` is `ok` for a valid generated file, `warning` when fallback is in use, and `error` only when neither generated nor tracked config can be used. Assert no wallpaper path appears in copied diagnostics.
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/settings-backup-live-contract.sh && tests/quickshell/settings-commit-reset-contract.sh && tests/quickshell/panama-doctor-contract.sh`
Expected: FAIL because restore and Health do not know the lock generator.
- [ ] **Step 3: Add restore ordering and health probe**
Inject `regenerateLock` into `SettingsBackup.qml`, start it after preference reload and idle regeneration, and include its bounded busy state in the existing settle timer. Add one authored doctor check whose parsed status includes no config contents or paths.
- [ ] **Step 4: Run and verify GREEN**
Run: `tests/quickshell/settings-backup-live-contract.sh && tests/quickshell/settings-commit-reset-contract.sh && tests/quickshell/panama-doctor-contract.sh && tests/quickshell/lock-screen-service-contract.sh`
Expected: PASS with restore ordering and redacted status.
- [ ] **Step 5: Commit integration**
```bash
git add config/dot/quickshell/services/SettingsBackup.qml config/dot/quickshell/scripts/panama-doctor tests/quickshell/settings-backup-live-contract.sh tests/quickshell/settings-commit-reset-contract.sh tests/quickshell/panama-doctor-contract.sh tests/quickshell/lock-screen-service-contract.sh
git commit -m "Integrate managed lock screen recovery"
```
### Task 5: Slice verification
- [ ] **Step 1: Run all lock contracts once**
```bash
tests/quickshell/lock-screen-helper-contract.sh
tests/quickshell/lock-screen-service-contract.sh
tests/quickshell/lock-screen-settings-contract.sh
tests/quickshell/settings-search-contract.sh
tests/quickshell/settings-ownership-contract.sh
tests/quickshell/settings-backup-live-contract.sh
tests/quickshell/settings-commit-reset-contract.sh
tests/quickshell/panama-doctor-contract.sh
```
Expected: all PASS and no process named `hyprlock` is started by the tests.
- [ ] **Step 2: Validate formatting and process safety**
Run: `bash -n config/dot/quickshell/scripts/panama-lock && bash -n config/dot/quickshell/scripts/panama-idle && git diff --check`
Expected: exit 0.
@@ -0,0 +1,327 @@
# Phase 2 Wallpaper Modes 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:** Extend Panama's thumbnail-first wallpaper picker with verified single, slideshow, and per-monitor policies.
**Architecture:** A pure policy module validates collections, calculates output maps, and owns deterministic shuffle bags. `Wallpaper.qml` remains the sole hyprpaper process boundary and persists manual changes only after `listactive` verifies every output.
**Tech Stack:** Quickshell 0.3, Qt 6 QML/JavaScript, hyprpaper 0.8.4 IPC, Bash fixture contracts
**Spec:** `docs/superpowers/specs/2026-08-18-phase2-expectation-gaps-design.md`
## Global Constraints
- Preserve `wallpaperPath` as the single-image and migration fallback.
- Reject non-absolute paths, commas, newlines, and UI selections outside scanned candidates.
- Automatic slideshow changes never rewrite durable policy or spin on failure.
- No filesystem watcher, animated wallpaper, per-workspace mode, or continuous repaint.
- A manual policy mutation persists only after full output-map readback succeeds.
---
### Task 1: Schema and pure wallpaper policy
**Files:**
- Modify: `config/dot/quickshell/config/PreferenceSchema.qml`
- Create: `config/dot/quickshell/services/WallpaperPolicy.js`
- Create: `config/dot/quickshell/wallpaper-policy-harness.qml`
- Create: `tests/quickshell/wallpaper-policy-contract.sh`
**Interfaces:**
- Consumes: mode, global path, collection, per-monitor map, connected outputs, valid candidates, and shuffle bag.
- Produces: `validPath`, `validCollection`, `validAssignments`, `effectiveMap`, `orderedNext`, `shuffledBag`, and `shuffledNext`.
- [ ] **Step 1: Write the failing policy contract**
Use literal paths `/images/a.jpg`, `/images/b.jpg`, and `/images/c.jpg` with connected outputs `DP-2` and `HDMI-A-1`. Assert:
```json
single -> {"DP-2":"/images/a.jpg","HDMI-A-1":"/images/a.jpg"}
per-monitor -> {"DP-2":"/images/b.jpg","HDMI-A-1":"/images/a.jpg"}
slideshow item c -> {"DP-2":"/images/c.jpg","HDMI-A-1":"/images/c.jpg"}
```
Assert missing assignments fall back to the global path, invalid collection entries are skipped, ordered rotation wraps `a -> b -> c -> a`, and a seeded shuffle emits all three literal paths exactly once before any repeat.
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/wallpaper-policy-contract.sh`
Expected: FAIL because the policy module and harness are missing.
- [ ] **Step 3: Add schema entries**
Add exact keys and defaults:
```qml
{
key: "wallpaperMode", type: "enum", def: "single", group: "wallpaper",
label: "Wallpaper mode", detail: "Use one image, rotate a collection, or choose per display",
options: [
{ value: "single", label: "Single" },
{ value: "slideshow", label: "Slideshow" },
{ value: "per-monitor", label: "Per display" }
]
},
{
key: "wallpaperSlideshowPaths", type: "json", def: ([]), group: "wallpaper", internal: true,
label: "Slideshow collection", detail: "Backgrounds selected for rotation"
},
{
key: "wallpaperIntervalMinutes", type: "int", def: 30, min: 5, max: 1440, step: 5,
unit: "min", group: "wallpaper", label: "Change background every", detail: "Time between slideshow images"
},
{
key: "wallpaperShuffle", type: "bool", def: true, group: "wallpaper",
label: "Shuffle", detail: "Show every selected image before repeating"
},
{
key: "wallpaperPerMonitor", type: "json", def: ({}), group: "wallpaper", internal: true,
label: "Per-display backgrounds", detail: "Background assigned to each connected display"
}
```
Use `group: "wallpaper"` for every key. Keep the existing `wallpaperPath` pattern unchanged.
- [ ] **Step 4: Implement the pure policy API**
`validPath` accepts only strings matching `^/[^,\n]+$` that occur in the supplied candidate set. `validCollection` de-duplicates while preserving first appearance. `validAssignments` keeps only connector keys matching `^[A-Za-z0-9_.-]+$` and valid paths. `effectiveMap` returns a plain output-keyed object. Shuffle accepts an injected `random()` function so tests use the literal sequence `0.8, 0.1, 0.6` without mocking global state.
- [ ] **Step 5: Run and verify GREEN**
Run: `tests/quickshell/wallpaper-policy-contract.sh`
Expected: PASS for validation, fallback maps, ordered wrap, and shuffle-without-repeat.
- [ ] **Step 6: Commit policy model**
```bash
git add config/dot/quickshell/config/PreferenceSchema.qml config/dot/quickshell/services/WallpaperPolicy.js config/dot/quickshell/wallpaper-policy-harness.qml tests/quickshell/wallpaper-policy-contract.sh
git commit -m "Model wallpaper display policies"
```
### Task 2: Verified multi-output hyprpaper transaction
**Files:**
- Modify: `config/dot/quickshell/services/Wallpaper.qml`
- Create: `config/dot/quickshell/wallpaper-service-harness.qml`
- Create: `tests/quickshell/wallpaper-service-contract.sh`
- Modify: `tests/quickshell/settings-pages-contract.sh`
**Interfaces:**
- Consumes: `WallpaperPolicy.effectiveMap(...)` and hyprpaper `listactive` output.
- Produces: `activeByOutput`, `applyPolicy(candidatePolicy, persist)`, `applyCurrentPolicy(persist)`, `setSingle(path)`, `setAssignment(output, path)`, `toggleSlideshowPath(path)`, and `setMode(mode)`.
- [ ] **Step 1: Write the failing IPC transaction contract**
Put fake `hyprctl` first on PATH. Make it record each argv and return fixture `listactive` maps. Assert a two-output request calls exactly:
```text
hyprctl hyprpaper wallpaper DP-2,/images/a.jpg
hyprctl hyprpaper wallpaper HDMI-A-1,/images/b.jpg
hyprctl hyprpaper listactive
```
Assert preferences are written only after exact readback, wrong readback leaves the old policy untouched, and a second output failure stops the transaction and reports **Hyprpaper did not apply that background.**
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/wallpaper-service-contract.sh`
Expected: FAIL because `Wallpaper.qml` only tracks one active path and persists after exit status.
- [ ] **Step 3: Parse complete active state**
Replace `active` as the primary state with `property var activeByOutput: ({})`; keep a compatibility `active` property bound to the first current screen. Parse every `OUTPUT: /absolute/path` line. Reject malformed lines instead of accepting partial state as verification.
- [ ] **Step 4: Implement the transaction queue**
The operation record contains:
```qml
{
expected: { "DP-2": "/images/a.jpg", "HDMI-A-1": "/images/b.jpg" },
remaining: ["DP-2", "HDMI-A-1"],
candidatePolicy: { mode: "per-monitor", assignments: { "HDMI-A-1": "/images/b.jpg" } },
persist: true,
automatic: false
}
```
Apply outputs sequentially, then call `listactive`. Compare every expected key and path. Only then write the candidate schema values through `DesktopPreferences.set`. Keep the last verified map visible during work.
- [ ] **Step 5: Preserve the legacy API safely**
Keep `set(path)` as `return root.setSingle(path)` so SettingsBackup and existing IPC remain compatible until their dedicated integration task changes them. Keep title formatting and scan behavior unchanged.
- [ ] **Step 6: Run and verify GREEN**
Run: `tests/quickshell/wallpaper-service-contract.sh && tests/quickshell/settings-pages-contract.sh`
Expected: PASS for exact argv, readback gating, old API compatibility, and failure copy.
- [ ] **Step 7: Commit verified application**
```bash
git add config/dot/quickshell/services/Wallpaper.qml config/dot/quickshell/wallpaper-service-harness.qml tests/quickshell/wallpaper-service-contract.sh tests/quickshell/settings-pages-contract.sh
git commit -m "Verify wallpaper policy application"
```
### Task 3: Event-driven slideshow and hotplug settle
**Files:**
- Modify: `config/dot/quickshell/services/Wallpaper.qml`
- Modify: `config/dot/quickshell/services/WallpaperPolicy.js`
- Modify: `config/dot/quickshell/wallpaper-service-harness.qml`
- Modify: `tests/quickshell/wallpaper-policy-contract.sh`
- Modify: `tests/quickshell/wallpaper-service-contract.sh`
**Interfaces:**
- Consumes: validated slideshow collection, interval, shuffle flag, and `Quickshell.screens`.
- Produces: `advanceSlideshow()`, runtime `slideshowIndex`, `shuffleBag`, and one interval Timer active only for a collection of at least two valid images.
- [ ] **Step 1: Write failing timer and failure tests**
Use a harness-adjustable interval measured in milliseconds while production derives `minutes * 60000`. Assert: empty and one-item collections do not repeat; two items advance once per trigger; automatic application uses `persist: false`; failure keeps the same policy and schedules only the next normal interval; three rapid screen-set changes coalesce into one reapply.
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/wallpaper-policy-contract.sh && tests/quickshell/wallpaper-service-contract.sh`
Expected: FAIL because no slideshow state or screen-set coalescing exists.
- [ ] **Step 3: Implement runtime rotation**
Add one repeating Timer whose `running` expression requires slideshow mode, at least two valid paths, and no active transaction. `advanceSlideshow()` chooses ordered or shuffled next state through `WallpaperPolicy`, updates runtime state only after verified apply, and never sets a preference.
- [ ] **Step 4: Implement connected-screen coalescing**
Bind a sorted screen-name signature and restart a 350 ms single-shot Timer when it changes. Reapply the current policy with `persist: false`. If a transaction is active, set one boolean follow-up flag rather than creating another queue.
- [ ] **Step 5: Run and verify GREEN**
Run: `tests/quickshell/wallpaper-policy-contract.sh && tests/quickshell/wallpaper-service-contract.sh`
Expected: PASS without rapid retry or durable writes during rotation.
- [ ] **Step 6: Commit runtime policy**
```bash
git add config/dot/quickshell/services/Wallpaper.qml config/dot/quickshell/services/WallpaperPolicy.js config/dot/quickshell/wallpaper-service-harness.qml tests/quickshell/wallpaper-policy-contract.sh tests/quickshell/wallpaper-service-contract.sh
git commit -m "Add event-driven wallpaper rotation"
```
### Task 4: Continuity wallpaper controls
**Files:**
- Create: `config/dot/quickshell/modules/settings/WallpaperControls.qml`
- Modify: `config/dot/quickshell/modules/settings/WallpaperPicker.qml`
- Modify: `config/dot/quickshell/modules/settings/AppearancePage.qml`
- Modify: `config/dot/quickshell/services/SettingsSearch.qml`
- Create: `tests/quickshell/wallpaper-settings-contract.sh`
- Modify: `tests/quickshell/settings-search-contract.sh`
**Interfaces:**
- Consumes: Wallpaper modes, connected displays, collection membership, assignments, and transaction state.
- Produces: compact mode controls above the existing grid and mode-aware tile actions/badges.
- [ ] **Step 1: Write the failing UI contract**
Render Appearance in all three modes. Assert Single tile activation calls `setSingle`; Slideshow activation toggles membership and exposes interval/shuffle; Per monitor exposes a display selector and calls `setAssignment` for the selected output. Assert the active image Prism and collection-member check are separate states.
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/wallpaper-settings-contract.sh && tests/quickshell/settings-search-contract.sh`
Expected: FAIL because only single-image tile activation exists.
- [ ] **Step 3: Implement `WallpaperControls.qml`**
Use `ChoiceGrid` for mode, `ChoiceGrid` for output only in per-monitor mode, `SliderRow`-equivalent layout for interval only in slideshow mode, and `SettingsToggle` for shuffle. Controls call Wallpaper transactional methods rather than writing preferences directly.
- [ ] **Step 4: Make the picker mode-aware**
Add `selectedOutput`, `mode`, `selected(path)`, and `activate(path)` properties/functions. Keep thumbnail decode bounds and event-driven fade. In slideshow mode, draw a quiet check in the upper-right for membership; preserve the Prism border exclusively for the image actually active on the selected/current output.
- [ ] **Step 5: Integrate and route search**
Place `WallpaperControls` in the existing wallpaper card above `WallpaperPicker`. Add search terms for slideshow, shuffle interval, and per-monitor assignment without creating a new Settings page.
- [ ] **Step 6: Run and verify GREEN**
Run: `tests/quickshell/wallpaper-settings-contract.sh && tests/quickshell/settings-search-contract.sh && tests/quickshell/settings-ownership-contract.sh`
Expected: PASS with no new mirrors or QML warnings.
- [ ] **Step 7: Commit the wallpaper UI**
```bash
git add config/dot/quickshell/modules/settings/WallpaperControls.qml config/dot/quickshell/modules/settings/WallpaperPicker.qml config/dot/quickshell/modules/settings/AppearancePage.qml config/dot/quickshell/services/SettingsSearch.qml tests/quickshell/wallpaper-settings-contract.sh tests/quickshell/settings-search-contract.sh
git commit -m "Add wallpaper mode controls"
```
### Task 5: Restore and reset integration
**Files:**
- Modify: `config/dot/quickshell/services/SettingsBackup.qml`
- Modify: `tests/quickshell/settings-backup-live-contract.sh`
- Modify: `tests/quickshell/settings-commit-reset-contract.sh`
**Interfaces:**
- Consumes: `Wallpaper.applyCurrentPolicy(false)`.
- Produces: policy-aware restore and shipped single-wallpaper reset.
- [ ] **Step 1: Write failing restore assertions**
Restore slideshow and per-monitor fixture snapshots. Assert the restored policy applies after preferences reload, uses `persist: false`, and shell reload waits for the bounded wallpaper transaction. Reset must yield mode `single`, empty collection/assignments, interval 30, shuffle true, and shipped `wallpaperPath` fallback.
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/settings-backup-live-contract.sh && tests/quickshell/settings-commit-reset-contract.sh`
Expected: FAIL because restore calls `Wallpaper.set(path)` only.
- [ ] **Step 3: Replace path-only restore**
Inject `applyWallpaperPolicy: function() { return Wallpaper.applyCurrentPolicy(false); }`, include `Wallpaper.busy` in the existing bounded settle condition, and remove the path parameter from the restore callback. Do not create a second wallpaper snapshot format; all policy keys are already in Settings JSON.
- [ ] **Step 4: Run and verify GREEN**
Run: `tests/quickshell/settings-backup-live-contract.sh && tests/quickshell/settings-commit-reset-contract.sh && tests/quickshell/wallpaper-service-contract.sh`
Expected: PASS for both policy modes and shipped reset.
- [ ] **Step 5: Commit restore integration**
```bash
git add config/dot/quickshell/services/SettingsBackup.qml tests/quickshell/settings-backup-live-contract.sh tests/quickshell/settings-commit-reset-contract.sh
git commit -m "Restore complete wallpaper policies"
```
### Task 6: Slice verification
- [ ] **Step 1: Run all wallpaper contracts once**
```bash
tests/quickshell/wallpaper-policy-contract.sh
tests/quickshell/wallpaper-service-contract.sh
tests/quickshell/wallpaper-settings-contract.sh
tests/quickshell/settings-backup-live-contract.sh
tests/quickshell/settings-commit-reset-contract.sh
tests/quickshell/settings-search-contract.sh
```
Expected: all PASS. The live desktop wallpaper is never changed by fixture tests.
- [ ] **Step 2: Perform one read-only live comparison**
Run: `hyprctl hyprpaper listactive`
Expected: every connected output reports an absolute path; compare it with `Wallpaper.activeByOutput` through the existing IPC/harness without applying an image.
- [ ] **Step 3: Review formatting and shell state**
Run: `git diff --check && git status --short`
Expected: clean after the Task 5 commit.
@@ -0,0 +1,235 @@
# Panama Health & Recovery Design
## Purpose
Panama Health & Recovery makes the desktop explain itself. It verifies the
local services, dependencies, links, and integrations that Panama relies on,
then presents useful recovery actions without asking the user to read logs or
diagnose a collection of unrelated Linux processes.
The feature is intentionally quiet. A healthy desktop produces no notification,
banner, or permanent bar ornament. Problems appear in Panama Settings and, when
actionable, as one restrained bar indicator. User-initiated repairs receive
immediate Prism OSD or inline feedback.
## Product boundaries
The first release covers Panama-owned or Panama-integrated functionality:
- Hyprland, Quickshell, the notification server, XDG desktop portals, PipeWire,
Vicinae, the clipboard watcher, wallpaper, idle policy, and Panama's runtime
configuration links.
- The Panama command collection, screenshot and OCR dependencies, DDC
brightness support, and the currently selected terminal and launcher.
- Nextcloud, RustDesk, KDE Connect, BlueBubbles, Home Assistant, calendar
aggregation, and the configured autostart entries.
- Orphaned Panama processes and inhibitors, including duplicate Caffeine locks.
- Versions and non-sensitive diagnostic context needed for a useful copied
report.
It does not become a package manager, a generic system monitor, or a replacement
for Fedora's troubleshooting tools. It never installs packages, invokes `sudo`,
deletes user data, rewrites arbitrary configuration, or repairs services Panama
does not own.
An optional integration that has never been configured is neutral **Not set
up**, not a warning. A configured integration that cannot operate is degraded.
This distinction prevents the health UI from pressuring the user to enable
features they do not want.
## Information architecture
The existing **Startup & Services** destination becomes **System Health**. This
avoids two pages reporting the same background services. Its existing Open and
Refresh actions remain available through the richer health rows.
The page has four levels:
1. A compact summary hero: **Healthy**, **Needs attention**, or **Action
required**, the last completed scan time, Refresh, and Copy Report.
2. An issues-first section shown only when one or more checks are degraded.
3. Grouped cards for Desktop Foundation, Input & Media, Integrations, and Panama
Tools. Healthy rows remain visible but visually quiet.
4. A short boundary note linking to GNOME or Fedora tools for system areas Panama
does not own.
Each row contains a stable title, one-sentence observation, status label, and at
most one primary action. Actions use concrete language such as **Restart
Vicinae**, **Repair command link**, **Open Home settings**, or **View setup
instructions**. There is no generic Fix Everything button.
The Settings sidebar's existing health footer becomes real and clickable. It
shows the aggregate state and opens System Health. The top bar gains a small
`HealthIndicator` only while an actionable warning or error exists; clicking it
opens the same page. Background scans never publish Signal Glass events or
desktop notifications.
## Diagnostic engine
`config/dot/quickshell/scripts/panama-doctor` is the single operating-system
boundary. It supports:
- `panama-doctor --json` for a complete versioned snapshot.
- `panama-doctor --summary` for a concise human-readable installer or terminal
result.
- `panama-doctor --repair CHECK_ID --json` for an explicitly allow-listed repair.
The helper emits one schema:
```json
{
"schemaVersion": 1,
"generatedAt": "2026-08-18T12:00:00Z",
"summary": {
"status": "warning",
"healthy": 18,
"warnings": 1,
"errors": 0,
"unconfigured": 2
},
"checks": [
{
"id": "launcher.panama-commands",
"group": "panama-tools",
"title": "Panama Commands",
"status": "warning",
"detail": "16 of 17 commands are loaded",
"action": {
"kind": "repair",
"label": "Repair command link"
}
}
]
}
```
Allowed statuses are `ok`, `warning`, `error`, and `unconfigured`. Check IDs,
group IDs, titles, and repair mappings are authored constants. Probe output may
populate observations but can never become a command or executable argument.
Checks run concurrently where doing so is safe, with short per-probe timeouts.
A failed or timed-out probe yields a check result rather than aborting the whole
snapshot. Output order is deterministic so tests, copied reports, and visual
rows do not jump between scans.
No secrets are read. The report may state whether a Home Assistant URL or token
is configured, but never includes either value. It excludes clipboard contents,
notification bodies, calendar event data, SSIDs, device addresses, environment
values, file contents, and command output that has not been explicitly parsed.
## Quickshell state and refresh model
`services/Health.qml` owns the latest accepted snapshot, aggregate severity,
busy state, last scan time, and the result of the most recent repair. It invokes
`panama-doctor` with argument arrays through `Process`; UI components never
construct shell commands.
Health performs one delayed scan after the shell reaches a stable startup state.
It scans again when the System Health page is opened, when the user presses
Refresh, and after a repair settles. There is no periodic polling loop while the
desktop is idle. Services that already expose event-driven state remain the
authoritative source for their own interactive controls; Health is a diagnostic
snapshot, not a competing live service model.
Every scan receives a monotonically increasing generation. Late output from an
older scan is discarded. A malformed snapshot leaves the last valid result in
place, marks the diagnostic engine unavailable, and offers a bounded Retry.
The shell exposes a typed `health` IPC target with `refresh`, `status`, `open`,
and `repair(id)` operations. Vicinae gains **Panama: Check System Health**, which
opens the page and requests a fresh scan through the existing `panama-action`
dispatcher.
## Repair policy
Repairs are narrow, reversible, and attached to one check. The first release may:
- Restart Panama's user services such as Vicinae, Hyprpaper, or Hypridle.
- Recreate Panama-owned symlinks when their destination is known and tracked.
- Reload Vicinae's Panama command collection.
- Release duplicate user-owned inhibitors whose metadata identifies Panama and
Caffeine.
- Restart Quickshell through the verified `panama-action restart-shell` path.
- Open the exact Panama Settings page required to finish credentials or entity
selection.
Restarting a working service is not presented as a repair. Repairs that interrupt
visible desktop chrome require a confirmation sheet in Settings. Navigation and
setup actions do not. Package installation, privileged service changes, display
mode writes, and destructive cleanup are never automatic; the UI shows concise
instructions instead.
After a repair, Health rescans and judges success from the observed result. A
zero exit status alone never turns a row green. Failure remains inline on the
affected row and also produces the existing Panama action-failure notification
when the action originated outside Settings.
## Visual language and interaction
System Health uses the established Settings cards and Prism tokens. Healthy
states use a small muted green dot and subdued **Healthy** copy. Warnings use
amber; red is reserved for functionality that is configured, required, and
currently broken. `unconfigured` rows use neutral gray.
The summary hero does not use a decorative gauge, percentage score, pulse,
shimmer, or animated gradient. A desktop is not “82% healthy.” The headline and
issue count are more understandable and do not create false precision.
Rows keep their height stable while refreshing. The previous snapshot remains
visible with a quiet **Checking…** label rather than replacing the page with a
spinner. Keyboard focus order reaches Refresh, Copy Report, issue rows, repair
actions, and external handoffs. Status is always expressed in text as well as
color.
Before production components are edited, the page and degraded bar indicator
will be shown in several static mocks using the existing Settings geometry. The
chosen mock must preserve this information architecture and Panama's current
Prism language rather than introduce a new visual system.
## Failure handling
- Missing required executables become actionable check results.
- Missing optional applications remain neutral until configured.
- A doctor crash, timeout, or malformed JSON does not clear the last good
snapshot or crash Quickshell.
- Concurrent refresh requests coalesce into one follow-up scan.
- A repair request for an unknown or non-repairable ID is rejected before any
process starts.
- Copy Report uses only the already-redacted snapshot and reports clipboard
failure inline.
- If the Settings window is closed during a scan or repair, the process may
finish; reopening the page shows the settled result.
## Verification
- Run the real helper against isolated fake command, config, state, and runtime
directories and prove every status transition deterministically.
- Validate the JSON schema, stable check IDs, deterministic ordering, and
uniqueness of each ID.
- Prove unconfigured integrations remain neutral while configured failures are
degraded.
- Prove reports contain no fixture secrets, clipboard text, calendar data,
addresses, or unparsed environment values.
- Exercise every repair through the allow-list, assert its exact command, and
prove unknown IDs cannot execute anything.
- Test scan generations, malformed snapshots, refresh coalescing, repair
rescans, and preservation of the last valid state in a Quickshell harness.
- Verify Settings routing, search entries, the live sidebar footer, and the
degraded-only bar indicator without QML warnings.
- Validate the Vicinae command and typed IPC surface.
- Run a read-only doctor scan on the real workstation and compare key results to
direct service checks. State-changing live repair tests require an actually
degraded disposable target or explicit user approval.
- Restart the live shell, inspect the fresh log, and visually review healthy,
warning, error, unconfigured, refreshing, and repair-result states.
## Delivery slices
1. Diagnostic schema, read-only probes, redaction, and contract tests.
2. `Health.qml`, typed IPC, startup/manual refresh, and fixture harness.
3. System Health Settings page, live sidebar footer, search, and report copy.
4. Degraded-only bar indicator and Vicinae command.
5. Allow-listed repairs, confirmations, post-repair verification, and live audit.
The slices are one feature and land together. Their order keeps the UI backed by
real diagnostics from its first production render.
@@ -0,0 +1,436 @@
# Phase 2: Close the Expectation Gaps
## Purpose
Phase 2 completes four ordinary desktop capabilities that are conspicuous when
they are absent: per-application volume, multi-monitor arrangement, lock-screen
appearance, and wallpaper automation. The goal is not to make Panama broader;
it is to make the parts people expect in their first week feel native, safe,
and complete.
The approved visual direction is **A — Continuity**. New controls use Panama's
existing two-pane Settings window, restrained cards, Prism selection marks, and
Tokyo Night Moon tokens. The monitor canvas and lock-screen preview are the only
new visual forms. There is no inspector sidebar, dashboard-within-a-dashboard,
or new navigation level.
## Product boundaries
Phase 2 includes:
- Live volume and mute controls grouped by playback application.
- Positioning connected displays, selecting Panama's primary display, and
preserving the existing mode, scale, rotation, confirmation, and rollback
behavior.
- Choosing the lock-screen background treatment and visibility of its clock,
date, user label, and idle password field.
- Single-image, slideshow, and per-monitor wallpaper modes, including an
explicit slideshow collection, shuffle, and interval.
It does not include audio routing between applications, equalizers, PipeWire
profiles, disabling displays, display mirroring, full-time HDR, animated
wallpapers, per-workspace wallpapers, a lock-screen plugin system, or arbitrary
hyprlock configuration. Those would expand four expectation-closing features
into four general-purpose configuration tools.
Phase 3 remains responsible for Control Center mirrors and contextual
"configure this" affordances. Phase 4 remains responsible for user accents and
named themes.
## Shared principles
Each area has one source of truth:
- PipeWire owns live stream volume. Panama does not persist ephemeral streams.
- `DesktopPreferences.displays` owns confirmed display layout overrides.
- Panama's schema owns lock appearance and generates the effective hyprlock
file under the user state directory.
- Panama's schema owns wallpaper policy; hyprpaper owns the pixels currently
displayed.
Settings components call typed service methods. They never construct shell
commands. External writes use argument arrays, validate user-authored values,
and verify observable state where the target exposes readback.
No feature adds a continuous animation or short polling loop. PipeWire and the
screen model are event-driven. Wallpaper rotation wakes only at its configured
interval. Lock configuration regenerates only after a relevant preference
change. Display reads happen when the page opens, after an operation, and when
the connected-screen set changes.
## Application volume
### State and grouping
`services/AudioDevices.qml` gains a playback-stream view derived from
`Pipewire.nodes.values`. A playback stream is a ready `PwNode` whose type
contains `PwNodeType.AudioOutStream`, has an audio interface, and is not a
physical device.
Streams are grouped into applications using the first non-empty stable identity
from PipeWire's properties:
1. `application.id`
2. `application.process.binary`
3. `application.name`
4. the node ID as a final per-stream fallback
The display label prefers `application.name`, then `node.description`,
`media.name`, and finally **Unknown application**. The icon prefers
`application.icon_name`; the delegate falls back to a generic audio-application
symbolic icon. Property values are presentation data only and never become
commands or file paths.
One application row may own several simultaneous streams. Its displayed volume
is the arithmetic mean of the tracked stream volumes. Moving the row writes the
same requested level to every stream in the group. The row is muted only when
every stream is muted; pressing mute applies one state to all streams. Moving a
slider always unmutes every stream, matching the existing device controls.
New streams appear and closed streams disappear through PipeWire's node model.
There is no saved per-application volume map: persisting a browser tab or media
session identity would restore stale state to unrelated future streams.
### Settings experience
Sound gains an **Applications** card beneath Output and Input. Each active
playback application has an icon, application name, optional stream-count or
media detail, mute button, slider, and percentage. Rows remain stable while a
stream's properties update.
When nothing is playing, the card remains visible with the quiet direction:
**Applications playing sound will appear here.** PipeWire discovery failure is
distinct and directs the user to System Health.
The existing Fedora handoff becomes **Device profiles**. Panama now owns
application volume, while Fedora's panel remains the advanced route for codec
and hardware profile selection.
### Failure behavior
- A node that disappears during a drag is ignored without affecting surviving
streams in the group.
- A stream without writable audio state remains visible as unavailable rather
than crashing the model.
- Mixed mute state is represented as unmuted; the next explicit mute action
makes the group consistent.
- No stream metadata is written to logs or the settings store.
## Multi-monitor arrangement
### Persisted layout
The existing `displays` JSON remains the single preference and keeps its
backward-compatible per-output shape. Confirmed entries gain three fields:
```json
{
"DP-2": {
"mode": "4500x3000@60.00",
"scale": 1.5,
"transform": 0,
"x": 0,
"y": 0,
"primary": true
}
}
```
Old entries without `x`, `y`, or `primary` remain valid. They use automatic
placement until the first confirmed arrangement. Exactly one connected output
is primary in a confirmed multi-monitor layout. Single-monitor layouts make the
only output primary automatically.
Wayland has no universal primary-display protocol. **Primary** is therefore an
honest Panama role: it anchors the saved coordinate system at logical `0,0` and
is listed first in Panama's display and per-monitor wallpaper selectors.
Panama's current overlays continue following the focused monitor; this phase
does not silently move them. The role does not claim to force arbitrary
third-party Wayland applications to open on a particular output.
Positions are integer logical pixels after scale and rotation. Before apply,
the service normalizes every coordinate relative to the selected primary
display. Negative coordinates are allowed for displays physically left of or
above the primary.
### Apply, verify, and rollback
`services/Displays.qml` moves from a one-output pending record to a whole-layout
transaction. A request contains every connected output's mode, scale,
transform, position, and primary flag.
The service:
1. Validates every output, offered mode, clean scale, transform, integer
coordinate, and the single-primary invariant.
2. Captures the complete current connected layout.
3. Applies the complete requested layout through one generated, allow-listed
Hyprland Lua evaluation.
4. Reads all monitors back and enables **Keep** only when every field matches.
5. Reverts the complete captured layout after 15 seconds unless confirmed.
6. Persists only the verified requested layout when **Keep** is pressed.
7. Reads the reverted layout back and reports if restoration cannot be proven.
A disconnected output invalidates an in-flight transaction and triggers
rollback for every still-connected output. A newly connected output receives
automatic placement; the connected-screen event refreshes the page, but the
new geometry is not persisted until the user confirms a layout. Stored
overrides for disconnected outputs are retained for reconnect, but never
participate in a live transaction while absent.
`hypr/monitors.lua` validates and replays the extended entries at startup. The
shipped DP-2 bit depth and color-management policy remain authoritative and are
not moved into user preferences.
### Settings experience
Displays gains an **Arrangement** card above the selected-display controls when
more than one monitor is connected. The canvas scales the complete logical
desktop into its available area while preserving real aspect ratios. Monitor
tiles show the human display name and connector. The selected tile gets the
Prism outline; the primary tile also receives a restrained **Primary** label.
Dragging moves a tile and snaps nearby edges. The change remains a draft until
pointer release, when the normal 15-second confirmation transaction begins.
Focused tiles also support arrow-key movement, with Shift for larger steps, so
arrangement is not pointer-only. **Make primary** normalizes the draft around
that display and enters the same confirmation flow.
**Identify** briefly draws a static numbered overlay on every connected screen.
The overlay has no repeating animation and dismisses itself after three seconds.
The existing resolution, scale, rotation, DDC brightness, Night Light, and
gaming-policy cards remain. Display selection now follows selection in the
arrangement canvas but still has a compact textual selector for narrow layouts.
## Lock-screen appearance
### Generated configuration
The tracked `hyprlock.conf` remains the documented shipped fallback. A new
`scripts/panama-lock` helper generates the effective configuration at
`$XDG_STATE_HOME/panama/hyprlock.conf` and launches:
```text
hyprlock -c $XDG_STATE_HOME/panama/hyprlock.conf
```
The helper supports `generate`, `run`, and `status`. It reads only validated
schema values, writes through a temporary file followed by an atomic rename,
and never edits the repository symlink under `~/.config/hypr`.
Both the shipped and Panama-managed hypridle configurations use
`pidof hyprlock || panama-lock run` as `lock_cmd`. Existing `loginctl
lock-session` actions remain unchanged; hypridle receives the session lock
request and invokes the configured locker. If generation fails, `run` falls
back to the tracked `hyprlock.conf` rather than leaving the session unlocked.
Relevant preference changes coalesce into one regeneration. They never restart
or mutate a lock screen that is already active; the next lock uses the new
file.
### User-facing settings
Appearance owns a **Lock screen** card because these choices are visual. Power
continues to own blank, lock, and suspend timing, and Privacy continues to mirror
the two established security controls.
The card exposes:
- **Background**: blurred desktop, current wallpaper, or solid theme color.
- **Background blur**: Off through Strong, stored as a small integer level and
mapped by the generator to bounded blur passes and size.
- **Show clock**, **Show date**, and **Show user name**.
- **Hide password field until typing**, mapped to hyprlock's empty-field fade.
The clock follows the existing global 12/24-hour preference; there is no second
clock-format setting. Current-wallpaper mode resolves the effective wallpaper
for each monitor, with the shipped image as fallback. Solid mode uses the
current light/dark scheme's background role. User-authored markup, commands,
fonts, and arbitrary paths are not accepted.
An inline preview uses ordinary QML and the current Theme tokens. It shows the
chosen visibility and background treatment but does not start hyprlock or
capture the desktop. The preview is explicitly representative, not a second
renderer that promises pixel identity with hyprlock.
### Failure behavior
- Missing or malformed preferences use shipped defaults.
- An unavailable selected wallpaper falls back to the shipped image and reports
the fallback in Settings.
- Generation failure leaves the last valid generated file in place.
- `run` falling back to the tracked config is logged as a bounded diagnostic
status and remains visible in System Health.
- Lock authentication and PAM configuration are never made adjustable.
## Wallpaper modes
### Preference model
The existing `wallpaperPath` remains the single-image choice and migration
fallback. The schema adds:
- `wallpaperMode`: `single`, `slideshow`, or `per-monitor`.
- `wallpaperSlideshowPaths`: a validated JSON array of absolute image paths.
- `wallpaperIntervalMinutes`: an integer from 5 to 1,440.
- `wallpaperShuffle`: a boolean.
- `wallpaperPerMonitor`: a validated JSON object from connector name to image
path.
Path validation retains the existing absolute-path, no-comma, no-newline rule
because hyprpaper receives `output,path` as one IPC argument. The service also
requires a selected path to exist in its scanned image candidates before a UI
action stores it. Hand-edited missing paths are tolerated at load and skipped
with a visible warning.
### Runtime policy
`services/Wallpaper.qml` owns one effective path per connected output and
parses `hyprpaper listactive` into an output-to-path map.
- **Single** applies `wallpaperPath` to every output.
- **Per monitor** applies `wallpaperPerMonitor[output]`, falling back to
`wallpaperPath` when an output has no assignment.
- **Slideshow** rotates the selected collection on every output. Sequential
mode advances in collection order. Shuffle mode uses a shuffled in-memory bag
and does not repeat an image until every valid selected image has appeared.
The slideshow timer wakes only at the configured minute interval. It does not
rewrite `wallpaperPath` on every rotation. Its current item and shuffle bag are
runtime state; the policy and collection are the durable state.
Every manual policy change applies all connected outputs, reads `listactive`
back, and persists only after the expected output map matches. An automatic
slideshow transition keeps the previous policy on failure, reports the problem,
and retries at the next interval rather than entering a rapid retry loop.
When the connected-screen set changes, Wallpaper reapplies the current policy
to the new set after a short coalescing delay. There is no generic filesystem
watcher: Rescan remains explicit, and startup performs the existing bounded
scan.
### Settings experience
Appearance keeps the current thumbnail-first wallpaper card. A compact mode
control sits above the grid:
- In **Single**, tapping a tile immediately applies it everywhere.
- In **Slideshow**, tapping toggles membership in the collection. Interval and
shuffle controls appear beneath the mode row. The active image still receives
the Prism outline, while selected collection members receive a quieter
checkmark treatment.
- In **Per monitor**, a connected-display selector appears above the grid and
tapping assigns the tile to that output. Each output's current assignment is
named in the card summary.
Mode-specific controls disappear when irrelevant; the grid itself does not
change size or become a nested settings panel. Empty slideshow collections
explain how to select images and do not start a timer. A collection containing
one valid image behaves as a static background and says so.
## Search, reset, backup, and ownership
Search routes application volume to Sound, arrangement and primary display to
Displays, lock appearance to Appearance, and wallpaper automation to
Appearance. Lock timing continues to route to Power.
Schema-backed lock and wallpaper values participate automatically in reset and
snapshots. The extended `displays` value remains protected by the existing
display-restore transaction during snapshot restore. Restore order is:
1. Restore and validate preference files.
2. Apply the protected confirmed display layout.
3. Regenerate idle and lock configuration.
4. Apply wallpaper policy.
5. Reload the shell only after those operations settle or reach their bounded
failure state.
Reset returns to the shipped static wallpaper, blurred screenshot lock screen,
and shipped/automatic display layout. It never changes physical audio stream
volumes because those are not preferences.
The Settings ownership ledger is updated only for new search groups and links;
none of these controls creates a new cross-page mirror.
## Error presentation
Errors stay on the page and name the failed boundary: **PipeWire is
unavailable**, **The display layout could not be verified**, **The lock-screen
configuration could not be generated**, or **Hyprpaper did not apply that
background**. There is no generic "Something went wrong."
Previous valid state remains visible during refresh and apply. Busy controls
disable only the operation they conflict with. Display confirmation remains
pinned above the page because it is the only time-sensitive state in Phase 2.
## Verification strategy
Tests are written before each production slice and use real service behavior at
the narrowest safe boundary.
### Application audio
- Construct complete PipeWire-shaped stream fixtures and prove grouping,
labeling, average volume, mute normalization, and disappearing-node behavior.
- Render the real application mixer with multiple streams, missing metadata,
an empty stream list, and unavailable PipeWire state.
- Perform one final live read against current streams; it does not start audio
or alter any stream unless a disposable test stream is available.
### Displays
- Exercise layout normalization, edge snapping, validation, persistence, and
startup replay with literal multi-monitor fixtures.
- Prove confirmation remains disabled until every output matches.
- Prove timeout, explicit revert, apply failure, disconnect, and failed-revert
paths restore the complete original layout.
- Keep development tests static. Run the existing live single-monitor display
contract once at feature completion. Do not invent a second physical display
in the live compositor; multi-monitor behavior is tested through controlled
compositor fixtures.
### Lock screen
- Run `panama-lock generate` against isolated settings, state, and wallpaper
fixtures and compare parsed hyprlock values, not source-text fragments.
- Prove every background mode, visibility control, global clock format, invalid
input fallback, atomic replacement, last-good preservation, and run fallback.
- Validate generated configuration with hyprlock's config checker when the
installed version exposes one; otherwise run a parser-only disposable launch
that cannot acquire the live session lock.
- Never activate the live lock screen automatically during tests.
### Wallpaper
- Use a fake hyprpaper IPC boundary and real temporary image files to prove
output maps, verification-before-persist, fallback, collection validation,
sequential order, shuffle-without-repeat, and interval clamping.
- Prove automatic failures do not spin or rewrite policy.
- Perform one final live no-op readback against the already active wallpaper.
Do not cycle the user's desktop through test images.
### Consolidated completion gate
- Run all new contracts and the existing Settings, Sound, Displays, backup,
ownership, migration, schema, and Hyprland configuration contracts once.
- Construct every changed QML surface without warnings in isolated harnesses.
- Run `Hyprland --verify-config` once.
- Restart Quickshell once after merge, inspect the fresh log, and visually
review the four finished experiences. Avoid repeated windows, reloads, and
live display changes during development.
## Delivery order
The phase is implemented as four independently green slices on one feature
branch:
1. Application mixer — event-driven and lowest risk.
2. Generated lock appearance — establishes the safe state-file pattern.
3. Wallpaper policy — reuses that state discipline and extends existing IPC.
4. Display arrangement — highest-risk slice, implemented after the supporting
Settings patterns and contracts are settled.
Each slice receives a focused commit after its contracts pass. The branch is
merged only when the consolidated completion gate passes. The visual mock is a
design reference, not a production dependency and is not committed.
+56 -16
View File
@@ -1,11 +1,24 @@
#!/usr/bin/env bash
source ~/.local/share/Panama/bin/ascii
# Set host name
# Panama's installer. Safe to re-run: every stage is idempotent, and this is
# also the upgrade path.
set -uo pipefail
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
source "$PANAMA_PATH/bin/ascii"
# ── Hostname, which is optional ──────────────────────────────────────────────
#
# Declining this used to `exit`, which aborted the ENTIRE installation. The
# prompt defaults to N, so simply pressing Enter -- the obvious thing to do when
# you do not want to rename your machine -- installed nothing at all and said
# nothing about it.
echo -e "Current hostname is: $(hostname)"
read -p "Do you want to change the hostname? [y/N]: " confirm_change
read -r -p "Do you want to change the hostname? [y/N]: " confirm_change
if [[ "$confirm_change" =~ ^[Yy]$ ]]; then
read -p "Hostname: " HOST_NAME
read -p "Set hostname to '$HOST_NAME'? [y/N]: " confirm_hostname
read -r -p "Hostname: " HOST_NAME
read -r -p "Set hostname to '$HOST_NAME'? [y/N]: " confirm_hostname
if [[ "$confirm_hostname" =~ ^[Yy]$ ]]; then
sudo hostnamectl set-hostname "$HOST_NAME"
echo "Hostname set to: $(hostname)"
@@ -13,18 +26,45 @@ if [[ "$confirm_change" =~ ^[Yy]$ ]]; then
echo "Hostname not changed."
fi
else
echo "Not changing hostname."
exit
echo "Keeping the current hostname."
fi
# Ensure computer doesn't go to sleep.
gsettings set org.gnome.desktop.screensaver lock-enabled false
gsettings set org.gnome.desktop.session idle-delay 0
# ── Keep the machine awake for the duration ──────────────────────────────────
# Package installation takes long enough to hit an idle lock, and being locked
# out mid-transaction is unpleasant. Restored on every exit path, including
# failure and Ctrl-C, so an interrupted install does not leave the screen
# permanently awake.
restore_idle() {
gsettings set org.gnome.desktop.screensaver lock-enabled true 2>/dev/null || true
gsettings set org.gnome.desktop.session idle-delay 300 2>/dev/null || true
}
trap restore_idle EXIT INT TERM
# Run each setup stage in its own process. This keeps strict-shell options and
# helper variables local to the script that owns them.
for script in ~/.local/share/Panama/setup/scripts/*; do "$script"; done
gsettings set org.gnome.desktop.screensaver lock-enabled false 2>/dev/null || true
gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true
# Revert to normal idle settings
gsettings set org.gnome.desktop.screensaver lock-enabled true
gsettings set org.gnome.desktop.session idle-delay 300
# ── Stages ───────────────────────────────────────────────────────────────────
# Each runs in its own process so strict-shell options and helper variables stay
# local to the script that owns them. A failing stage is reported and the rest
# still run: a missing optional package should not stop the dotfiles being
# linked. The summary at the end is what decides whether the install worked,
# because a failure scrolled past twenty minutes ago is a failure nobody saw.
failed=()
for script in "$PANAMA_PATH"/setup/scripts/*; do
[[ -x "$script" ]] || continue
stage="$(basename "$script")"
printf '\n=== %s ===\n' "$stage"
if ! "$script"; then
failed+=("$stage")
printf '!!! %s failed\n' "$stage" >&2
fi
done
printf '\n'
if (( ${#failed[@]} == 0 )); then
echo "Panama installed. Log out and choose the Hyprland session to start it."
else
printf 'Panama installed with %d failed stage(s): %s\n' "${#failed[@]}" "${failed[*]}" >&2
printf 'Re-running ./install is safe and will retry them.\n' >&2
exit 1
fi
+38 -31
View File
@@ -1,38 +1,45 @@
hyprland
hyprland-uwsm
uwsm
quickshell
vicinae
hyprlock
hypridle
hyprpaper
hyprpicker
hyprsunset
hyprpolkitagent
hyprshutdown
hyprpwcenter
hyprsysteminfo
hyprland-guiutils
xdg-desktop-portal-hyprland
grim
slurp
grimblast
satty
wl-clipboard
wf-recorder
gpu-screen-recorder
brightnessctl
playerctl
pamixer
udiskie
wofi
NetworkManager
adw-gtk3-theme
adwaita-icon-theme
adwaita-sans-fonts
qt6-qtwayland
nm-connection-editor
brightnessctl
ddcutil
gpu-screen-recorder
grim
grimblast
gtk-update-icon-cache
hypridle
hyprland
hyprland-guiutils
hyprland-uwsm
hyprlock
hyprpaper
hyprpicker
hyprpolkitagent
hyprpwcenter
hyprshutdown
hyprsunset
hyprsysteminfo
kde-connect
libnotify
nm-connection-editor
orca
pamixer
playerctl
qrencode
qt6-qtwayland
quickshell
satty
slurp
system-config-printer
tesseract
tesseract-langpack-eng
udiskie
uwsm
vicinae
wf-recorder
wireplumber
wl-clipboard
wofi
xdg-desktop-portal-hyprland
zbar
system-config-printer
+11 -2
View File
@@ -1,18 +1,27 @@
awk
bat
btop
cargo
curl
eza
fontconfig
fwupd
fzf
git-all
gh
git-all
gum
jq
kitty
ksshaskpass
libselinux-utils
neovim
openssl
pciutils
python3-dnf
python3-neovim
rustup
tmux
unzip
wireguard-tools
wget
wireguard-tools
zoxide
+32
View File
@@ -89,6 +89,38 @@ elif [ -d "$PANAMA_DOT/tmux/themes" ]; then
log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME"
fi
# hyprlock.conf is generated from a template on every colour scheme change and
# is not committed. Seed it so the FIRST lock of a fresh install is themed --
# without it hyprlock falls back to its own defaults, which is a bare grey
# screen with none of this desktop's identity, and the first time anyone would
# find out is when they walked away from the machine.
HYPRLOCK_TEMPLATE="$PANAMA_DOT/hypr/hyprlock.conf.template"
HYPRLOCK_CONF="$PANAMA_DOT/hypr/hyprlock.conf"
if [ -e "$HYPRLOCK_CONF" ]; then
log "Keeping existing hyprlock config at $HYPRLOCK_CONF"
elif [ -r "$HYPRLOCK_TEMPLATE" ]; then
lock_scheme="dark"
lock_prefs="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
if [ -r "$lock_prefs" ]; then
lock_stored="$(jq -r '.colorScheme // "dark"' "$lock_prefs" 2>/dev/null || echo dark)"
[ "$lock_stored" = "light" ] && lock_scheme="light"
fi
if [ "$lock_scheme" = "light" ]; then
sed -e "s/@FG@/55, 96, 191/g" -e "s/@MUTED@/97, 114, 176/g" \
-e "s/@ACCENT@/46, 125, 233/g" -e "s/@ERROR@/245, 42, 101/g" \
-e "s/@BG@/225, 226, 231/g" -e "s/@FIELD@/208, 213, 227/g" \
-e "s/@MUTED_HEX@/6172b0/g" -e "s/@ERROR_HEX@/f52a65/g" \
"$HYPRLOCK_TEMPLATE" > "$HYPRLOCK_CONF"
else
sed -e "s/@FG@/200, 211, 245/g" -e "s/@MUTED@/130, 139, 184/g" \
-e "s/@ACCENT@/130, 170, 255/g" -e "s/@ERROR@/255, 117, 127/g" \
-e "s/@BG@/34, 36, 54/g" -e "s/@FIELD@/46, 47, 61/g" \
-e "s/@MUTED_HEX@/828bb8/g" -e "s/@ERROR_HEX@/ff757f/g" \
"$HYPRLOCK_TEMPLATE" > "$HYPRLOCK_CONF"
fi
log "Seeded hyprlock $lock_scheme theme → $HYPRLOCK_CONF"
fi
# btop reads themes from its own config directory, but OWNS btop.conf -- it
# rewrites that file on exit -- so only the theme files are exposed, per file,
# and the config itself is left to btop. panama-theme-apps edits the single
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/audio-streams-harness.qml"
state_home="$(mktemp -d /tmp/panama-application-volume-state.XXXXXX)"
shell_log="$state_home/quickshell.log"
harness_pid=""
fail() {
printf 'application volume contract: %s\n' "$1" >&2
[[ -s "$shell_log" ]] && sed -n '1,160p' "$shell_log" >&2
exit 1
}
instances_for_harness() {
qs list --all 2>/dev/null | awk -v expected="$harness" '
/^Instance / { pid = "" }
/^[[:space:]]*Process ID:/ { pid = $3 }
/^[[:space:]]*Config path:/ {
path = $0
sub(/^[[:space:]]*Config path: /, "", path)
if (path == expected && pid ~ /^[0-9]+$/) print pid
}
'
}
cleanup() {
if [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill -0 "$harness_pid" 2>/dev/null; then
kill "$harness_pid" 2>/dev/null || true
for _ in $(seq 1 40); do
kill -0 "$harness_pid" 2>/dev/null || break
sleep 0.05
done
fi
rm -rf "$state_home"
}
trap cleanup EXIT
qs_for_harness() {
if [[ "$harness_pid" =~ ^[0-9]+$ && "${1:-}" == "ipc" ]]; then
XDG_STATE_HOME="$state_home" qs -p "$harness" ipc --pid "$harness_pid" "${@:2}"
else
XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
fi
}
qs_for_harness --daemonize >"$shell_log" 2>&1 || fail 'isolated harness did not launch'
for _ in $(seq 1 50); do
harness_pid="$(instances_for_harness | head -1)"
if [[ "$harness_pid" =~ ^[0-9]+$ ]] \
&& qs_for_harness ipc show 2>/dev/null | rg -q '^target application-volume-test$'; then
break
fi
sleep 0.1
done
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'isolated harness process did not start'
qs_for_harness ipc show 2>/dev/null | rg -q '^target application-volume-test$' \
|| fail 'application-volume-test IPC target did not register'
summary="$(qs_for_harness ipc call application-volume-test summary)"
expected_groups='[
{"key":"org.chromium.Chromium","label":"Chromium","icon":"chromium","count":2},
{"key":"spotify","label":"Spotify","icon":"audio-x-generic-symbolic","count":1},
{"key":"node:99","label":"Unknown application","icon":"audio-x-generic-symbolic","count":1}
]'
jq -e --argjson expected "$expected_groups" \
'.groups == $expected and (.chromiumVolume - 0.6 | fabs) < 0.000001 and .chromiumMuted == false' \
<<<"$summary" >/dev/null || fail "unexpected grouping summary: $summary"
volume_result="$(qs_for_harness ipc call application-volume-test mutateVolume)"
jq -e '.changed == true and .volumes == [0.7, 0.7] and .muted == [false, false]' \
<<<"$volume_result" >/dev/null || fail "volume mutation did not reach every stream: $volume_result"
mute_result="$(qs_for_harness ipc call application-volume-test mutateMute)"
jq -e '.changed == true and .muted == [true, true]' <<<"$mute_result" >/dev/null \
|| fail "mute mutation did not reach every stream: $mute_result"
service_summary="$(qs_for_harness ipc call application-volume-test serviceSummary)"
jq -e '.count >= 0 and .validTypes == true' <<<"$service_summary" >/dev/null \
|| fail "live service exposed invalid playback groups: $service_summary"
invalid_result="$(qs_for_harness ipc call application-volume-test invalidMutations)"
jq -e '.nullVolume == false and .emptyMute == false' <<<"$invalid_result" >/dev/null \
|| fail "service mutators accepted missing audio nodes: $invalid_result"
if rg -n 'ReferenceError|TypeError|Binding loop|Cannot assign|PwObjectTracker' "$shell_log"; then
fail 'isolated harness emitted a QML runtime error'
fi
printf 'application volume contract: PASS\n'
@@ -33,6 +33,9 @@ done
assert_contains 'title: "Default applications"'
assert_contains 'title: "User autostart"'
assert_contains 'title: "Compositor autostart"'
assert_contains 'AutostartAppPicker {'
assert_contains 'DefaultApps.addAutostart('
assert_contains 'label: "Add an application"'
assert_contains 'categories'
assert_contains 'genericName'
assert_contains '.sort('
@@ -123,4 +126,14 @@ fi
[[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \
|| fail 'default and autostart rows are not both whole-row activatable'
picker="$project_root/config/dot/quickshell/modules/settings/AutostartAppPicker.qml"
qmldir="$project_root/config/dot/quickshell/modules/settings/qmldir"
[[ -f "$picker" ]] || fail 'autostart application picker is missing'
rg -Fq 'required property var existing' "$picker" \
|| fail 'autostart picker cannot exclude existing entries'
rg -Fq 'signal picked(string id)' "$picker" \
|| fail 'autostart picker does not emit a validated desktop id'
rg -q '^AutostartAppPicker 1\.0 AutostartAppPicker\.qml$' "$qmldir" \
|| fail 'autostart picker is not registered in the Settings module'
printf 'applications settings contract: PASS\n'
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Every external command Panama's own scripts invoke must be installed by
# Panama's own package lists.
#
# This exists because the lists had drifted badly. jq is used by thirty-one call
# sites across the helpers and the contracts; kitty has a full shipped config
# and a dock pin; tmux and btop have shipped themes that the colour scheme
# switches. None of the four were declared. So a fresh machine that followed
# this repository's own install instructions would not have them.
#
# The failure is quiet by design, which is what makes it worth a test: the
# helpers are written to report "not installed" rather than crash, so a missing
# dependency presents as a feature that silently is not there.
#
# Commands from coreutils and the shell itself are not checked -- nothing
# installs those separately, and listing them would be noise.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
fail() {
printf 'declared dependencies contract: %s\n' "$1" >&2
exit 1
}
# Shell syntax and builtins. These are not commands anyone installs, and the
# first version of this contract reported `then`, `esac` and `done` as missing
# packages, which buried the four real findings in a hundred lines of noise.
SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|function|select|time|coproc|break|continue|return|exit|local|readonly|declare|export|unset|shift|eval|exec|source|trap|set|shopt|alias|unalias|builtin|command|enable|help|let|read|mapfile|printf|echo|test|true|false|wait|jobs|bg|fg|kill|pwd|cd|dirs|pushd|popd|umask|type|hash|getopts|split|sync)$'
# Provided by any Fedora install: coreutils, util-linux, the shell, and the
# systemd/session tooling. Nothing here is a choice Panama makes.
BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|lsof)$'
SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$'
declared="$(cat "$repo_dir"/setup/packages/* 2>/dev/null | sed 's/#.*//' | tr -d ' ' | grep -v '^$' | sort -u)"
[[ -n "$declared" ]] || fail 'no package lists found'
# A package is not always named after its command. Only the genuine mismatches
# are mapped, so an unmapped command is a real omission rather than a lookup
# failure.
package_for() {
case "$1" in
zbarimg) printf 'zbar' ;;
fc-list|fc-match) printf 'fontconfig' ;;
lspci) printf 'pciutils' ;;
getenforce) printf 'libselinux-utils' ;;
nmcli) printf 'NetworkManager' ;;
wpctl) printf 'wireplumber' ;;
nvim) printf 'neovim' ;;
fwupdmgr) printf 'fwupd' ;;
dnf4) printf 'python3-dnf' ;;
notify-send) printf 'libnotify' ;;
wl-copy|wl-paste) printf 'wl-clipboard' ;;
rg) printf 'ripgrep' ;;
python3) printf 'python3' ;;
*) printf '%s' "$1" ;;
esac
}
missing=()
checked=0
while read -r script; do
[[ -n "$script" ]] || continue
head -1 "$script" | grep -qE 'bash|/sh' || continue
# Commands appearing at the start of a statement or after a pipe. Crude, but
# it is looking for undeclared dependencies, not building a call graph.
#
# No minimum length. An earlier version required three characters, which
# quietly excluded the most-used dependency in the repository -- jq, at
# thirty-one call sites -- along with rg, ss and ip. A dependency checker
# with a blind spot for short names is worse than none, because it reports
# PASS.
while read -r cmd; do
[[ -n "$cmd" ]] || continue
[[ "$cmd" =~ $SHELL_WORDS ]] && continue
[[ "$cmd" =~ $BASELINE ]] && continue
[[ "$cmd" =~ $SESSION ]] && continue
pkg="$(package_for "$cmd")"
grep -qx "$pkg" <<<"$declared" && continue
# Only report a command that actually exists on this machine. An
# invented name in a comment or a heredoc is a false positive; a real
# binary that nothing declares is the thing being looked for.
command -v "$cmd" >/dev/null 2>&1 || continue
missing+=("$cmd (from $(basename "$script"), package: $pkg)")
done < <({
# Statement-initial or after a pipe.
grep -oE '(^|[|;&]|\$\()[[:space:]]*[a-z][a-z0-9_-]+' "$script" \
| grep -oE '[a-z][a-z0-9_-]+$'
# Behind a wrapper. ddcutil is always invoked as `timeout 10 ddcutil`,
# so it never appears statement-initial and was missed entirely.
grep -oE '\b(timeout[[:space:]]+[0-9.]+|sudo|nohup|env)[[:space:]]+[a-z][a-z0-9_-]+' "$script" \
| grep -oE '[a-z][a-z0-9_-]+$'
# `command -v X` is how these helpers probe for a tool before using it,
# which makes it the clearest possible statement of a dependency.
grep -oE 'command -v[[:space:]]+[a-z][a-z0-9_-]+' "$script" \
| grep -oE '[a-z][a-z0-9_-]+$'
} | sort -u)
checked=$((checked + 1))
done < <(find "$repo_dir/config/dot/quickshell/scripts" \
"$repo_dir/config/local/share/vicinae/scripts" \
"$repo_dir/setup/scripts" "$repo_dir/bin" \
-type f 2>/dev/null)
if (( ${#missing[@]} > 0 )); then
printf 'declared dependencies contract: commands used but never installed:\n' >&2
printf ' %s\n' "${missing[@]}" | sort -u >&2
fail 'add each to a list in setup/packages/, or the feature silently will not exist on a fresh machine'
fi
printf 'declared dependencies contract: PASS (%d scripts)\n' "$checked"

Some files were not shown because too many files have changed in this diff Show More