Author SHA1 Message Date
Gabriel Brown 607acb0a2d Make About answer what fastfetch answers
About reported four component versions and, since this morning, a short
hardware summary. It now covers what someone actually wants from an
About page or a fastfetch run: operating system, model, hostname,
kernel, uptime, package counts, shell, locale, windowing system,
resolution, processor, graphics, memory, swap, and disk.

Rows are ordered the way fastfetch presents them -- what the system is,
then what is installed on it, then the hardware underneath -- and the
GPU rows are spliced in directly after Processor rather than appended,
because a graphics card listed after "Disk" reads as an afterthought.
Graphics is still joined from GraphicsDevices rather than read a second
time, so the two readouts cannot disagree.

Memory is total, not used. About is not a monitor: a "12.4 GiB used"
figure is stale before it finishes drawing, and the Home page's vitals
readout is where live numbers belong. Disk is the exception because free
space does not move while you look at it.

Package counting is why this whole helper runs on demand -- rpm -qa on a
full workstation is a few thousand lines and takes a moment. Swap and
package counts are omitted entirely when they are zero or the tools are
absent, rather than reported as "0" or "Unknown".

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:09:06 -04:00
Gabriel Brown 9b2fa80ab7 Drop the product branding: this is just Settings
It is the system settings app for this desktop, so it is named the way
one is. The window is "Settings", the titlebar is "Settings", the
wordmark above the search field is gone, the sidebar's last row is
"About", and the status line reads "Desktop is healthy".

Prose followed the same rule. Forty-odd strings explained what "Panama"
does -- "Panama never animates while idle", "Restore Panama defaults",
"Panama looks in ~/Pictures/Wallpapers" -- which is how a product
describes itself, not how a settings panel describes a setting. They now
say what happens. No user-visible "Panama" remains anywhere in the app.

Two consequences worth naming:

The SUPER+I shortcut's description is user-visible, because the
Shortcuts page is generated from it, so that is renamed too. Rebindings
are keyed by shipped chord rather than description, so no existing
override is orphaned by this.

Three contracts matched the window by title and one matched that
shortcut by description; all four are updated. There are no Hyprland
window rules keyed on the title, so nothing about the window's placement
changes.

The desktop entry is now Name=Settings, but the FILE keeps its
panama-settings name, as does the icon: the dock pins applications by
desktop id, and renaming the file would silently unpin it.
dock-pins-contract covers exactly that.

The dated design docs under docs/superpowers keep the old name. They are
a record of what was decided when, and editing them to agree with the
present would make them lie about the past.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:08:55 -04:00
Gabriel Brown 816ea68f9a Read DDC/CI from the AUX bus, not the EDID bus
With I2C access granted, the helper still found nothing: every connected
monitor was probed on the wrong bus.

A connector has two. The `ddc` symlink points at the classic I2C line
that carries EDID on HDMI and DVI. DisplayPort carries DDC/CI over the
AUX channel instead, which appears as a child directory of the
connector. Both exist on a DP connector and both resolve, so the wrong
choice looks entirely reasonable and simply finds no monitor: this
machine's DP-2 has ddc -> i2c-5, where ddcutil reports "No monitor
detected", while its AUX child i2c-9 answers VCP 0x10 immediately.

This is the guess the previous commit said was unverified, and it was
wrong in the way that mattered. Enumerating from sysfs is still right --
it gives the connector name Hyprland uses and skips empty connectors --
but it has to prefer the AUX child and fall back to the symlink.

The fixture now mirrors sysfs properly: /sys/class/drm/<connector> is a
SYMLINK to the real device directory, and `find` does not follow the
path it is given. A fixture built from plain directories passes whether
or not the code resolves the symlink first, which is a test that agrees
with itself rather than with the kernel.

Verified on hardware: the Kuycon P20 reports 100%, accepts 70 and
returns to 100, and the media keys move it through codex's OSD path.
Both bus-selection mistakes are now caught by the contract.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 09:51:55 -04:00
Gabriel Brown 3ff414fb1b Stop two contracts from driving each other's shell
settings-hyprland-write-contract failed in full suite runs and passed on
its own, reporting "a typed batch did not reach the compositor" with
every value at its default.

Both it and settings-commit-reset-contract drive the same harness file,
and Quickshell identifies an instance by its config path -- not by the
environment it was launched with. So when one run's instance has not
fully exited, the other's wait for `ipc show` is satisfied by that
instance's target, and the whole contract then talks to a shell it did
not start.

The two directions fail differently, and the second is the alarming one:

  The write contract lands on the isolated instance, whose compositor
  write seam is deliberately stubbed. Its writes go nowhere, which is
  exactly the symptom above.

  The commit/reset contract lands on the non-isolated instance and
  drives the DAILY DESKTOP's real compositor while believing it is
  isolated.

Both now refuse to start while another instance of that harness is
alive, and say which hazard they are avoiding rather than failing on an
assertion much later.

One trap worth naming, since it bit me writing this: `rg -c` prints
nothing at all when there are no matches, so an unguarded command
substitution yields "" and not "0" -- the first version of the guard
fired on a perfectly clean machine.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:43:59 -04:00
Gabriel Brown 0fd59c59e3 Answer GNOME's Search panel, and pin the settings navigation wiring
GNOME's Search panel configures which applications provide results in
gnome-shell's overview and which folders are indexed. gnome-shell does
not run here, so reimplementing those switches would store preferences
that change nothing. Under Panama searching is the launcher's job and
Vicinae carries its own preferences, so the Applications page says where
search lives and how to rebind the keys that open it, rather than
offering settings that would be inert or duplicated.

The nav contract is the more useful half. Adding a settings page means
editing four files, and missing any one of them fails quietly in a
different way: no sidebar row, a row that silently shows Home, a deep
link that redirects to Home, or -- worst -- a missing qmldir entry,
which makes the page "not a type" and takes the entire settings window
down with it. Nothing at runtime cross-checks the four. Having just
added four pages by hand, this checks them statically, and also fails on
a page file that exists but is unreachable.

Verified it catches a missing qmldir registration and a missing
allow-list entry.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:38:54 -04:00
Gabriel Brown b1f9891849 Add multitasking controls, and a light theme for the launcher
Two things, both closing gaps in work that was already reported done.

GNOME's Multitasking panel, in Hyprland's terms: tiling layout, split
behaviour, floating-window snapping, workspace wrap-around and
back-and-forth, whether applications may take focus, and whether the
pointer changes the active display. Hyprland creates and destroys
workspaces as you use them, so there is no fixed count to expose, and
the page says so rather than leaving a conspicuous absence.

The Desktop page described the first two of these as read-only facts --
"Layout: Tiling", "Workspace movement: Dynamic" -- which was never true.
Both are ordinary Hyprland options that happened to have no controls,
and TextRow's own documentation says a setting the user could reasonably
change does not belong in it. Schema defaults are Panama's shipped
values from looks.lua rather than Hyprland's own, so restoring defaults
returns the desktop to how it ships. 43 mapped options now, from 35.

The launcher had no light theme. vicinae.json already selected a theme
per system appearance, but both entries pointed at Moon, so choosing
light mode left the most frequently opened window on the desktop dark --
a hole in the light/dark work, not a missing feature. Day is authored
from the same palette as the kitty Day theme so the two cannot drift,
and link-dotfiles now installs every authored theme rather than only the
dark one, which is why the gap survived being noticed.

Its placeholder colour is not Tokyo Night Day's own: that measures
2.54:1 against the background, below the 3:1 floor for secondary text.
This is 3.25:1, the same value used for neovim's light comments.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:34:05 -04:00
Gabriel Brown 6026bf308b Add Region & Language, and answer "what am I running on" in About
More GNOME Settings parity.

Region & Language is new. The locale is localectl's, and Panama stores no
copy of it -- there is exactly one system locale, so a preference here
would be a second source of truth that drifts the moment anything else
changes it. Codes are resolved against iso-codes into "Portuguese
(Brazil)" the way GNOME does, with the code kept visible because it is
what actually gets written and someone choosing between two Spanish
variants needs to see it. Changing it is privileged and only applies to
programs started afterwards, so the page says a sign-out is needed
rather than claiming the new language is in use.

The service is called SystemLocale, not Locale: QML has a built-in
Locale value type that silently shadows a singleton of that name, and
every binding then reads properties off the wrong thing. The page
rendered empty with nothing but "cannot read property of undefined" to
explain it.

About now answers what GNOME's About answers -- model, processor,
memory, disk, OS, kernel, windowing system -- where before it listed
only Panama's own component versions. Graphics is joined from
GraphicsDevices rather than read again, because two readouts of the same
hardware are two things that can disagree. Placeholder DMI strings
("To Be Filled By O.E.M.") are filtered out, and unreadable facts are
omitted rather than shown as "Unknown".

The Fedora hand-off card was one row listing five subjects that opened
the network panel regardless. Naming a panel and then not opening it
reads as a broken button rather than a deliberate hand-off. Each subject
now opens the panel that owns it, and openGnomePanel takes an optional
subpage so "Users" reaches System's users page the way GNOME's own
desktop entry does, instead of dropping the user on System's front page.
Printers and online accounts are not repeated here; they stay with the
network hardware on Network & Devices.

One bug this surfaced, caught by the hyprland write contract: the Lua
config key and the hyprctl option name genuinely differ for tap to
click. hl.config wants input.touchpad.tap_to_click; getoption answers to
input:touchpad:tap-to-click. Either spelling used for both fails -- a
hyphen is not a Lua identifier, and the underscored name is not a known
option -- which is what the schema's two separate fields are for.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:30:21 -04:00
Gabriel Brown 14529c011f Add a Privacy & Security page
Continuing towards GNOME Settings parity. GNOME's Privacy panel covers
screen lock, camera and microphone access, file history, trash, and
device security; Panama had no equivalent page at all, despite already
tracking camera and microphone use for the bar indicator.

Device security is a new read-only readout: Secure Boot, TPM, disk
encryption, SELinux mode, and the firewall. None of these is a
preference -- they are set in firmware, at install time, or by system
policy, and a switch offering to change them would either fail or do
something far-reaching from a control that looks like every other
control. What it answers is "is this machine set up the way I think it
is", which otherwise takes five commands and root. Facts that cannot be
determined report Unknown rather than guessing, because a security
readout that quietly says "fine" when it failed to look is worse than
no readout.

File history and trash retention are deliberately NOT offered as
switches. They are GNOME preferences enforced by gsd-housekeeping, which
does not run in a Hyprland session -- verified, it is not running here.
Toggling them would store a preference, change nothing, and give no sign
of it. They are delegated to GNOME Settings by name instead.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:20:04 -04:00
Gabriel Brown 7378c18ab9 Merge remote-tracking branch 'origin/fix/osd-ddc-brightness' 2026-08-18 08:16:49 -04:00
Gabriel Brown 9ce7040b91 Add a Mouse & Touchpad page and make keyboard layout editable
Working towards parity with GNOME Settings, which splits pointing
devices into their own panel. Panama had pointer speed and focus-follows
buried under a page called "Input & Shortcuts", and had nothing at all
for scroll direction, acceleration profile, scroll speed, left-handed
buttons, or any touchpad setting -- all of which could only be changed
by editing hypr/input.lua by hand, which is the thing this app exists to
stop.

Every new mapping was read back off the running compositor rather than
assumed, and two were not what they look like: touchpad drag lock is an
int with three states, not a switch, and scroll factors are floats even
at their default of exactly 1. Getting either wrong makes every write to
that setting look rejected. The shape contract now covers 35 mapped
options, up from 23.

