Files
Panama/config/dot/quickshell/modules/settings/README.md
T

429 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Settings
The control center for everything Panama owns. Anything the system owns —
hardware, accounts, printers — is delegated to GNOME Settings and labeled as
such rather than half-reimplemented.
## Navigation
The sidebar lists fourteen **categories**, not one row per page. A category
covering several subjects — Home, Input, Network & Sharing, Applications,
Users & Accounts, Privacy & Security, System — draws a tab strip above the
page, and each of its **leaf** pages is one tab; a category with a single
subject is a leaf itself and shows no strip. The column used to be a flat list
of thirty-one pages, which made finding Printers a scan of the whole thing.
Grouped, it sits under Network & Sharing, where somebody looking for it already
expects it.
**Home** carries three tabs: **Overview** (`home`, the quick-action tiles,
findings, glance cards, Today and Weather that the shell opens by default),
**My Home** (`my-home`, Home Assistant lights grouped by room, accessories, and
the connection card) and **Phone** (`phone`, vitals, ring, clipboard, send file,
and BlueBubbles Messages). It absorbed the former standalone Home & Phone
category, whose `home-phone` id is now retired.
Leaf pages are ordinary `SettingsPage` files and know nothing about this.
`SettingsShell` draws the strip and hosts the page inside it, so moving a page
between categories never touches the page.
`services/SettingsRoutes.qml` is the one place the taxonomy is written down.
The sidebar, the tab strip, `ShellState`'s route validation, the breadcrumb
subtitles on search results ("System Storage"), and
`scripts/panama-settings-commands` all derive from it — so a page moves in one
edit rather than five, and none of them can disagree about where it lives.
`ShellState.settingsPage` still holds a **leaf** id, the same ids callers have
always used, so every existing deep link, IPC call, and search hit keeps
working and now lands on the exact tab. A category id is accepted too, and
resolves to that category's first available tab.
**A retired id never stops resolving.** When a page is split, merged, or
renamed, its old id goes into `SettingsRoutes.retired` — a map from the dead id
to the leaf that absorbed its content — rather than being deleted. Old Vicinae
commands, shell history, notification handoffs, and muscle memory all hold
those ids, and `resolve()` checks the map first, so `home-phone` still lands on
**My Home** instead of falling back to Home. Retired ids are aliases only: they
are not leaves, so nothing generates a command, a tab, or a doc heading for
them.
Availability gating belongs to the strip rather than the sidebar:
`SettingsRoutes.pageAvailable()` drops the Containers tab on a machine without
podman and Snapshots without a snapper configuration, but only once a scan has
proven the stack absent, so a machine that has it never sees the tab blink.
## System Health
The stable internal `services` route renders **System Health**, a tab of
**System**. It is reachable from there, from its live 54px footer, from the
degraded-only bar indicator, and from Vicinae's **Check System Health**
command. Healthy scans reserve no bar space and produce no notification.
`services/Health.qml` owns the last accepted redacted snapshot. It invokes
`scripts/panama-doctor` for scans and bounded repairs, `wl-copy` only for an
explicit **Copy Report**, and bounded `notify-send` only when an external repair
fails. For a concise terminal view, run:
```bash
~/.config/quickshell/scripts/panama-doctor --summary
```
The helper diagnoses Panama-owned desktop services, dependencies, links, and
configured integrations. It does not read secret values, clipboard or
notification contents, calendar events, SSIDs, addresses, or arbitrary command
output. Its repair interface is an authored allow-list: it never installs a
package, runs `sudo`, deletes user data, or repairs a service Panama does not
own. A repair remains degraded until a fresh scan observes recovery.
The final card is the ownership boundary. Network configuration and the exact
Users, Sharing, Color profiles, and Digital wellbeing handoffs open GNOME
Settings because Fedora's system services own those areas.
## Appearance
Five tabs, in the order the questions are actually asked: **Themes**, **Theme
editor**, **Background**, **Typography**, **Windows**. Themes leads because
light and dark is the control reached most often, and it used to be the third
section down, under a wallpaper grid and the whole lock screen. The old `theme`
section id still resolves to it, so deep links and IPC calls keep working.
The sixth tab, **Shell**, is gone: its two cards moved to **Shell Bar**, which
is where somebody who wants to change the bar now looks. Appearance answers what
the desktop looks like; Shell answers what the desktop *is*.
### Themes
`config/themes.json` is the catalog: ten shipped themes, each a full palette of
nineteen tokens plus sixteen ANSI colors. `services/ThemeCatalog.qml` reads it
with a `FileView` and carries Moon and Day as an embedded fallback, so the
shell renders correctly for the instant before the file loads and forever on a
machine where it is missing.
The tab is two galleries — dark and light — rather than a card each. Selection
is **per mode**: `themeDark` and `themeLight` remember what you chose on each
side, so flipping light and dark lands on your theme for that side rather than
resetting to the shipped defaults.
`services/ThemeProfiles.qml` resolves the active record; `Theme.qml` reads
every one of its nineteen color tokens from `ThemeProfiles.activePalette`.
There are no palette ternaries left in `Theme.qml`, which is what makes a
tenth theme a data change rather than a code change.
### Theme editor
Four wells — primary, secondary, background, foreground — a saturation slider,
an effects card, and the six HSV rows demoted to a fine-tune behind a
disclosure. Background and foreground are never stored alone: the five surfaces
and two text tints are mixed from them, so changing the ground moves the whole
family instead of leaving twelve tokens pointing at the old one.
Every route that writes color — typed hex, the colour wheel, the eyedropper,
the HSV sliders — ends in `ThemeProfiles.commitActive`, which also recomputes
`accentName` as the nearest curated accent. That is what keeps GNOME's accent
enum, kitty's border and the lock screen from going stale after a custom edit.
Editing a shipped theme forks it into a named custom carrying the whole
palette; saving snapshots the palette, the terminal colors, and all ten effect
values into the record.
The HSV rows commit on a **debounce**, not per move. Writing on every move
meant one drag across the hue row spent the whole gesture in apply-and-verify
round trips with the desktop repainting behind the pointer.
### Backgrounds, still and moving
Stills go through hyprpaper. Videos go through mpvpaper, supervised by
`services/VideoWallpaper.qml`, and Panama owns the pause policy rather than the
compositor: the video pauses whenever a game runs, on battery if the preference
says so, and whenever the bar pill is clicked — over mpv's JSON IPC socket, so
resuming does not restart the clip. hyprpaper's service is stopped while a
video plays, because both claim the background layer and stacking within a
layer is creation order. The lock screen gets a cached still frame; hyprlock
cannot play motion, and pretending otherwise would show a black screen.
### The honest titlebar
There is no minimize or maximize control, and no setting for one. Hyprland has
no minimize — it receives the request and does nothing with it — and maximize
is noise in a tiler, so `DesktopStyle` pushes a close-only GNOME button layout
and Panama's own Settings titlebar shows one button. `panamaTitlebar` turns
that bar off entirely, leaving the window pure Hyprland: Super+Q closes,
Super+drag moves, Escape still works.
## Shell
Everything Quickshell itself draws, in five tabs: **Bar**, **Dock**, **Control
Center**, **Tiling**, **Workspaces**. The category used to be **Desktop & Dock**,
a single page that held the dock alongside window layout, workspaces, and the
settings-management cluster — three unrelated subjects and a filing cabinet. Its
`desktop` id is retired to `bar`, so old deep links land on the first tab.
The split is by *surface*. If you can point at it on screen, it has a tab.
### Bar
The first settings surface the bar has ever had. Three cards.
**Visibility** exists because the bar is the one thing the shell draws that has
no ground of its own — it floats on the wallpaper, which the theme has never
seen. So a palette that is correct everywhere else can still be unreadable
exactly where the clock is. `barTextTone` (`theme`/`light`/`dark`) drives a
second neutral family in `Theme.qml``barFg`, `barFgDim`, `barFgMuted` — that
thirteen bar widgets bind to instead of `fg`/`fgDim`/`fgMuted`. Left on `theme`
the family *is* the fg family by identity, so nothing changes for anyone who
never asks, and a custom palette still reaches the bar. Semantic tones (warn,
danger, accent, ok) never became bar tones: a battery at 4% is red whatever the
neutrals were forced to.
`barTextShadow` and `barBackdrop` are the two escape hatches for a wallpaper no
tone wins against. The shadow is **one** `layer.effect` MultiEffect over the
whole of `Bar.qml`'s content rather than one per widget, so a widget added
tomorrow picks it up without opting in; the backdrop is a static top-down
gradient Rectangle behind the content rows. Both default off and both are read
from the preference — during the build they briefly landed as literal `true`,
which forced a dark band and an extra compositing layer on everyone.
`tests/quickshell/bar-visibility-contract` pins both bindings for that reason,
along with every widget's use of the bar tones.
**Widgets** is one toggle per thing that earns a place: weather, media,
clipboard, calendar countdown, the three vitals readouts, battery, agent usage.
Each toggle is **ANDed** with the widget's own state condition rather than
replacing it, so switching one on never conjures a pill with nothing in it.
State-driven indicators — health, activity, focus, video wallpaper — get no
toggle: they appear when they have something to say and leave when they are
done. `VitalsWidget` answers for its own pill as well as its three fields,
because an invisible child still occupies its Row and gating the fields alone
left a padded, empty pill in the bar.
**Clock** keeps `showSeconds` and `showWeekday`. `use24Hour` is *not* here: it
drives the date menu, notification timestamps, and the lock screen too, so it
moved to group `datetime` and lives on **System Date & Time**. The card says
so rather than leaving its absence looking like an oversight.
Right-clicking the vitals or agent-usage pill opens this page.
### Dock
Behavior — position, screens, autohide, reveal and hide delays, icon size —
unchanged. **Pinned applications** is now `DockPinsStrip.qml`: the dock's own
row of icons, resolved exactly the way the dock resolves them, drag to reorder,
hover for the unpin ×, `DockAppPicker` search underneath. It replaces a sixteen-
row list of names, which was honest but was not the thing being edited — the
order of a horizontal row of icons was being decided in a vertical column of
text.
Two details make the drag safe: `preventStealing` on the grabbing MouseArea, or
the page's Flickable claims the gesture and the icon never moves; and the order
is held in the strip while the drag runs and written **once** on release, rather
than rewriting `settings.json` a dozen times for one gesture. Every icon is also
a tab stop — Left and Right move it, Delete unpins it — because a drag is not
reachable from the keyboard.
The live dock gained the matching gestures: drag to reorder on the dock itself,
a right-click menu (window rows, pin, unpin, quit, new window, and a **Dock
settings** entry that deep-links here), scroll an icon to cycle its windows, and
hover previews from `DockPreviews.qml`. `DockPickOverlay.qml` plus the `dock`
IPC target back the launcher's **Add App to Dock** command, so pinning never
requires opening Settings at all.
### Control Center
The quick settings panel's first settings surface. **Sections** is one
`ccShow*` bool per real section of `QuickSettingsPanel` — Focus, Home, Phone —
and only for sections that actually exist; a toggle for a section the panel does
not draw is a control that writes a preference nothing reads. Turning one off
hides it from the panel and stops nothing: the page behind it still works.
**Accessories** is a labeled handoff to Home My Home rather than a second copy
of the shelf editor.
### Tiling and Workspaces
Rows moved unchanged. Tiling holds window layout (with the handoff to Appearance
Windows for gaps, corners, and effects), master and stack, window edges, and
the Hyprland notices. Workspaces holds the workspace and focus toggles, focus
session length, and Projects.
## System Sync & Backup
`SyncPage.qml`, the tenth tab of System: **Carry settings to another machine**
(`SettingsSync`), **Settings backups** (`SettingsBackup`), and **Reset**. It was
the settings-management cluster on the old Desktop page, which is the one thing
on that page that was never a surface. Backups is titled that way deliberately
so it stops colliding with the btrfs **Snapshots** tab one along — the two mean
different things and used to share a word. The search index's **Restore
defaults** entry points here.
## Adding a setting
One schema entry. That is the whole job.
```qml
// config/PreferenceSchema.qml
{
key: "blurSize", type: "int", def: 8, min: 1, max: 20, step: 1,
unit: "px", group: "effects",
label: "Blur radius",
detail: "Larger is softer and costs more frame time",
hypr: { path: ["decoration", "blur", "size"], option: "decoration:blur:size", readAs: "int" }
}
```
```qml
// the page
SliderRow { setting: "blurSize" }
```
Persistence, validation, clamping, reset, search indexing, and — with a `hypr`
block — live application to the compositor and the startup replay all derive
from that entry. There is nothing else to register.
If it is compositor-backed, add the matching `prefs.get("blurSize", 8)` in
`hypr/looks.lua` so the Hyprland config still stands alone with no settings
file.
## Setting ownership
Every preference has **one primary page**, derived from its schema `group` and
the route in `services/SettingsSearch.qml`. Search results always open that
owner. A control may appear on a second page only when the same adaptation is
part of another established mental model; otherwise use a labeled handoff to
the owner instead of duplicating it.
### Intentional mirrors
| Setting | Primary page | Mirror | Why the mirror earns its place |
|---|---|---|---|
| `animationsEnabled` | Appearance | Accessibility | Reduced motion belongs both to visual polish and motion accessibility. |
| `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behavior but affects motor and visual access. |
| `cursorSize` | Accessibility | Mouse | Large cursors are an accessibility adaptation that users also look for beside pointer controls. |
| `inactiveOpacity` | Appearance | Accessibility | Window translucency is an appearance choice with a direct readability impact. |
Lock-screen visuals belong only to **Appearance**: background source, blur,
clock, date, user name, and password-field presentation. **Power** owns when
the session locks — the timings and the suspend transition both, with no mirror
anywhere. **Privacy** carried a second Screen-lock card until it was removed:
two sliders writing one preference is not a convenience, it is a page where the
number you are reading may not be the one you last set. Privacy points at Power
& Lock instead. Visual controls must not be copied onto either page.
**Displays** is the sole owner of mode, scale, rotation, arrangement, and primary role.
Those values form one safety transaction: every connected output
is applied, verified, confirmed, or restored together. Other pages may link to
Displays, but must never expose a second geometry control or persist a partial
layout.
Mirrors must remain the same schema-backed control, never a second preference
or a copied default. Additions to this table require a concrete discoverability
reason and an update to `tests/quickshell/settings-ownership-contract`.
Window border color follows the same ownership rule. The inactive border is a
neutral contrast role — a **scheme-relative role** in the sense that it exists
to stay legible on either ground — owned by `ColorScheme.qml`, and it is drawn
from the active theme's `gutter` rather than from a hardcoded pair. The two
literals that used to live there were correct for two of the ten shipped themes
and for no custom one. The focused Prism border is the **accent role**, driven
by the chosen `accentName` and also owned by `ColorScheme.qml`: each accent
carries a separate pair for light and dark. Both roles are restated on a scheme
change, an accent change, and a theme change, rather than leaving a flip to
erase a user-selected accent or a theme switch to leave the border behind.
## The rows
| Component | For |
|---|---|
| `SettingsPage` | The page scaffold: title, lede, optional pinned `header` |
| `ToggleRow { setting }` | A boolean |
| `SliderRow { setting }` | A number; `zeroLabel` renders 0 as "Never"/"Instant"/"None" |
| `ChoiceRow { setting }` | An enum, as a segmented control |
| `ActionRow` | A button: opens a GNOME panel, runs a one-shot |
| `TextRow` | A genuinely read-only fact |
| `SettingsNote` | An inset paragraph: the explanation too long to be a `detail` |
`SettingsNote` is deliberately quiet and must stay that way. It explains
absences and boundaries — what this session cannot do, and why — and a
warning-coloured box would turn "not offered yet, here is the reason" into
"something is wrong here".
### The rows are keyboard-reachable
Every row primitive carries `Accessible.role`, an `Accessible.name` taken from
its label, and an `Accessible.description` taken from its detail; the
interactive element sets `activeFocusOnTab` and draws a two-pixel
`Theme.accentSecondary` ring **only** while it holds focus. Tab walks the page,
Space and Enter flip a switch or press a button, and Left/Right (and Up/Down)
step a slider by one schema step or move a segmented choice by one option,
never wrapping.
Two rules hold this together. An inert row is not a Tab stop: `SettingRow`
becomes focusable only when `activatable` is true, because Tab landing on
static text is how keyboard navigation stops being usable. And a keyboard
change commits through exactly the same path a pointer change does —
`SliderRow`'s arrows go through the same 140 ms debounce as a drag, so a held
arrow behaves like a drag rather than a burst of writes the compositor spends
the whole time rejecting.
`TextRow` is for facts, not for settings that were merely expensive to wire.
Before Stage 3 more than half of all rows were static text standing in for
controls; that is the failure this vocabulary exists to prevent.
Rows write through `SystemSettings.commitPreference(key, value)`, which routes
compositor-backed keys through apply-and-verify and local keys straight to the
store. A row never needs to know which kind it holds.
## Things that will bite you
**`readAs` describes the answer, not the setting.** `hyprctl getoption` returns
the value in a different JSON field per type — `int`, `bool`, `float`, `str`,
and `css` for gaps (a four-value box). Declaring the wrong one does not fail
loudly: it makes every write to that key look *rejected*, and the user sees an
error for a change that worked. `tests/quickshell/schema-hypr-shape-contract`
asks the compositor for the real shape of every mapped option.
**Never trust an exit code from `hyprctl`.** `keyword` refuses to work on a
Lua-configured Hyprland, prints the refusal to stdout, and exits 0. `eval` exits
0 on syntax and runtime errors too. The only trustworthy signal that a write
landed is reading the value back.
**The Settings window is tiled.** `implicitWidth` is a hint; the layout decides,
and it ranges from a half-screen split to the full display. `SliderRow` stacks
its control under the label below 520px. Test narrow.
**Binding an anchor to `undefined` does not reliably release it.** Switching
layouts that way left a slider anchored to both edges with the label squeezed
into what was left. Position explicitly instead.
**Inside a `SettingsCard`, `parent` is the card's internal Column.** So
`parent.modelData` in a nested `Repeater` is undefined and the rows silently
never appear — you get a card with a heading and nothing under it. Address the
outer model through an explicit `id`.
**A `TapHandler` declared as a child of `SettingRow` lands in the trailing
slot**, because that is the row's default property, so only the right-hand edge
becomes clickable. Use `activatable: true` with `onActivated` for a whole-row
target.
**A content-identical Quickshell entry can share the live shell's ID.**
Quickshell derives the Shell ID from config *content*, not path. Runtime
harnesses therefore create a distinct semantic entry file, address that exact
file with `qs -p`, and discover its PID from the exact Config path in
`qs list --all`. They terminate only that recorded PID with `kill`; never use
`qs kill` from a copied configuration.
## Where state lives
| File | Holds |
|---|---|
| `~/.config/panama/settings.json` | Everything in the schema. Read by the shell *and* by `hypr/prefs.lua` |
| `$XDG_STATE_HOME/panama/panama-home.json` | Home accessory favorites and aliases |
| `$XDG_STATE_HOME/panama/backups/` | Settings snapshots |
| `$XDG_STATE_HOME/panama/hypridle.conf` | Generated idle config |
| `$XDG_STATE_HOME/panama/hyprlock.conf` | Generated lock-screen appearance |
`SystemSettings.restoreDefaults()` spans all of them. A reset that silently
skipped one would be worse than having no reset, because nothing would say so.
## Not stored by Panama
Timezone and network time are read from and written to `timedatectl` directly.
They belong to the machine and are shared with sessions that never see Panama's
file; storing a copy would create a second answer to a question the system
already answers.