The touchpad card renders only when a touchpad is attached, which is
what InputDevices is for. On a desktop it would be worse than useless:
every switch on it would appear to work, because the preference is
stored and Hyprland accepts an option for a device class it has no
member of, so the settings would silently affect nothing.

Keyboard layout was read-only text, justified by a note saying changes
needed a compositor reload. That is not true in 0.56.2 -- setting
input:kb_variant through hl.config re-keymaps attached keyboards
immediately, verified by watching active_keymap on a real keyboard
change to "English (US, intl., with dead keys)" and back. So layout,
variant, and options are now real controls, joined by a TextEntryRow
that commits on Enter or focus loss rather than per keystroke, since
half a layout name is a valid string meaning something else.

Rejected input is shown as rejected rather than sanitised: these strings
are serialised into an hl.config payload, where stripping an unexpected
character would turn a typo into a different working setting.

Verified each new pointer option applies and reverts against the live
compositor. Schema, search, commit/reset, and system contracts pass.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:16:10 -04:00
52 changed files with 1755 additions and 125 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ same Tokyo Night Moon palette.
| Piece | What it is |
|---|---|
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Panama Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
| `config/dot/vicinae/` | Raycast-style launcher, themed |
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
+1 -1
View File
@@ -36,7 +36,7 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Removable media | udiskie plus udisks notifications | Live |
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
| System settings | Panama Settings for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
| System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
## GNOME extension migration
+8 -8
View File
@@ -27,7 +27,7 @@ Don't "fix" them.
| File | Contents |
|---|---|
| `hyprland.lua` | Entry point. Each `require()` is its own error scope |
| `prefs.lua` | Reads the settings file Panama Settings writes. See below |
| `prefs.lua` | Reads the settings file the Settings app writes. See below |
| `env.lua` | Environment. Note the uwsm caveat below |
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout |
@@ -59,7 +59,7 @@ generated elsewhere:
`quickshell/scripts/panama-idle` regenerates the hypridle config from the
settings store and restarts the daemon. `hypridle.conf` in this directory
remains the shipped default and is what runs when the drop-in is not installed;
Panama Settings shows which of the two states you are in rather than presenting
The Settings app shows which of the two states you are in rather than presenting
controls that quietly do nothing.
Remove the drop-in and go back to the shipped config with:
@@ -77,7 +77,7 @@ relationship is:
`prefs.get("key", <shipped value>)`, so the config still works standalone with
no settings file at all.
- **The JSON is the truth.** Hyprland and Quickshell both read it.
- **Panama Settings is the editor.** It writes the file *and* applies the change
- **The Settings app is the editor.** It writes the file *and* applies the change
live, so nothing needs a reload and the two sides cannot drift apart.
To add an adjustable setting: add an entry to
@@ -95,7 +95,7 @@ accepts the config in each of those states.
Every bind in `keybinds.lua` goes through a local `bind()` wrapper that
substitutes the chord from a stored override, so shortcuts can be moved from
Panama Settings without editing this file.
the Settings app without editing this file.
```lua
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
@@ -118,7 +118,7 @@ shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
one has nothing readable beside its chord, and Panama Settings drops it from the
one has nothing readable beside its chord, and the Settings app drops it from the
Input & Shortcuts page rather than showing a mystery row.
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
this cannot regress silently.
@@ -275,7 +275,7 @@ The mental model is unchanged from Forge:
| `SUPER + V` | Clipboard history |
| `SUPER + .` | Emoji picker |
| `SUPER + S` | Quick settings |
| `SUPER + I` | Panama Settings |
| `SUPER + I` | Settings |
| `SUPER + SHIFT + F` | Start or reveal focus session |
| `SUPER + B` | Notification centre |
| `SUPER + grave` | Workspace overview |
@@ -288,7 +288,7 @@ The mental model is unchanged from Forge:
### Apps
`SUPER + T` terminal · `N` neovim · `W` browser · `F` files · `C` calculator ·
`E` mail · `I` Panama Settings · `CTRL + SHIFT + Esc` system monitor. GNOME
`E` mail · `I` Settings · `CTRL + SHIFT + Esc` system monitor. GNOME
Settings remains searchable in Vicinae for hardware and account panels.
## Calendar and notifications
@@ -326,7 +326,7 @@ one place. Home and Phone continue the same surface rather than opening extra
dashboard windows.
Home shows the first four selected favourites at rest and every selected light
when expanded. Use **Panama Settings → Home & Phone** to choose favourites,
when expanded. Use **Settings → Home & Phone** to choose favourites,
set Panama-only aliases, and arrange their order. Dragging a brightness control
only previews the value; releasing it sends one brightness request. A normal
power toggle leaves Home Assistant responsible for restoring its previous
+1 -1
View File
@@ -80,7 +80,7 @@ bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" })
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" })
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
-- ── Launcher ────────────────────────────────────────────────────────────────
@@ -257,6 +257,24 @@ Singleton {
detail: "XKB layout name, or a comma-separated list to switch between",
hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" }
},
{
key: "keyboardVariant", type: "string", def: "", group: "input",
// Same shape as the layout list and for the same reason: this is
// serialised into an hl.config string.
pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$",
label: "Layout variant",
detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout",
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
},
{
key: "keyboardOptions", type: "string", def: "", 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_]+)*$",
label: "Keyboard options",
detail: "XKB options, such as compose:ralt to make right Alt a compose key",
hypr: { path: ["input", "kb_options"], option: "input:kb_options", readAs: "str" }
},
{
key: "numlockByDefault", type: "bool", def: true, group: "input",
label: "Num Lock on login",
@@ -310,6 +328,167 @@ Singleton {
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
},
// ── Pointer ─────────────────────────────────────────────────────────
//
// Every `readAs` below was read back off the running compositor rather
// than guessed. Two are not what they look like: touchpad drag lock is
// an int with three states, not a switch, and scroll factors are floats
// even at their default of exactly 1.
{
key: "naturalScroll", type: "bool", def: false, group: "pointer",
label: "Natural scrolling",
detail: "Content follows the direction of your fingers, as on a phone",
hypr: { path: ["input", "natural_scroll"], option: "input:natural_scroll", readAs: "bool" }
},
{
key: "accelProfile", type: "enum", def: "flat", group: "pointer",
label: "Acceleration",
detail: "Flat moves the pointer the same distance however fast you move",
options: [
{ value: "flat", label: "Flat" },
{ value: "adaptive", label: "Adaptive" }
],
hypr: { path: ["input", "accel_profile"], option: "input:accel_profile", readAs: "str" }
},
{
key: "scrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
group: "pointer",
label: "Scroll speed",
detail: "Multiplies how far one notch of the wheel scrolls",
hypr: { path: ["input", "scroll_factor"], option: "input:scroll_factor", readAs: "float" }
},
{
key: "leftHanded", type: "bool", def: false, group: "pointer",
label: "Left-handed",
detail: "Swap the primary and secondary buttons",
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
},
// ── Touchpad ────────────────────────────────────────────────────────
//
// Shown only on machines that have one. These are separate from the
// pointer settings above because libinput keeps them separate: a mouse
// and a touchpad on the same machine can scroll in opposite directions,
// and usually should.
{
key: "touchpadTapToClick", type: "bool", def: true, group: "touchpad",
label: "Tap to click",
detail: "A tap counts as a click without pressing down",
// The Lua config key and the hyprctl option name genuinely differ
// here: hl.config wants input.touchpad.tap_to_click, getoption
// answers to input:touchpad:tap-to-click. Using either spelling for
// both fails -- a hyphen is not a Lua identifier, and the
// underscored name is not a known option to getoption. This is what
// the two separate fields are for.
hypr: { path: ["input", "touchpad", "tap_to_click"], option: "input:touchpad:tap-to-click", readAs: "bool" }
},
{
key: "touchpadNaturalScroll", type: "bool", def: true, group: "touchpad",
label: "Natural scrolling",
detail: "Content follows the direction of your fingers",
hypr: { path: ["input", "touchpad", "natural_scroll"], option: "input:touchpad:natural_scroll", readAs: "bool" }
},
{
key: "touchpadDisableWhileTyping", type: "bool", def: true, group: "touchpad",
label: "Disable while typing",
detail: "Ignore the touchpad briefly after a keystroke, so a palm cannot move the pointer",
hypr: { path: ["input", "touchpad", "disable_while_typing"], option: "input:touchpad:disable_while_typing", readAs: "bool" }
},
{
key: "touchpadScrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
group: "touchpad",
label: "Scroll speed",
detail: "Multiplies how far a two-finger scroll travels",
hypr: { path: ["input", "touchpad", "scroll_factor"], option: "input:touchpad:scroll_factor", readAs: "float" }
},
{
key: "touchpadDragLock", type: "enum", def: 0, group: "touchpad",
label: "Drag lock",
detail: "Keeps a tap-and-drag active when you lift a finger mid-drag",
// An int with three states rather than a switch, which is why this
// is an enum: reported as `int` by getoption, not `bool`.
options: [
{ value: 0, label: "Off" },
{ value: 1, label: "On" },
{ value: 2, label: "On, until you tap again" }
],
hypr: { path: ["input", "touchpad", "drag_lock"], option: "input:touchpad:drag_lock", readAs: "int" }
},
{
key: "touchpadMiddleButtonEmulation", type: "bool", def: false, group: "touchpad",
label: "Middle-click by pressing both buttons",
detail: "Pressing left and right together acts as a middle click",
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
},
// ── Multitasking ────────────────────────────────────────────────────
//
// GNOME's Multitasking panel, in Hyprland's terms. The Desktop page
// described the first two of these as read-only facts ("Layout:
// Tiling"), which was never true -- they are ordinary settings that
// happened not to have controls.
//
// Defaults here are Panama's shipped values from hypr/looks.lua, not
// Hyprland's own, so restoring defaults returns the desktop to how it
// ships rather than to how Hyprland would behave with no config.
{
key: "windowLayout", type: "enum", def: "dwindle", group: "multitasking",
label: "Tiling layout",
detail: "Dwindle splits the focused window; master keeps one large window beside a stack",
options: [
{ value: "dwindle", label: "Dwindle" },
{ value: "master", label: "Master and stack" }
],
hypr: { path: ["general", "layout"], option: "general:layout", readAs: "str" }
},
{
key: "preserveSplit", type: "bool", def: true, group: "multitasking",
label: "Keep split direction",
detail: "New windows follow the split of the window they replace, instead of always halving the longer side",
hypr: { path: ["dwindle", "preserve_split"], option: "dwindle:preserve_split", readAs: "bool" }
},
{
key: "forceSplit", type: "enum", def: 0, group: "multitasking",
label: "New windows open",
detail: "Where a new window lands relative to the one that was focused",
options: [
{ value: 0, label: "Where the pointer is" },
{ value: 1, label: "Always left or above" },
{ value: 2, label: "Always right or below" }
],
hypr: { path: ["dwindle", "force_split"], option: "dwindle:force_split", readAs: "int" }
},
{
key: "windowSnapping", type: "bool", def: true, group: "multitasking",
label: "Snap floating windows",
detail: "Floating windows stick to screen edges and to each other as you drag them",
hypr: { path: ["general", "snap", "enabled"], option: "general:snap:enabled", readAs: "bool" }
},
{
key: "workspaceBackAndForth", type: "bool", def: false, group: "multitasking",
label: "Switch back and forth",
detail: "Selecting the workspace you are already on returns you to the previous one",
hypr: { path: ["binds", "workspace_back_and_forth"], option: "binds:workspace_back_and_forth", readAs: "bool" }
},
{
key: "allowWorkspaceCycles", type: "bool", def: false, group: "multitasking",
label: "Wrap around at the ends",
detail: "Moving past the last workspace continues from the first",
hypr: { path: ["binds", "allow_workspace_cycles"], option: "binds:allow_workspace_cycles", readAs: "bool" }
},
{
key: "focusOnActivate", type: "bool", def: false, group: "multitasking",
label: "Let applications take focus",
detail: "An application asking for attention is switched to, rather than only highlighted",
hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
},
{
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
label: "Pointer changes active display",
detail: "Moving the pointer to another display makes it the active one",
hypr: { path: ["misc", "mouse_move_focuses_monitor"], option: "misc:mouse_move_focuses_monitor", readAs: "bool" }
},
// ── Night light ─────────────────────────────────────────────────────
{
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
@@ -4,11 +4,20 @@ import qs.config
import qs.services
SettingsPage {
title: "About Panama"
id: root
title: "About"
lede: "A curated Hyprland desktop built around focus, speed, and good taste."
Component.onCompleted: {
if (!MachineInfo.scanned)
MachineInfo.refresh();
if (GraphicsDevices.devices.length === 0)
GraphicsDevices.refresh();
}
SettingsCard {
title: "Panama Desktop"
title: "Desktop"
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
TextRow {
@@ -31,6 +40,59 @@ SettingsPage {
}
}
// What GNOME's About panel answers and this page did not: what am I running
// on. Rows come from MachineInfo, except graphics, which is joined from
// GraphicsDevices rather than read a second time -- two readouts of the same
// hardware are two things that can disagree.
//
// The join splices the GPU rows in directly after Processor rather than
// appending them, so the card reads the way fastfetch does: what the system
// is, then what is installed on it, then the hardware underneath. A GPU
// listed after "Disk" reads as an afterthought.
readonly property var machineRows: {
const rows = (MachineInfo.facts ?? []).slice();
const gpus = GraphicsDevices.devices ?? [];
if (gpus.length === 0)
return rows;
const graphics = gpus.map((device, index) => ({
label: gpus.length > 1 ? "Graphics " + (index + 1) : "Graphics",
value: device.name
}));
const after = rows.findIndex(row => row.label === "Processor");
if (after < 0)
return rows.concat(graphics);
return rows.slice(0, after + 1).concat(graphics, rows.slice(after + 1));
}
SettingsCard {
title: "This machine"
subtitle: "Hardware and system, as the kernel reports it."
Repeater {
model: root.machineRows
TextRow {
id: machineRow
required property var modelData
required property int index
label: machineRow.modelData.label
value: machineRow.modelData.value
divider: machineRow.index < root.machineRows.length - 1
}
}
TextRow {
visible: MachineInfo.scanned && root.machineRows.length === 0
label: "Hardware"
detail: "The system did not report anything readable"
value: "Unavailable"
divider: false
}
}
SettingsCard {
title: "Design principles"
@@ -25,14 +25,14 @@ SettingsPage {
SettingsCard {
title: "Text"
subtitle: "Scales text in applications. Panama's own panels are drawn at their design size, so the shell is unaffected."
subtitle: "Scales text in applications. The shell's own panels are drawn at their design size, so they are unaffected."
SliderRow { setting: "textScale"; divider: false }
}
SettingsCard {
title: "Motion"
subtitle: "Panama never animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing."
subtitle: "Nothing animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing."
ToggleRow { setting: "animationsEnabled"; divider: false }
}
@@ -46,7 +46,7 @@ SettingsPage {
title: "Background"
subtitle: Wallpaper.lastError !== ""
? Wallpaper.lastError
: "Applied to every display. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
: "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
WallpaperPicker {
id: wallpapers
@@ -184,7 +184,7 @@ SettingsPage {
SettingsCard {
title: "Theme"
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
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 }
@@ -142,6 +142,40 @@ SettingsPage {
}
}
// GNOME's Search panel, answered honestly.
//
// It configures which applications provide results in gnome-shell's
// overview and which folders are indexed. gnome-shell does not run here,
// so those settings would do nothing. Under Panama searching is the
// launcher's job, and Vicinae carries its own preferences -- reimplementing
// them here would give two places to change one thing.
SettingsCard {
title: "Search"
subtitle: "Applications, files, the calculator, clipboard history, emoji, and open windows are all searched from the launcher."
TextRow {
label: "Launcher"
detail: SystemSettings.vicinaeActive
? "Running as a user service"
: "Not running — Super+Shift+R opens the fallback launcher"
value: "Vicinae"
}
TextRow {
label: "Open search"
detail: "Three keys open it, because Super+A and Super+R were GNOME's app grid and run dialog"
value: "Super+Space"
}
ActionRow {
label: "Change these shortcuts"
detail: "Every launcher chord is rebindable, including clipboard history and emoji"
action: "Open keyboard"
divider: false
onTriggered: ShellState.openSettings("shortcuts")
}
}
SettingsCard {
title: "User autostart"
subtitle: "These desktop entries live in your user configuration. Select a row to toggle it."
@@ -175,7 +209,7 @@ SettingsPage {
SettingsCard {
title: "Compositor autostart"
subtitle: "Panama starts these from Hyprland configuration. They are read-only here."
subtitle: "These are started from the Hyprland configuration. They are read-only here."
TextRow {
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
@@ -47,18 +47,16 @@ SettingsPage {
SettingsCard {
title: "Window layout"
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
subtitle: "Follows the Forge mental model, with native Hyprland tiling."
TextRow {
label: "Layout"
detail: "Dwindle with preserved split direction"
value: "Tiling"
}
TextRow {
label: "Workspace movement"
detail: "Alt+H / Alt+L, add Shift to move a window"
value: "Dynamic"
}
// These were two read-only rows reporting "Tiling" and "Dynamic", which
// described settings rather than facts -- both are ordinary Hyprland
// options that simply had no controls. TextRow's own documentation says
// a setting the user could reasonably change does not belong in it.
ChoiceRow { setting: "windowLayout" }
ToggleRow { setting: "preserveSplit" }
ChoiceRow { setting: "forceSplit" }
ToggleRow { setting: "windowSnapping" }
ActionRow {
label: "Gaps, corners, and effects"
detail: "Adjusted on the Appearance page, beside a live preview"
@@ -68,6 +66,18 @@ SettingsPage {
}
}
// GNOME's Multitasking panel, in Hyprland's terms.
SettingsCard {
title: "Workspaces & focus"
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
ToggleRow { setting: "workspaceBackAndForth" }
ToggleRow { setting: "allowWorkspaceCycles" }
ToggleRow { setting: "focusOnActivate" }
ToggleRow { setting: "mouseMoveFocusesMonitor" }
ChoiceRow { setting: "followMouse"; divider: false }
}
SettingsCard {
title: "Focus"
@@ -111,10 +121,10 @@ SettingsPage {
SettingsCard {
title: "Reset"
subtitle: "Restores Panama's appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
subtitle: "Restores the appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
ActionRow {
label: "Restore Panama defaults"
label: "Restore defaults"
detail: "Applies immediately, including to the compositor"
action: "Restore defaults"
divider: false
@@ -89,9 +89,12 @@ Rectangle {
}
}
// The real bar carries a clock here, not a name. Standing in with the
// actual time keeps the preview a picture of this desktop rather than
// a picture of a product.
Text {
anchors.centerIn: parent
text: "Panama"
text: Qt.formatTime(new Date(), "h:mm")
color: Theme.alpha(Theme.fg, 0.45)
font.family: Theme.fontFamily
font.pixelSize: 9
@@ -149,7 +149,7 @@ SettingsPage {
ActionRow {
visible: Displays.isOverridden(root.monitor ? root.monitor.name : "")
label: "Using a custom display setting"
detail: "Forget it to go back to the resolution and scale Panama ships"
detail: "Forget it to go back to the shipped resolution and scale"
action: "Forget"
divider: false
onTriggered: Displays.forget(root.monitor.name)
@@ -248,7 +248,7 @@ SettingsPage {
SettingsCard {
title: "Gaming display policy"
subtitle: "Applied immediately and restored when Panama starts."
subtitle: "Applied immediately and restored when the session starts."
ToggleRow { setting: "autoHdr" }
ChoiceRow { setting: "vrrPolicy" }
@@ -9,7 +9,7 @@ SettingsPage {
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
title: `${root.greeting}, Gabriel`
lede: "Your Panama desktop is configured and ready."
lede: "Your desktop is configured and ready."
SettingsCard {
title: SystemSettings.monitorDescription || "Active display"
@@ -202,7 +202,7 @@ SettingsPage {
SettingRow {
label: "Private configuration"
detail: "Saved with owner-only permissions in Panama's private environment file"
detail: "Saved with owner-only permissions in a private environment file"
controlWidth: 216
Row {
@@ -239,7 +239,7 @@ SettingsPage {
SettingRow {
label: "Light catalog"
detail: "Panama reads light state through the Home Assistant helper."
detail: "Light state is read through the Home Assistant helper."
divider: false
controlWidth: 176
@@ -0,0 +1,57 @@
// Mouse & Touchpad.
//
// GNOME splits pointing devices out of Keyboard, and so does this: the settings
// are unrelated, and burying pointer speed under a page called "Shortcuts" is
// where it was before. These all reach Hyprland's input section, which until now
// could only be changed by editing hypr/input.lua by hand.
//
// The touchpad card renders only when a touchpad is attached. On a desktop it
// would be worse than useless -- every switch on it appears to work, because the
// preference is stored and the compositor accepts an option for a device class
// with no members, so the user would be toggling settings that can never affect
// anything with nothing to say so.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Mouse & Touchpad"
lede: InputDevices.hasTouchpad
? "Pointer behaviour for your mouse and touchpad."
: "Pointer behaviour. Touchpad settings appear when a touchpad is attached."
SettingsCard {
title: "Mouse"
subtitle: "Applied to every pointing device that is not a touchpad."
SliderRow { setting: "pointerSensitivity" }
ChoiceRow { setting: "accelProfile" }
ToggleRow { setting: "naturalScroll" }
SliderRow { setting: "scrollFactor" }
ToggleRow { setting: "leftHanded"; divider: false }
}
SettingsCard {
visible: InputDevices.hasTouchpad
title: "Touchpad"
subtitle: "Separate from the mouse on purpose: libinput keeps them apart, and a touchpad and a mouse usually want to scroll in opposite directions."
ToggleRow { setting: "touchpadTapToClick" }
ToggleRow { setting: "touchpadNaturalScroll" }
ToggleRow { setting: "touchpadDisableWhileTyping" }
SliderRow { setting: "touchpadScrollFactor" }
ChoiceRow { setting: "touchpadDragLock" }
ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false }
}
SettingsCard {
title: "Pointer"
ChoiceRow { setting: "followMouse" }
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never" }
SliderRow { setting: "cursorSize"; divider: false }
}
}
@@ -24,7 +24,7 @@ SettingsPage {
TextRow {
label: "Notification history"
detail: "Live notifications retained by Panama"
detail: "Live notifications retained by the shell"
value: `${Notifs.history.length} items`
}
@@ -20,8 +20,8 @@ SettingsPage {
SettingsCard {
title: "Idle behaviour"
subtitle: IdleLock.managed
? "Panama is managing hypridle. Changes take effect immediately."
: "hypridle is running Panama's shipped configuration. Turn on management below to make these adjustable."
? "Idle timings are managed here. Changes take effect immediately."
: "hypridle is running its shipped configuration. Turn on management below to make these adjustable."
SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" }
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
@@ -41,10 +41,10 @@ SettingsPage {
SettingsCard {
title: "Management"
subtitle: "Panama generates hypridle's configuration into your state directory and points the service at it with a systemd drop-in. ~/.config/hypr is a symlink into the Panama repository, so the shipped configuration cannot be rewritten in place."
subtitle: "hypridle's configuration is generated into your state directory and the service is pointed at it with a systemd drop-in. ~/.config/hypr is a symlink into the configuration repository, so the shipped file cannot be rewritten in place."
SettingRow {
label: "Let Panama manage idle timings"
label: "Manage idle timings here"
detail: IdleLock.serviceState === "active"
? "hypridle is running"
: "hypridle is " + IdleLock.serviceState
@@ -0,0 +1,103 @@
// Privacy & Security.
//
// GNOME's Privacy panel covers screen lock, camera and microphone access, file
// history, trash, and device security. Panama covers the parts it genuinely
// owns and is explicit about the parts it does not.
//
// The file-history and trash settings are the notable omission, and the reason
// is worth stating: those are GNOME preferences enforced by gsd-housekeeping,
// which is not running in a Hyprland session. Offering switches for them would
// store a preference, change nothing, and give no sign of it -- the exact
// failure this codebase keeps designing against. So they are delegated by name
// rather than reimplemented as controls that lie.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Privacy & Security"
lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0
? "Screen lock, device access, and a machine whose security settings all check out."
: "Screen lock, which applications can see you, and how this machine is protected."
Component.onCompleted: if (!DeviceSecurity.scanned) DeviceSecurity.refresh()
SettingsCard {
title: "Screen lock"
subtitle: "The same settings as Power & Lock, which is where the idle timings live."
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
ToggleRow { setting: "lockOnSleep"; divider: false }
}
SettingsCard {
title: "Camera & microphone"
subtitle: PrivacyState.anyActive
? "In use right now — the bar shows an indicator whenever this is true."
: "Nothing is using your camera or microphone."
TextRow {
label: "Camera"
detail: PrivacyState.cameraActive
? "In use by " + (PrivacyState.cameraApp || "an application")
: "Not in use"
value: PrivacyState.cameraActive ? "Active" : "Idle"
}
TextRow {
label: "Microphone"
detail: PrivacyState.microphoneActive
? "In use by " + (PrivacyState.microphoneApp || "an application")
: "Not in use"
value: PrivacyState.microphoneActive ? "Active" : "Idle"
}
TextRow {
label: "Screen sharing"
detail: PrivacyState.screenSharingActive
? "Being shared by " + (PrivacyState.screenSharingApp || "an application")
: "Not being shared"
value: PrivacyState.screenSharingActive ? "Active" : "Idle"
divider: false
}
}
SettingsCard {
title: "Device security"
subtitle: DeviceSecurity.attentionCount === 0
? "Everything below is in its recommended state."
: DeviceSecurity.attentionCount + " item"
+ (DeviceSecurity.attentionCount === 1 ? "" : "s") + " below may deserve attention."
Repeater {
model: DeviceSecurity.facts
TextRow {
id: factRow
required property var modelData
required property int index
label: factRow.modelData.label
detail: factRow.modelData.detail
value: factRow.modelData.value
divider: factRow.index < DeviceSecurity.facts.length - 1
}
}
}
SettingsCard {
title: "Owned by Fedora"
subtitle: "File history and trash retention are GNOME preferences, applied by a housekeeping service that does not run in a Hyprland session. They are not offered as switches here, because storing that preference would change nothing."
ActionRow {
label: "File history & trash"
detail: "Opens GNOME Settings, which owns these"
action: "Open privacy"
divider: false
onTriggered: SystemSettings.openGnomePanel("privacy")
}
}
}
@@ -1,4 +1,4 @@
# Panama Settings
# Settings
The control centre for everything Panama owns. Anything the system owns —
hardware, accounts, printers — is delegated to GNOME Settings and labelled as
@@ -0,0 +1,75 @@
// Region & Language.
//
// GNOME keeps language and formats under System; the setting itself is the
// machine's locale, which localectl owns. Panama does not store a copy of it --
// there is exactly one system locale and localectl is where it lives, so a
// preference here would be a second source of truth that drifts the moment
// anything else changes it.
//
// Keyboard layout is deliberately not repeated here even though GNOME groups it
// with region. It is a compositor setting that applies instantly, it lives on
// the Keyboard page with the rest of the typing settings, and showing it twice
// invites the two views to disagree.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Region & Language"
lede: SystemLocale.pendingRestart
? "Your new language applies to programs started after you sign out and back in."
: "The language and regional formats this machine uses."
Component.onCompleted: if (SystemLocale.locales.length === 0) SystemLocale.refresh()
SettingsCard {
title: "Language"
subtitle: "Changing this needs your password, and takes effect for programs started afterwards."
TextRow {
label: "Current language"
detail: SystemLocale.pendingRestart
? "Chosen, but not in use until you sign out and back in"
: "Used by programs that ask the system what language to speak"
value: SystemLocale.currentLabel || "Reading…"
}
SearchPicker {
width: parent.width
items: SystemLocale.locales
current: SystemLocale.current
placeholder: "Search languages and regions"
emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
onPicked: value => SystemLocale.set(value)
}
}
SettingsCard {
visible: SystemLocale.lastError !== ""
title: "Language problem"
subtitle: SystemLocale.lastError
}
SettingsCard {
title: "Formats"
subtitle: "Dates, times, and numbers follow the language above. The desktop's own clock formatting is on the Appearance page."
ActionRow {
label: "Clock and date display"
detail: "How the desktop itself shows the time"
action: "Open appearance"
onTriggered: ShellState.openSettings("appearance")
}
ActionRow {
label: "Regional formats"
detail: "Separate per-category formats (LC_TIME, LC_NUMERIC) are owned by Fedora"
action: "Open system"
divider: false
onTriggered: SystemSettings.openGnomePanel("system", "region")
}
}
}
@@ -18,7 +18,7 @@ SettingsPage {
SettingsCard {
title: "Read anything on screen"
subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions."
subtitle: "Select a region, window, or display. It is recognized locally, with clean follow-up actions."
SettingRow {
icon: "󰗊"
@@ -52,7 +52,7 @@ SettingsPage {
SettingsCard {
title: "Local recognition"
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result."
subtitle: "The screen image stays in a local cache and is deleted when you dismiss the result."
TextRow {
label: "Text recognition"
@@ -0,0 +1,94 @@
// A searchable list of choices, for settings with far too many values to put in
// a dropdown and no useful preview to show.
//
// SearchPicker {
// items: Locale.locales // [{ value, label, detail }]
// current: Locale.current
// placeholder: "Search languages"
// onPicked: value => Locale.set(value)
// }
//
// FontPicker is the same idea specialised: it draws each candidate in its own
// family, which is the whole reason it is a list rather than a text field. This
// one is for choices whose only useful preview is their name.
import QtQuick
import qs.config
// SearchField lives with the clipboard module, which is where it was first
// needed; FontPicker imports it from the same place.
import qs.modules.clipboard
Column {
id: root
// [{ value, label, detail }]
property var items: []
property string current: ""
property string emptyText: "Nothing to choose from"
property string placeholder: "Search"
signal picked(string value)
spacing: 0
// Capped and filtered rather than listing everything: 327 locales inside a
// page that is already scrolling is worse than a search box.
readonly property var matches: {
const needle = filter.text.trim().toLowerCase();
const list = root.items.filter(item =>
needle === ""
? true
: (String(item.label).toLowerCase().indexOf(needle) >= 0
|| String(item.detail).toLowerCase().indexOf(needle) >= 0));
// The current choice stays visible while browsing, so it is always
// clear what would be replaced.
const selected = list.find(item => item.value === root.current);
if (needle === "" && selected !== undefined)
return [selected].concat(list.filter(item => item.value !== root.current)).slice(0, 12);
return list.slice(0, 12);
}
SearchField {
id: filter
width: parent.width
placeholder: root.placeholder
}
Repeater {
model: root.matches
SettingRow {
id: candidate
required property var modelData
required property int index
readonly property bool selected: candidate.modelData.value === root.current
label: candidate.modelData.label
detail: candidate.selected ? "Currently in use" : candidate.modelData.detail
controlWidth: 120
divider: candidate.index < root.matches.length - 1
activatable: !candidate.selected
onActivated: root.picked(candidate.modelData.value)
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: candidate.selected
text: "✓"
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 2
color: Theme.accent
}
}
}
SettingRow {
width: parent.width
visible: root.matches.length === 0
label: root.emptyText
divider: false
}
}
@@ -120,14 +120,40 @@ SettingsPage {
SettingsCard {
title: "Fedora system settings"
subtitle: "These remain owned by trusted system services and GNOME's mature panels."
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: "Network, Bluetooth, printers, users, and accounts"
detail: "GNOME Settings remains searchable from the launcher too"
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
action: "Open network"
onTriggered: SystemSettings.openGnomePanel("network")
onTriggered: SystemSettings.openGnomePanel("wellbeing")
}
}
}
@@ -40,7 +40,7 @@ Rectangle {
anchors.left: parent.left
anchors.leftMargin: 18
anchors.verticalCenter: parent.verticalCenter
text: "Panama Settings"
text: "Settings"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
@@ -99,6 +99,9 @@ Rectangle {
case "notifications": return notificationsPage;
case "screen-intelligence": return screenIntelligencePage;
case "shortcuts": return shortcutsPage;
case "mouse": return mousePage;
case "privacy": return privacyPage;
case "region": return regionPage;
case "accessibility": return accessibilityPage;
case "power": return powerPage;
case "datetime": return dateTimePage;
@@ -153,6 +156,9 @@ Rectangle {
Component { id: notificationsPage; NotificationsPage {} }
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
Component { id: shortcutsPage; ShortcutsPage {} }
Component { id: mousePage; MousePage {} }
Component { id: privacyPage; PrivacyPage {} }
Component { id: regionPage; RegionPage {} }
Component { id: servicesPage; ServicesPage {} }
Component { id: aboutPage; AboutPage {} }
@@ -31,13 +31,16 @@ Rectangle {
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
{ page: "shortcuts", label: "Input & Shortcuts", icon: "\u{F030C}" },
{ page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" },
{ page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" },
{ page: "privacy", label: "Privacy & Security", icon: "\u{F0483}" },
{ page: "region", label: "Region & Language", icon: "\u{F0AC2}" },
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}" },
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
{ page: "services", label: "Startup & Services", icon: "\u{F0493}" },
{ page: "about", label: "About Panama", icon: "\u{F02FD}" }
{ page: "about", label: "About", icon: "\u{F02FD}" }
]
width: 272
@@ -56,15 +59,6 @@ Rectangle {
height: implicitHeight
spacing: 12
Text {
text: "PANAMA"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
font.letterSpacing: 2.2
}
Rectangle {
width: parent.width
height: 36
@@ -314,7 +308,7 @@ Rectangle {
anchors.left: parent.left
anchors.leftMargin: 36
anchors.verticalCenter: parent.verticalCenter
text: "Panama desktop is healthy"
text: "Desktop is healthy"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
@@ -8,7 +8,7 @@ FloatingWindow {
readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics
title: "Panama Settings"
title: "Settings"
visible: ShellState.settingsOpen
implicitWidth: 1120
implicitHeight: 760
@@ -28,24 +28,19 @@ SettingsPage {
SettingsCard {
title: "Keyboard"
TextRow {
label: "Keyboard layout"
detail: "XKB layout name. Changing it needs a compositor reload, so it is shown here rather than offered as a control that appears to apply instantly."
value: Settings ? DesktopPreferences.get("keyboardLayout") : "us"
}
// These were read-only text, on the grounds that a layout change needed
// a compositor reload. It does not: setting input:kb_variant through
// hl.config re-keymaps attached keyboards immediately -- verified by
// watching active_keymap on a real keyboard change and change back. So
// they are real controls.
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
SliderRow { setting: "keyRepeatDelay" }
SliderRow { setting: "keyRepeatRate" }
ToggleRow { setting: "numlockByDefault"; divider: false }
}
SettingsCard {
title: "Pointer"
ChoiceRow { setting: "followMouse" }
SliderRow { setting: "pointerSensitivity" }
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
}
SettingsCard {
title: "Hardware input"
@@ -155,7 +150,7 @@ SettingsPage {
SettingsCard {
visible: Object.keys(Keybinds.overrides).length > 0
title: "Changed shortcuts"
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from Panama's configuration."
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from the desktop's configuration."
ActionRow {
label: "Restore every shipped shortcut"
@@ -0,0 +1,109 @@
// A free-text setting, bound to a schema key by name.
//
// TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
//
// For the settings whose value is a short string with too many legal values to
// enumerate -- XKB layouts and options, chiefly. Label, explanation, and
// validation all come from PreferenceSchema, so a row cannot drift from the
// setting it edits.
//
// Rejected input is shown as rejected rather than silently dropped or quietly
// sanitised. Several of these strings are serialised into an hl.config payload,
// where stripping an unexpected character would turn a typo into a different
// working setting -- so the schema's pattern decides, the field turns red when
// it fails, and nothing is committed until it passes.
//
// Committed on Enter or when focus leaves, not per keystroke: half a layout
// name is a valid string that means something else, and each commit is a round
// trip to the compositor.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
required property string setting
property string placeholder: ""
readonly property var spec: PreferenceSchema.spec(root.setting)
readonly property string stored: String(DesktopPreferences.get(root.setting) ?? "")
// Empty is always allowed to be typed through, even where the pattern
// forbids it, so a field can be cleared on the way to a new value.
readonly property bool valid: input.text === ""
|| !root.spec?.pattern
|| new RegExp(root.spec.pattern).test(input.text)
label: root.spec ? root.spec.label : root.setting
detail: root.spec ? root.spec.detail : ""
controlWidth: 210
function commit(): void {
if (!root.valid || input.text === root.stored)
return;
SystemSettings.commitPreference(root.setting, input.text);
}
function revert(): void {
input.text = root.stored;
}
// Follows the store when the value changes elsewhere -- a reset, a restored
// backup -- but never while the field has focus, which would overwrite what
// is being typed.
onStoredChanged: if (!input.activeFocus) input.text = root.stored
Rectangle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: root.controlWidth
height: 30
radius: 8
color: Theme.alpha(Theme.fg, 0.05)
border.width: 1
border.color: !root.valid
? Theme.danger
: (input.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12))
TextInput {
id: input
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
verticalAlignment: TextInput.AlignVCenter
clip: true
text: root.stored
color: root.valid ? Theme.fg : Theme.danger
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
selectByMouse: true
selectionColor: Theme.alpha(Theme.accent, 0.35)
onAccepted: root.commit()
// Commit, then show what is actually stored. If the compositor
// refused the value, the field snaps back to the value in effect
// rather than displaying a setting that was never applied; if it
// accepted, onStoredChanged brings the field back up to date the
// moment the write lands.
onActiveFocusChanged: if (!activeFocus) { root.commit(); root.revert(); }
Keys.onEscapePressed: {
root.revert();
input.focus = false;
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: input.text === "" && !input.activeFocus
text: root.placeholder
color: Theme.fgMuted
font: input.font
}
}
}
}
@@ -156,7 +156,7 @@ Item {
width: parent.width - 40
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
text: "No images found. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
text: "No images found. Looked in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
@@ -46,3 +46,8 @@ SoundDeviceRow 1.0 SoundDeviceRow.qml
TimeOfDayRow 1.0 TimeOfDayRow.qml
LocationPicker 1.0 LocationPicker.qml
FontPicker 1.0 FontPicker.qml
MousePage 1.0 MousePage.qml
TextEntryRow 1.0 TextEntryRow.qml
PrivacyPage 1.0 PrivacyPage.qml
RegionPage 1.0 RegionPage.qml
SearchPicker 1.0 SearchPicker.qml
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# What this machine is, as JSON: {label, value} pairs in display order.
#
# GNOME's About panel answers "what am I running on" in one screen, and
# fastfetch answers it in more detail; this covers both -- model, OS, kernel,
# uptime, package counts, shell, resolution, processor, memory, swap, disk and
# locale. Rows are ordered roughly the way fastfetch presents them: what the
# system is, then what is installed on it, then the hardware underneath.
#
# Graphics is deliberately absent: GraphicsDevices already enumerates GPUs for
# the vitals readout, and naming them again here would be a second source of
# truth that could disagree with the first. The page joins the two.
#
# Anything unreadable is omitted rather than reported as "Unknown". These are
# facts about hardware, and a row saying "Processor: Unknown" is noise where
# simply not having the row is not.
set -uo pipefail
facts=()
emit() {
[[ -n "${2:-}" ]] || return 0
facts+=("$(jq -cn --arg label "$1" --arg value "$2" '{label: $label, value: $value}')")
}
# ── System ───────────────────────────────────────────────────────────────────
if [[ -r /etc/os-release ]]; then
# Sourced in a subshell so the variables cannot leak into this script.
os="$( . /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-$NAME}" )"
emit "Operating system" "$os"
fi
# DMI strings are frequently placeholders ("To Be Filled By O.E.M.", "Default
# string"). Those are worse than nothing, so they are filtered out.
dmi() {
local value
value="$(cat "/sys/class/dmi/id/$1" 2>/dev/null)" || return 0
case "$value" in
""|"To Be Filled By O.E.M."*|"Default string"|"System Product Name"|"Unknown"|"None") return 0 ;;
esac
printf '%s' "$value"
}
vendor="$(dmi sys_vendor)"
product="$(dmi product_name)"
if [[ -n "$vendor" && -n "$product" ]]; then
emit "Model" "$vendor $product"
else
emit "Model" "${product:-$vendor}"
fi
emit "Hostname" "$(hostnamectl hostname 2>/dev/null || hostname 2>/dev/null)"
emit "Kernel" "$(uname -r 2>/dev/null)"
# `uptime -p` already reads as prose ("1 week, 23 hours, 5 minutes"); only the
# leading "up " needs removing.
emit "Uptime" "$(uptime -p 2>/dev/null | sed 's/^up //')"
# ── Installed ────────────────────────────────────────────────────────────────
# Counted rather than listed. rpm -qa on a full workstation is a few thousand
# lines and takes a moment, which is part of why this whole helper runs on
# demand rather than at startup.
packages=""
if command -v rpm >/dev/null 2>&1; then
rpm_count="$(rpm -qa 2>/dev/null | wc -l)"
[[ "$rpm_count" -gt 0 ]] && packages="$rpm_count rpm"
fi
if command -v flatpak >/dev/null 2>&1; then
flatpak_count="$(flatpak list --app 2>/dev/null | wc -l)"
if [[ "$flatpak_count" -gt 0 ]]; then
[[ -n "$packages" ]] && packages="$packages, "
packages="$packages$flatpak_count flatpak"
fi
fi
emit "Packages" "$packages"
# $SHELL is the login shell, which is the one worth reporting -- the shell this
# script happens to run under is an implementation detail of the caller.
if [[ -n "${SHELL:-}" ]]; then
shell_name="$(basename "$SHELL")"
shell_version="$("$SHELL" --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)"
emit "Shell" "${shell_name}${shell_version:+ $shell_version}"
fi
emit "Locale" "${LANG:-}"
case "${XDG_SESSION_TYPE:-}" in
wayland) emit "Windowing system" "Wayland" ;;
x11) emit "Windowing system" "X11" ;;
esac
# ── Hardware ─────────────────────────────────────────────────────────────────
# The focused monitor's actual mode, including the fractional scale, since a
# 4500x3000 panel at 1.5 presents very differently from one at 1.
if command -v hyprctl >/dev/null 2>&1; then
emit "Resolution" "$(hyprctl -j monitors 2>/dev/null \
| jq -r 'map(select(.focused)) + . | .[0]
| select(. != null)
| "\(.width)x\(.height) @ \(.refreshRate | floor)Hz · scale \(.scale)"' 2>/dev/null)"
fi
cpu="$(awk -F': ' '/^model name/ { print $2; exit }' /proc/cpuinfo 2>/dev/null)"
threads="$(nproc 2>/dev/null)"
if [[ -n "$cpu" ]]; then
# The marketing name usually already says "6-Core", so the thread count is
# the part that adds information.
[[ -n "$threads" ]] && cpu="$cpu ($threads threads)"
emit "Processor" "$cpu"
fi
# Reported as the kernel sees it, which is a little under the sticker figure
# because firmware and integrated graphics reserve some before Linux starts.
#
# Total rather than used: About is not a monitor, and a "12.4 GiB used" figure
# is stale before it finishes drawing. The Home page's vitals readout is where
# live numbers belong.
emit "Memory" "$(awk '/^MemTotal:/ { printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)"
swap="$(awk '/^SwapTotal:/ { if ($2 > 0) printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)"
emit "Swap" "$swap"
read -r size used avail <<<"$(df -h --output=size,used,avail / 2>/dev/null | tail -1)"
[[ -n "${size:-}" ]] && emit "Disk" "$avail free of $size"
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
@@ -57,6 +57,39 @@ has_accessible_bus() {
return 1
}
# The I2C bus that carries DDC/CI for one connector.
#
# There are two, and picking the wrong one finds no monitor at all:
#
# DisplayPort carries DDC/CI over the AUX channel. That adapter shows up as a
# child directory of the connector -- /sys/class/drm/card1-DP-2/i2c-9 -- and
# is the one ddcutil talks to.
#
# The connector's `ddc` symlink points at the classic I2C line used for EDID
# on HDMI and DVI. On a DisplayPort connector it still exists and still
# resolves, but nothing answers on it: this machine's DP-2 has ddc -> i2c-5,
# where ddcutil reports "No monitor detected", while i2c-9 answers VCP 0x10
# immediately.
#
# So prefer the AUX child and fall back to the symlink. Note the readlink: the
# entries under /sys/class/drm are symlinks, and `find` does not follow the path
# it is given, so searching the unresolved path silently finds nothing.
bus_for_connector() {
local path="$1" real aux ddc
real="$(readlink -f "$path")"
aux="$(find "$real" -maxdepth 1 -name 'i2c-*' -printf '%f' -quit 2>/dev/null)"
if [[ -n "$aux" ]]; then
printf '%s' "${aux#i2c-}"
return 0
fi
ddc="$(readlink -f "$path/ddc" 2>/dev/null)" || return 1
[[ -n "$ddc" ]] || return 1
ddc="$(basename "$ddc")"
printf '%s' "${ddc#i2c-}"
}
cmd_list() {
has_accessible_bus || emit_error 'no I2C bus is accessible. ddcutil ships a udev rule that grants this, but only to devices created after it was installed. Run: sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm'
@@ -69,8 +102,7 @@ cmd_list() {
connector="$(basename "$path")"
connector="${connector#card*-}"
bus="$(basename "$(readlink -f "$path/ddc")")"
bus="${bus#i2c-}"
bus="$(bus_for_connector "$path")"
[[ "$bus" =~ ^[0-9]+$ ]] || continue
# A monitor that does not implement 0x10 is not an error; it simply
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# System locale, via localectl.
#
# panama-locale list -> [{value, label, detail}]
# panama-locale get -> the current LANG, e.g. en_US.UTF-8
# panama-locale set <locale>
#
# Locale codes are not names. "pt_BR.UTF-8" tells you what it means only if you
# already know, which defeats the point of a picker, so codes are resolved
# against the iso-codes database into "Portuguese (Brazil)" the way GNOME does.
# The code stays visible as the row's detail, because it is what actually gets
# written and someone choosing between two Spanish variants needs to see it.
#
# The join happens in a single jq pass. Doing it per locale meant 327 jq
# invocations, which took long enough to be visible when opening the page.
#
# Setting the locale is a privileged operation: localectl goes through polkit,
# which prompts. It also only takes effect for programs started afterwards, so
# the caller is responsible for saying a sign-out is needed -- this script does
# not pretend the running session changed.
set -uo pipefail
readonly ISO_LANG=/usr/share/iso-codes/json/iso_639-2.json
readonly ISO_COUNTRY=/usr/share/iso-codes/json/iso_3166-1.json
cmd_get() {
localectl status 2>/dev/null \
| awk -F'LANG=' '/System Locale:/ { print $2; exit }' \
| tr -d '[:space:]'
}
cmd_list() {
local locales
locales="$(localectl list-locales 2>/dev/null)" || locales=""
if [[ -z "$locales" ]]; then
printf '[]\n'
return 0
fi
# Without iso-codes installed the codes are still perfectly usable; they
# just do not get friendly names. That is a degraded list, not a failure.
if [[ ! -r "$ISO_LANG" || ! -r "$ISO_COUNTRY" ]]; then
jq -Rn --rawfile raw /dev/stdin \
'[$raw | split("\n")[] | select(length > 0) | {value: ., label: ., detail: ""}]' \
<<<"$locales"
return 0
fi
jq -Rn \
--slurpfile languages "$ISO_LANG" \
--slurpfile countries "$ISO_COUNTRY" \
--rawfile raw /dev/stdin '
# alpha_2 -> name, for both databases. Languages without a two-letter
# code cannot appear in a locale name, so they are simply absent.
($languages[0]["639-2"] | map(select(.alpha_2)) | INDEX(.alpha_2) | map_values(.name)) as $lang
| ($countries[0]["3166-1"] | INDEX(.alpha_2) | map_values(.name)) as $country
| [ $raw
| split("\n")[]
| select(length > 0)
| . as $value
# en_US.UTF-8 -> ["en", "US"]; the codeset and any @modifier are
# not part of the human name.
| ($value | split(".")[0] | split("@")[0] | split("_")) as $parts
| ($lang[$parts[0]] // $parts[0]) as $language
| (if ($parts | length) > 1 then $country[$parts[1]] else null end) as $region
| {
value: $value,
label: (if $region then "\($language) (\($region))" else $language end),
detail: $value
}
]
| sort_by(.label)
' <<<"$locales"
}
cmd_set() {
local locale="${1:-}"
# Constrained rather than passed through: this reaches a privileged
# command, and the set of legal locale names is narrow and well known.
[[ "$locale" =~ ^[[email protected]]+$ ]] || {
printf 'panama-locale: refusing a locale name with unexpected characters\n' >&2
return 2
}
localectl list-locales 2>/dev/null | grep -qxF "$locale" || {
printf 'panama-locale: %s is not an installed locale\n' "$locale" >&2
return 2
}
localectl set-locale "LANG=$locale"
}
case "${1:-list}" in
list) cmd_list ;;
get) cmd_get ;;
set) shift; cmd_set "${1:-}" ;;
*) printf 'usage: panama-locale [list|get|set <locale>]\n' >&2; exit 2 ;;
esac
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Device security facts, as JSON.
#
# Everything here is READ-ONLY and deliberately so. Secure Boot, TPM presence,
# disk encryption, SELinux mode and the firewall are set in firmware, at install
# time, or by system policy -- none of them is a desktop preference, and a
# settings app that offered to toggle them would either fail or do something
# far-reaching from a switch that looks like any other.
#
# What it is for is answering "is this machine set up the way I think it is",
# which is the question GNOME's Device Security panel exists to answer and which
# otherwise needs five commands and root.
#
# Each fact is reported as {value, ok} where `ok` marks the reassuring state, so
# the UI can highlight what deserves attention without hard-coding the meaning
# of each string. Anything that cannot be determined reports "Unknown" with
# ok:false rather than guessing, because a security readout that quietly reports
# "fine" when it failed to look is worse than no readout.
set -uo pipefail
fact() {
jq -cn --arg label "$1" --arg value "$2" --argjson ok "$3" --arg detail "${4:-}" \
'{label: $label, value: $value, ok: $ok, detail: $detail}'
}
facts=()
# ── Secure Boot ──────────────────────────────────────────────────────────────
if command -v mokutil >/dev/null 2>&1; then
case "$(mokutil --sb-state 2>/dev/null)" in
*"SecureBoot enabled"*) facts+=("$(fact "Secure Boot" "Enabled" true "Firmware verifies the bootloader and kernel signatures")" ) ;;
*"SecureBoot disabled"*) facts+=("$(fact "Secure Boot" "Disabled" false "Firmware does not verify what it boots")") ;;
*) facts+=("$(fact "Secure Boot" "Unknown" false "The firmware did not report a Secure Boot state")") ;;
esac
elif [[ -d /sys/firmware/efi ]]; then
facts+=("$(fact "Secure Boot" "Unknown" false "Install mokutil to report this")")
else
facts+=("$(fact "Secure Boot" "Not applicable" false "This machine booted in legacy BIOS mode")")
fi
# ── TPM ──────────────────────────────────────────────────────────────────────
tpm_major="$(cat /sys/class/tpm/tpm0/tpm_version_major 2>/dev/null || true)"
if [[ -n "$tpm_major" ]]; then
facts+=("$(fact "TPM" "Version $tpm_major" true "A trusted platform module is present and usable")")
elif [[ -e /sys/class/tpm/tpm0 ]]; then
facts+=("$(fact "TPM" "Present" true "A trusted platform module is present")")
else
facts+=("$(fact "TPM" "None" false "No trusted platform module, so keys cannot be sealed to this machine")")
fi
# ── Disk encryption ──────────────────────────────────────────────────────────
# Counts LUKS mappings rather than naming them: which volume is encrypted is
# more detail than this readout needs, and device names are not meaningful here.
crypt_count="$(lsblk -o TYPE 2>/dev/null | grep -c '^crypt$' || true)"
[[ "$crypt_count" =~ ^[0-9]+$ ]] || crypt_count=0
if (( crypt_count > 0 )); then
facts+=("$(fact "Disk encryption" "$crypt_count encrypted volume$( (( crypt_count == 1 )) || printf 's')" true "Data at rest is protected by LUKS")")
else
facts+=("$(fact "Disk encryption" "None" false "No LUKS volume is unlocked on this machine")")
fi
# ── SELinux ──────────────────────────────────────────────────────────────────
if command -v getenforce >/dev/null 2>&1; then
case "$(getenforce 2>/dev/null)" in
Enforcing) facts+=("$(fact "SELinux" "Enforcing" true "Policy violations are blocked")") ;;
Permissive) facts+=("$(fact "SELinux" "Permissive" false "Violations are logged but allowed")") ;;
Disabled) facts+=("$(fact "SELinux" "Disabled" false "Mandatory access control is off")") ;;
*) facts+=("$(fact "SELinux" "Unknown" false "")") ;;
esac
fi
# ── Firewall ─────────────────────────────────────────────────────────────────
if systemctl list-unit-files firewalld.service >/dev/null 2>&1; then
if [[ "$(systemctl is-active firewalld 2>/dev/null)" == "active" ]]; then
facts+=("$(fact "Firewall" "Active" true "firewalld is filtering incoming connections")")
else
facts+=("$(fact "Firewall" "Inactive" false "firewalld is installed but not running")")
fi
fi
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
@@ -0,0 +1,54 @@
pragma Singleton
// Read-only device security facts: Secure Boot, TPM, disk encryption, SELinux,
// firewall.
//
// Nothing here is a preference. These are set in firmware, at install time, or
// by system policy, and a settings app that offered to change them from a
// switch would either fail or do something far-reaching from a control that
// looks like every other control. What this answers is "is this machine set up
// the way I think it is", which otherwise takes five commands and root.
//
// Read on demand. None of these can change while the desktop is running,
// short of a reboot.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-security"
// [{ label, value, ok, detail }]
property var facts: []
property bool scanned: false
// The facts that are not in their reassuring state. The page leads with the
// count so a machine that is entirely fine says so in one line instead of
// making the user read five rows to find out.
readonly property int attentionCount: root.facts.filter(fact => !fact.ok).length
function refresh(): void {
if (!query.running)
query.running = true;
}
Process {
id: query
command: [root.helperPath]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.facts = Array.isArray(parsed) ? parsed : [];
} catch (error) {
root.facts = [];
console.warn("DeviceSecurity: could not parse helper output:", error);
}
root.scanned = true;
}
}
}
}
@@ -0,0 +1,66 @@
pragma Singleton
// What input hardware this machine actually has.
//
// Exists so pages can hide controls for hardware that is not present. A
// touchpad card on a desktop is not merely useless -- it is misleading, because
// every switch on it appears to work: the preference is stored and Hyprland
// accepts the option for a device class it has no member of. The user is left
// toggling settings that will never affect anything, with nothing to say so.
//
// Touchpads are identified by name. libinput exposes them through the same
// "mice" list as everything else that reports pointer motion, and Hyprland
// passes the device name through, so an Elan or Synaptics touchpad arrives as
// something like "elan-touchpad". There is no device-class field to consult.
//
// Read on demand rather than polled. Input devices do come and go -- a mouse is
// unplugged, a receiver is moved -- so this also refreshes when Hyprland says
// the device list changed, which is the only moment the answer can differ.
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import QtQuick
Singleton {
id: root
property var mice: []
property var keyboards: []
// True when anything that looks like a touchpad is attached.
readonly property bool hasTouchpad: root.mice.some(name =>
name.includes("touchpad") || name.includes("trackpad"))
readonly property bool hasMouse: root.mice.length > 0
function refresh(): void {
if (!query.running)
query.running = true;
}
Process {
id: query
running: true
command: ["hyprctl", "-j", "devices"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.mice = (parsed.mice ?? []).map(device => String(device.name ?? "").toLowerCase());
root.keyboards = (parsed.keyboards ?? []).map(device => String(device.name ?? "").toLowerCase());
} catch (error) {
console.warn("InputDevices: could not parse hyprctl devices:", error);
}
}
}
}
Connections {
target: Hyprland
function onRawEvent(event: var): void {
if (event.name === "device" || event.name === "configreloaded")
root.refresh();
}
}
}
@@ -0,0 +1,47 @@
pragma Singleton
// What this machine is: model, processor, memory, disk, OS, kernel.
//
// Read once, on demand. None of it changes while the desktop is running except
// free disk space, and About is not a monitor -- the vitals readout on the Home
// page is where live figures belong.
//
// Graphics is not here. GraphicsDevices already enumerates GPUs for the vitals
// readout, and naming them again would be a second source of truth that could
// disagree with the first; the About page joins the two instead.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-about"
// [{ label, value }] in display order.
property var facts: []
property bool scanned: false
function refresh(): void {
if (!query.running)
query.running = true;
}
Process {
id: query
command: [root.helperPath]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.facts = Array.isArray(parsed) ? parsed : [];
} catch (error) {
root.facts = [];
console.warn("MachineInfo: could not parse helper output:", error);
}
root.scanned = true;
}
}
}
}
@@ -33,6 +33,9 @@ Singleton {
"idle": "power",
"accessibility": "accessibility",
"input": "shortcuts",
"pointer": "mouse",
"touchpad": "mouse",
"multitasking": "desktop",
"weather": "appearance",
"notifications": "notifications",
"capture": "screen-intelligence"
@@ -92,7 +92,7 @@ Singleton {
}
function openSettings(page: string): void {
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "accessibility", "power", "datetime", "applications", "services", "about"];
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accessibility", "power", "datetime", "applications", "services", "about"];
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
DesktopPreferences.set("lastPage", root.settingsPage);
root.settingsOpen = true;
@@ -0,0 +1,99 @@
pragma Singleton
// The system locale.
//
// Named SystemLocale, not Locale: QML has a built-in Locale value type, and a
// singleton of that name is silently shadowed by it. Every binding then reads
// properties off the wrong thing and the page renders empty with only
// "Cannot read property of undefined" to show for it.
//
// Changing it is privileged: localectl goes through polkit, which prompts
// (hyprpolkitagent serves that in this session). It also only applies to
// programs started afterwards, so `pendingRestart` goes true once a change is
// accepted and the page says a sign-out is needed. Reporting the new locale as
// simply "in effect" would be wrong -- almost nothing on screen would be using
// it yet.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-locale"
// [{ value, label, detail }]
property var locales: []
property string current: ""
property bool scanning: false
property string lastError: ""
// True once a change has been accepted but the session has not restarted,
// so the UI can stop claiming the new locale is already in use.
property bool pendingRestart: false
readonly property string currentLabel: {
const match = root.locales.find(locale => locale.value === root.current);
return match ? match.label : root.current;
}
function refresh(): void {
if (root.scanning)
return;
root.scanning = true;
readCurrent.running = true;
list.running = true;
}
function set(value: string): void {
if (value === root.current)
return;
apply.command = [root.helperPath, "set", value];
apply.pendingValue = value;
apply.running = true;
}
Process {
id: list
command: [root.helperPath, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.locales = Array.isArray(parsed) ? parsed : [];
} catch (error) {
root.locales = [];
console.warn("SystemLocale: could not parse the locale list:", error);
}
root.scanning = false;
}
}
}
Process {
id: readCurrent
command: [root.helperPath, "get"]
stdout: StdioCollector {
onStreamFinished: root.current = this.text.trim()
}
}
Process {
id: apply
property string pendingValue: ""
// A refused change -- polkit dismissed, or an unknown locale -- must not
// move the UI. The value is only adopted on a zero exit.
onExited: code => {
if (code === 0) {
root.current = apply.pendingValue;
root.pendingRestart = true;
root.lastError = "";
} else {
root.lastError = "The system did not accept that language. It may have needed a password.";
}
}
}
}
@@ -497,18 +497,35 @@ Singleton {
].indexOf(panel) >= 0;
}
function openGnomePanel(panel: string): bool {
// `subpage` reaches the panels GNOME 50 nests under System -- users,
// about, datetime, region -- which its own desktop entries open as
// `gnome-control-center system users`. Without it, a row labelled "Users"
// lands on System's front page and leaves the user to navigate, which is
// most of the way to a broken button.
function openGnomePanel(panel: string, subpage: string): bool {
if (!root.isGnomePanelAllowed(panel)) {
root.lastError = "That GNOME Settings panel is not available.";
return false;
}
const command = ["gnome-control-center", panel];
if (subpage !== undefined && subpage !== "" && root.isGnomeSubpageAllowed(panel, subpage))
command.push(subpage);
Quickshell.execDetached({
command: ["gnome-control-center", panel],
command: command,
environment: { "XDG_CURRENT_DESKTOP": "GNOME" }
});
return true;
}
// Only System nests panels, and only these. Read off the Exec lines of the
// gnome-*-panel desktop entries rather than guessed, for the same reason
// the panel list above was.
function isGnomeSubpageAllowed(panel: string, subpage: string): bool {
if (panel !== "system")
return false;
return ["users", "about", "datetime", "region", "remote-desktop"].indexOf(subpage) >= 0;
}
function openApplication(id: string): bool {
const commands = {
"nextcloud": ["nextcloud"],
@@ -0,0 +1,87 @@
# Tokyo Night Day — the light counterpart to tokyonight-moon.toml.
#
# Same schema and the same role for every colour; only the palette differs, so
# the launcher keeps its identity when the desktop switches to light rather
# than becoming a different-looking application.
#
# Colours are the Tokyo Night Day palette, taken from kitty/themes/tokyonight-day.conf
# so the launcher and the terminal cannot drift apart.
[meta]
version = 1
name = "Tokyo Night Day"
description = "Tokyo Night Day, matched to the Panama Hyprland desktop."
variant = "light"
[colors.core]
background = "#e1e2e7" # bg
foreground = "#3760bf" # fg
secondary_background = "#d0d5e3" # bg_dark, a shade below the ground
border = "#a8aecb" # a visible hairline on a light ground
accent = "#2e7de9" # blue
accent_foreground = "#e1e2e7"
[colors.accents]
blue = "#2e7de9"
green = "#587539"
magenta = "#9854f1"
orange = "#b15c00"
purple = "#7847bd"
red = "#f52a65"
yellow = "#8c6c3e"
cyan = "#007197"
[colors.main_window]
border = "#a8aecb"
footer = { background = "colors.core.secondary_background" }
[colors.settings_window]
border = "#c4c8da"
[colors.shortcut]
border = "colors.core.border"
[colors.text]
default = "colors.core.foreground"
muted = "#6172b0" # fg_dark, 4.0:1 on the background
danger = "#f52a65"
success = "#587539"
placeholder = "#7079a8" # 3.25:1; Day's own #848cb5 is 2.54:1, too faint to read
selection = { background = "#2e7de9", foreground = "#e1e2e7" }
[colors.text.links]
default = "#2e7de9"
visited = "#9854f1"
[colors.input]
border = "#c4c8da"
border_focus = "#2e7de9"
border_error = "#f52a65"
[colors.button.primary]
background = "#d4d6e4" # bg_highlight
foreground = "#3760bf"
hover = { background = "#c4c8da" }
focus = { outline = "colors.core.accent" }
[colors.list.item.hover]
foreground = "#3760bf"
secondary_foreground = "#6172b0"
[colors.list.item.selection]
background = "#c3cdf0" # blue side of the Prism selection, lightened
foreground = "#3760bf"
secondary_background = "#d8c8e4" # orchid side of the Prism selection, lightened
secondary_foreground = "#3760bf"
[colors.grid.item]
background = "#d7d9e5"
hover = { outline = "#2e7de9" }
selection = { outline = "#9854f1" }
[colors.scrollbars]
background = "#c4c8da"
[colors.loading]
bar = "#2e7de9"
spinner = "#3760bf"
+6 -4
View File
@@ -6,12 +6,14 @@
// and emoji-copy extensions, so its clipboard and emoji views are bound
// directly in hypr/keybinds.lua.
// Vicinae 0.26 selects a theme per system appearance. Both point to Moon so
// the launcher never flashes or falls back to its stock palette while the
// desktop appearance is being initialized.
// Vicinae 0.26 selects a theme per system appearance, and now there is a
// theme for each: the launcher follows Panama's light/dark setting instead of
// staying dark on a light desktop. Both are authored here rather than using
// Vicinae's bundled Tokyo Night, which ships Night and Storm but not the Moon
// and Day variants the rest of this desktop uses.
"theme": {
"light": {
"name": "tokyonight-moon",
"name": "tokyonight-day",
"icon_theme": "auto"
},
"dark": {
@@ -1,11 +1,11 @@
[Desktop Entry]
Type=Application
Name=Panama Settings
Name=Settings
GenericName=System Settings
Comment=Configure the Panama Hyprland desktop
Comment=Configure this desktop
Exec=qs ipc call settings open
Icon=panama-settings
Terminal=false
StartupNotify=false
Categories=Settings;
Keywords=Panama;Hyprland;Display;Dock;Notifications;Shortcuts;Services;
Keywords=Settings;Preferences;Configuration;Display;Sound;Network;Keyboard;Mouse;Privacy;Power;Shortcuts;
+17 -10
View File
@@ -95,19 +95,26 @@ fi
# from ~/.config/vicinae. Keep the authored theme in Panama with the rest of
# the launcher config and expose only that file at Vicinae's runtime path.
VICINAE_THEME_DIR="$HOME/.local/share/vicinae/themes"
VICINAE_THEME="$VICINAE_THEME_DIR/tokyonight-moon.toml"
mkdir -p "$VICINAE_THEME_DIR"
if [ -L "$VICINAE_THEME" ]; then
rm "$VICINAE_THEME"
fi
# Every authored theme, not just the dark one: vicinae.json selects a theme per
# system appearance, so linking only Moon left the launcher falling back to its
# stock palette whenever Panama was in light mode.
for theme_src in "$PANAMA_DOT"/vicinae/themes/*.toml; do
[ -e "$theme_src" ] || continue
theme_dst="$VICINAE_THEME_DIR/$(basename "$theme_src")"
if [ -e "$VICINAE_THEME" ]; then
log "Keeping existing Vicinae theme at $VICINAE_THEME"
else
ln -s "$PANAMA_DOT/vicinae/themes/tokyonight-moon.toml" "$VICINAE_THEME"
log "Linked Tokyo Night Moon theme → $VICINAE_THEME"
fi
if [ -L "$theme_dst" ]; then
rm "$theme_dst"
fi
if [ -e "$theme_dst" ]; then
log "Keeping existing Vicinae theme at $theme_dst"
else
ln -s "$theme_src" "$theme_dst"
log "Linked Vicinae theme → $theme_dst"
fi
done
# Panama-native applications live in the user data directory so launchers can
# discover them alongside system desktop entries. Keep each authored file in
+34 -13
View File
@@ -34,15 +34,35 @@ mkdir -p "$fixture/drm" "$fixture/dev" "$fixture/bin" "$fixture/i2c"
# Two connectors with a monitor, two without. DP-2 answers DDC; DP-3 is
# connected but does not implement brightness. HDMI-A-1 and DP-1 are empty and
# must never be probed at all.
# A connector has an EDID bus (the `ddc` symlink) and, on DisplayPort, an AUX
# bus that appears as a child directory. DDC/CI rides the AUX channel where one
# exists, and the `ddc` line answers nothing on a DP connector even though it
# still resolves -- so the aux argument here is what a real DisplayPort monitor
# looks like, and omitting it is what HDMI and DVI look like.
make_connector() {
local name="$1" bus="$2" status="$3"
mkdir -p "$fixture/drm/$name"
printf '%s\n' "$status" >"$fixture/drm/$name/status"
mkdir -p "$fixture/i2c/i2c-$bus"
ln -sfn "$fixture/i2c/i2c-$bus" "$fixture/drm/$name/ddc"
local name="$1" ddc_bus="$2" status="$3" aux_bus="${4:-}"
local device="$fixture/devices/$name"
# /sys/class/drm/<connector> is a SYMLINK to the real device directory, and
# this fixture mirrors that rather than using a plain directory. It matters:
# `find` does not follow the path it is given, so code that searches the
# unresolved path finds nothing while appearing to work anywhere the entry
# happens to be a real directory.
mkdir -p "$device"
printf '%s\n' "$status" >"$device/status"
mkdir -p "$fixture/i2c/i2c-$ddc_bus"
ln -sfn "$fixture/i2c/i2c-$ddc_bus" "$device/ddc"
[[ -n "$aux_bus" ]] && mkdir -p "$device/i2c-$aux_bus"
mkdir -p "$fixture/drm"
ln -sfn "$device" "$fixture/drm/$name"
return 0
}
# DP-2 is the DisplayPort case: its EDID line is bus 5, which answers nothing,
# and its AUX child is bus 9, which does. Choosing bus 5 here finds no monitor
# at all, which is exactly the bug this pins down.
make_connector card1-DP-1 4 disconnected
make_connector card1-DP-2 5 connected
make_connector card1-DP-2 5 connected 9
make_connector card1-DP-3 6 connected
make_connector card1-HDMI-A-1 7 disconnected
@@ -75,7 +95,7 @@ for arg in "$@"; do
done
case "$bus" in
5) printf 'VCP 10 C 120 200\n'; exit 0 ;;
9) printf 'VCP 10 C 120 200\n'; exit 0 ;;
*) exit 1 ;;
esac
STUB
@@ -103,8 +123,9 @@ jq -e . >/dev/null 2>&1 <<<"$listing" || fail "list did not emit JSON: $listing"
[[ "$(jq -r '.displays[0].connector' <<<"$listing")" == "DP-2" ]] \
|| fail "the connector name must match Hyprland's output name: $listing"
[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "5" ]] \
|| fail "the display was mapped to the wrong I2C bus: $listing"
# The AUX bus, not the EDID bus its `ddc` symlink points at.
[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "9" ]] \
|| fail "the display was mapped to its EDID bus instead of its DisplayPort AUX bus, where nothing answers: $listing"
# 120 of a maximum of 200 is 60%.
[[ "$(jq -r '.displays[0].value' <<<"$listing")" == "60" ]] \
@@ -123,12 +144,12 @@ if grep -qxE '4|7' "$DDCUTIL_PROBE_LOG"; then
fi
# ── Writes scale to the reported maximum ─────────────────────────────────────
run_helper set 5 40
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 80" ]] \
run_helper set 9 40
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 9 80" ]] \
|| fail "set did not scale to the display's maximum: $(cat "$DDCUTIL_SET_LOG")"
run_helper set 5 500
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 200" ]] \
run_helper set 9 500
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 9 200" ]] \
|| fail "an out-of-range percent was not clamped: $(cat "$DDCUTIL_SET_LOG")"
# ── No I2C access explains itself ────────────────────────────────────────────
@@ -288,18 +288,18 @@ shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')"
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
for _ in $(seq 1 40); do
if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null; then
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null; then
break
fi
sleep 0.1
done
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \
|| fail 'the branch shell did not own exactly one tiled Panama Settings client'
if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then
geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \
'.[] | select(.pid == $pid and .title == "Panama Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')"
'.[] | select(.pid == $pid and .title == "Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')"
[[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry'
grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH"
fi
@@ -61,6 +61,26 @@ restore() {
}
trap restore EXIT
# This contract shares its harness file with settings-hyprland-write-contract,
# and Quickshell identifies an instance by config path -- so if that run's
# instance is still alive, the IPC wait below is satisfied by ITS target. That
# direction is the dangerous one: this contract believes the compositor seam is
# stubbed, so it would happily drive the DAILY DESKTOP's real compositor while
# reporting isolation. Refuse to start rather than find out.
harness_instances() {
# rg -c prints nothing when there are no matches, so an unguarded
# substitution yields "" rather than "0".
local count
count="$(qs list 2>/dev/null | rg -c "^ Config path: $harness\$" || true)"
printf '%s' "${count:-0}"
}
for _ in $(seq 1 50); do
[[ "$(harness_instances)" == "0" ]] && break
sleep 0.1
done
[[ "$(harness_instances)" == "0" ]] \
|| fail 'another instance of the settings harness is still running -- this contract would drive it instead of its own isolated one, and that instance may be writing to the real compositor'
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1 qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
@@ -77,6 +77,32 @@ target_auto_hdr_int=$([[ "$target_auto_hdr" == true ]] && printf 1 || printf 0)
target_vrr=$([[ "$original_vrr" == 3 ]] && printf 0 || printf 3)
target_direct=$([[ "$original_direct" == 2 ]] && printf 0 || printf 2)
# settings-commit-reset-contract drives the SAME harness file with
# PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1, where the compositor write seam is
# stubbed out. Quickshell identifies an instance by its config path, so if that
# run's instance has not fully exited, the `ipc show` wait below is satisfied by
# ITS target -- and every write in this contract lands on the isolated instance
# and never reaches the compositor. That is exactly what "a typed batch did not
# reach the compositor" looks like when this fails in a full suite run but
# passes on its own.
#
# So wait for the harness to be clear first, and say so plainly if it is not,
# rather than silently talking to the wrong shell.
harness_instances() {
# rg -c prints nothing at all when there are no matches, so an unguarded
# substitution yields "" rather than "0" and every comparison against a
# count fails.
local count
count="$(qs list 2>/dev/null | rg -c "^ Config path: $harness\$" || true)"
printf '%s' "${count:-0}"
}
for _ in $(seq 1 50); do
[[ "$(harness_instances)" == "0" ]] && break
sleep 0.1
done
[[ "$(harness_instances)" == "0" ]] \
|| fail 'another instance of the settings harness is still running -- this contract would talk to it instead of its own, and its writes may be deliberately stubbed'
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$'; then
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Adding a settings page means editing four separate files, and missing one
# fails quietly rather than loudly:
#
# SettingsSidebar.qml the row you click
# SettingsShell.qml the case that maps that row to a component, AND the
# Component declaration itself
# ShellState.qml the allow-list openSettings() checks -- a page missing
# here silently redirects to Home, so a deep link or a
# search result lands on the wrong page with no error
# modules/settings/qmldir the component registration -- without it the page
# is "not a type" and the whole settings window fails
# to load, taking every other page with it
#
# Nothing at runtime cross-checks the four. This does, statically.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
sidebar="$settings_dir/SettingsSidebar.qml"
shell_file="$settings_dir/SettingsShell.qml"
qmldir="$settings_dir/qmldir"
shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
fail() {
printf 'settings nav contract: %s\n' "$1" >&2
exit 1
}
for required in "$sidebar" "$shell_file" "$qmldir" "$shell_state"; do
[[ -r "$required" ]] || fail "cannot read $required"
done
# ── Every sidebar row resolves everywhere ────────────────────────────────────
pages="$(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\(.*\)"/\1/')"
[[ -n "$pages" ]] || fail 'no pages found in the sidebar -- this contract is not reading it correctly'
allowed_line="$(grep -m1 'const allowed = \[' "$shell_state")" \
|| fail 'could not find the allow-list in ShellState'
while read -r page; do
[[ -n "$page" ]] || continue
# Home is the switch's default arm rather than a case, since it is also
# where an unknown page falls back to.
if [[ "$page" != "home" ]]; then
grep -qE "case \"$page\": return [a-zA-Z]+;" "$shell_file" \
|| fail "the sidebar offers \"$page\" but SettingsShell has no case for it, so clicking it shows Home"
fi
grep -qF "\"$page\"" <<<"$allowed_line" \
|| fail "\"$page\" is missing from ShellState's allow-list, so openSettings(\"$page\") silently redirects to Home"
done <<<"$pages"
# ── Every routed component is declared and registered ────────────────────────
# The case arms name a Component id; each must have a declaration, and the type
# it instantiates must appear in qmldir.
while read -r component; do
[[ -n "$component" ]] || continue
declaration="$(grep -oE "Component \{ id: $component; [A-Za-z]+ \{\} \}" "$shell_file")" \
|| fail "SettingsShell routes to \"$component\" but never declares it"
type_name="$(sed -E 's/.*; ([A-Za-z]+) \{\} \}/\1/' <<<"$declaration")"
grep -qE "^$type_name [0-9.]+ $type_name\.qml$" "$qmldir" \
|| fail "$type_name is not registered in modules/settings/qmldir -- it will fail to load as \"not a type\", and the whole settings window fails with it"
[[ -r "$settings_dir/$type_name.qml" ]] \
|| fail "$type_name is registered in qmldir but $type_name.qml does not exist"
done < <({
grep -oE 'case "[a-z-]+": return [a-zA-Z]+;' "$shell_file" | sed -E 's/.*return ([a-zA-Z]+);/\1/'
grep -oE 'default: return [a-zA-Z]+;' "$shell_file" | sed -E 's/.*return ([a-zA-Z]+);/\1/'
} | sort -u)
# ── Every page file is reachable ─────────────────────────────────────────────
# A page nobody can navigate to is dead code that still has to compile. The
# scaffold SettingsPage.qml is the one file here that is a base class rather
# than a page.
while read -r page_file; do
type_name="$(basename "$page_file" .qml)"
[[ "$type_name" == "SettingsPage" ]] && continue
grep -qE "; $type_name \{\} \}" "$shell_file" \
|| fail "$type_name.qml exists but nothing in SettingsShell instantiates it"
done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml')
printf 'settings nav contract: PASS\n'
+2 -2
View File
@@ -195,14 +195,14 @@ for page in "${pages[@]}"; do
done
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route"
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \
|| fail "$page created a missing, floating, or duplicate Settings window"
done
qs_for_test ipc call settings page '__unsupported__' >/dev/null
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home'
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Panama Settings" and .key == "I" and .modmask == 64)' >/dev/null \
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|| fail 'Super+I is not registered as Panama Settings'
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
|| fail 'Super+Shift+S is not registered as Screen Intelligence'
+6 -6
View File
@@ -16,14 +16,14 @@ qs ipc show | rg -q '^target settings$' || fail 'settings IPC target is missing'
qs ipc call settings open >/dev/null
for _ in $(seq 1 40); do
if hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null; then
if hyprctl -j clients | jq -e '[.[] | select(.title == "Settings")] | length == 1' >/dev/null; then
break
fi
sleep 0.1
done
hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null \
hyprctl -j clients | jq -e '[.[] | select(.title == "Settings")] | length == 1' >/dev/null \
|| fail 'exactly one Settings window did not map'
hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings" and .floating == false)' >/dev/null \
hyprctl -j clients | jq -e '.[] | select(.title == "Settings" and .floating == false)' >/dev/null \
|| fail 'Settings window is not tiled'
qs ipc call settings page displays >/dev/null
@@ -32,7 +32,7 @@ qs ipc call settings page displays >/dev/null
qs ipc call settings page desktop >/dev/null
[[ "$(qs ipc call settings status | jq -r .page)" == "desktop" ]] || fail 'Desktop page did not route'
address="$(hyprctl -j clients | jq -r '.[] | select(.title == "Panama Settings") | .address')"
address="$(hyprctl -j clients | jq -r '.[] | select(.title == "Settings") | .address')"
hyprctl dispatch "hl.dsp.window.close({ window = \"address:$address\" })" >/dev/null
for _ in $(seq 1 40); do
[[ "$(qs ipc call settings status | jq -r .open)" == "false" ]] && break
@@ -42,13 +42,13 @@ done
qs ipc call settings open >/dev/null
for _ in $(seq 1 40); do
hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings")' >/dev/null && break
hyprctl -j clients | jq -e '.[] | select(.title == "Settings")' >/dev/null && break
sleep 0.1
done
qs ipc call settings close >/dev/null
for _ in $(seq 1 40); do
if ! hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings")' >/dev/null; then
if ! hyprctl -j clients | jq -e '.[] | select(.title == "Settings")' >/dev/null; then
trap - EXIT
printf 'settings window contract: PASS\n'
exit 0