Panama had grown into three configuration surfaces that only agreed because they had been typed to agree: looks.lua hardcoded values, DesktopPreferences independently defaulted the same values, and SystemSettings replayed them at startup. Nothing kept them in sync, and the Lua side read no shared state at all. This lands the first three stages of docs/superpowers/plans/2026-08-17-panama-cohesion.md. Fix silently failing Hyprland writes. On a Lua-configured Hyprland, hyprctl keyword refuses the write, prints the refusal to stdout, and still exits 0, so the HDR, VRR, and direct-scanout toggles persisted their value and reported success while the compositor never changed. Writes now go through hyprctl eval, which has the same hazard on syntax and runtime errors, so success is defined as reading the value back and finding it equal. The existing contract passed throughout the outage because it re-applied the values already in place; the new one flips each value to something it does not hold. Derive preferences from a schema. Every setting used to be restated four times -- a property alias, a JSON adapter property, a change handler, and a line in reset -- where omitting any one failed silently. PreferenceSchema.qml is now the single source, and persistence, validation, reset, and the Hyprland mapping all derive from it. Unknown keys on disk survive a write so a rollback does not discard a newer build's settings, and a corrupt file falls back to shipped defaults. The store moved to ~/.config/panama/settings.json, migrating from the old state directory without deleting it. Share that file with Hyprland. prefs.lua reads it at config time with every shipped literal kept as the fallback, so the config still stands alone. The Lua is the default, the JSON is the truth, and Settings is the editor. The compositor-adjustable surface goes from 3 keys to 23. Also fixes two test-hygiene bugs found by running the suite end to end for the first time: settings-pages-contract could see the window settings-window-contract leaves behind, and the new write contract was persisting its deliberately-wrong values into the user's real store. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
350 lines
15 KiB
Markdown
350 lines
15 KiB
Markdown
# Panama :: Hyprland
|
||
|
||
A Hyprland desktop built to reproduce the GNOME + Forge setup it replaces, so
|
||
that muscle memory transfers unchanged.
|
||
|
||
## The one thing to know first
|
||
|
||
**Hyprland 0.55 replaced hyprlang with Lua.** `hyprland.conf` still loads on
|
||
0.56 as a legacy fallback and is removed in 0.57. Everything here is Lua.
|
||
|
||
The authoritative option reference for the installed build is on disk:
|
||
|
||
| File | What it is |
|
||
|---|---|
|
||
| `/usr/share/hypr/stubs/hl.meta.lua` | Generated type stub — the exact option list for this build |
|
||
| `/usr/share/hypr/hyprland.lua` | Upstream example config |
|
||
|
||
Wire the stub into your editor with `.luarc.json` in this directory for
|
||
autocomplete.
|
||
|
||
**The Hypr ecosystem tools did NOT move to Lua.** `hyprlock.conf`,
|
||
`hypridle.conf`, `hyprpaper.conf` and `hyprtoolkit.conf` are still hyprlang.
|
||
Don't "fix" them.
|
||
|
||
## Layout
|
||
|
||
| File | Contents |
|
||
|---|---|
|
||
| `hyprland.lua` | Entry point. Each `require()` is its own error scope |
|
||
| `prefs.lua` | Reads the settings file Panama Settings 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 |
|
||
| `input.lua` | Keyboard/mouse. Click-to-focus, like GNOME |
|
||
| `rules.lua` | Window rules, gaming rules, layer rules for the shell |
|
||
| `keybinds.lua` | The full keymap |
|
||
| `autostart.lua` | Session startup |
|
||
| `overrides.lua` | Per-machine escape hatch, loaded last |
|
||
| `hyprlock.conf` / `hypridle.conf` / `hyprpaper.conf` / `hyprtoolkit.conf` | Ecosystem tools (hyprlang) |
|
||
|
||
Validate any change without leaving your session:
|
||
|
||
```sh
|
||
Hyprland --verify-config
|
||
```
|
||
|
||
## Settings: one file, both sides
|
||
|
||
`~/.config/panama/settings.json` is shared with the Quickshell side. The
|
||
relationship is:
|
||
|
||
- **This config is the default.** Every adjustable value is written
|
||
`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
|
||
live, so nothing needs a reload and the two sides cannot drift apart.
|
||
|
||
To add an adjustable setting: add an entry to
|
||
`quickshell/config/PreferenceSchema.qml` with a `hypr` block naming the
|
||
`hl.config` path, then read it here with `prefs.get`. Nothing else is needed —
|
||
persistence, validation, reset, and the live write are all derived from that
|
||
entry.
|
||
|
||
`prefs.lua` never raises. A missing, empty, truncated, malformed, or
|
||
wrong-typed settings file costs you your customisations and nothing else;
|
||
`tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still
|
||
accepts the config in each of those states.
|
||
|
||
### Never use `hyprctl keyword`
|
||
|
||
On a Lua-configured Hyprland it refuses the write, prints
|
||
`keyword can't work with non-legacy parsers` to **stdout**, and still **exits 0**:
|
||
|
||
```sh
|
||
$ hyprctl getoption decoration:rounding -j # → "int": 18
|
||
$ hyprctl keyword decoration:rounding 4 # → the refusal above
|
||
$ echo $? # → 0
|
||
$ hyprctl getoption decoration:rounding -j # → "int": 18, unchanged
|
||
```
|
||
|
||
Use `hyprctl eval 'hl.config({ ... })'` instead. Note that `eval` *also* exits 0
|
||
on syntax and runtime errors, reporting them as an `error:` line on stdout — so
|
||
for either command, the only trustworthy signal that a write landed is reading
|
||
the value back with `hyprctl getoption`.
|
||
|
||
## The look
|
||
|
||
Tokyo Night Moon, with two accents that come from the tmux theme:
|
||
|
||
| Token | Value | Role |
|
||
|---|---|---|
|
||
| `accent` | `#82aaff` | Carries every state meaning — focused, active, on |
|
||
| `accentSecondary` | `#b172b0` | Never used alone; only the far end of a gradient |
|
||
|
||
The signature is **the prism**: the two accents meeting. It appears in exactly
|
||
four places, and nowhere else —
|
||
|
||
1. A one-pixel hairline along the top edge of every glass surface (the bar,
|
||
dock, popovers), running blue on the left to orchid on the right and fading
|
||
out before the corners. See `quickshell/widgets/PrismEdge.qml`.
|
||
2. The focused window's border — `general.col.active_border`, blue→orchid at
|
||
115°. Unfocused windows get no colour at all, because the gradient only
|
||
means something if one window on screen is wearing it.
|
||
3. The active workspace pill in the bar.
|
||
4. Slider fills.
|
||
|
||
The restraint is the point. The pink stops being special the moment it's used
|
||
as a flat fill, so it never is.
|
||
|
||
### Typography
|
||
|
||
**Adwaita Sans for everything the user reads as text — no monospace in the UI.**
|
||
A monospaced clock or percentage reads as terminal output pasted into a panel,
|
||
which is the opposite of the intent.
|
||
|
||
The Nerd Font is still used, but only to draw **icon glyphs** — it is the
|
||
pragmatic alternative to freedesktop symbolic icons, which ship with a
|
||
hardcoded `#2e3436` fill that Qt (unlike GTK) will not recolour. Where a
|
||
themed freedesktop icon is wanted instead, `quickshell/widgets/ThemedIcon.qml`
|
||
paints a palette colour through the icon's alpha.
|
||
|
||
Anything whose digits change in place — the clock, the vitals percentages, the
|
||
recording timer, the selection readout — sets `font.features:
|
||
Theme.tabularFigures`. Tabular figures share one advance width, so the text
|
||
stops twitching as numbers tick without reaching for a monospaced face.
|
||
|
||
Window rounding is 18, matching the shell's popover radius, so a window and a
|
||
panel next to each other read as the same family of object.
|
||
|
||
**On animated gradient borders:** Hyprland can rotate the border gradient
|
||
continuously with `borderangle` + `style = "loop"`. Don't. The wiki is explicit
|
||
that it forces a full-refresh-rate repaint forever, even when no border is
|
||
visible — at 4500×3000 that is pure idle GPU burn. This config uses
|
||
`style = "once"`, so the gradient sweeps into place when a window takes focus
|
||
and then costs nothing.
|
||
|
||
## Session: use the uwsm one
|
||
|
||
Log in as **"Hyprland (uwsm-managed)"**, not plain "Hyprland".
|
||
|
||
`xdg-desktop-portal-hyprland`'s systemd unit requires `graphical-session.target`,
|
||
and Fedora ships no `hyprland-session.target`. Without uwsm that target never
|
||
activates and **screen sharing in OBS, Sunshine and Zoom silently fails.**
|
||
|
||
### The uwsm environment gotcha
|
||
|
||
`hl.env()` in `env.lua` exports into the *compositor's* environment, **not** the
|
||
systemd user manager. Anything started as a user unit — `vicinae.service`,
|
||
`hyprpaper.service`, `hyprpolkitagent.service`, `hypridle.service` — and every
|
||
app those launch will not see it.
|
||
|
||
That is why `~/.config/uwsm/env` and `~/.config/uwsm/env-hyprland` exist and
|
||
duplicate the important variables. Keep them in sync with `env.lua`; `env.lua`
|
||
remains the fallback for the plain session.
|
||
|
||
## HDR — read this before turning it on
|
||
|
||
GNOME ran this panel in bt2100/HDR. This config deliberately does not.
|
||
|
||
On Hyprland 0.56.x, `cm = "hdr"` currently breaks **screencopy**: `grim`
|
||
screenshots come back empty, and OBS/Sunshine capture and hyprlock's blurred
|
||
background go with them. Root cause is still open upstream.
|
||
|
||
So the desktop runs SDR at 10-bit (`cm = "auto"`) and HDR is handed to
|
||
fullscreen games only, via `render.cm_auto_hdr = 1` in `looks.lua`. Games get
|
||
HDR; screenshots keep working.
|
||
|
||
To try full-time HDR anyway, `overrides.lua` has the block and the ordered list
|
||
of workarounds to reach for when things break.
|
||
|
||
## Keymap
|
||
|
||
The mental model is unchanged from Forge:
|
||
|
||
- **SUPER** acts on windows
|
||
- **ALT** acts on workspaces
|
||
- **SUPER + CTRL** changes layout structure
|
||
|
||
### Windows
|
||
| Key | Action |
|
||
|---|---|
|
||
| `SUPER + H/J/K/L` (or arrows) | Focus |
|
||
| `SUPER + SHIFT + H/J/K/L` | Move window |
|
||
| `SUPER + CTRL + H/J/K/L` | Swap window |
|
||
| `SUPER + SHIFT + Y/O` · `B/M` | Wider · narrower |
|
||
| `SUPER + SHIFT + I/U` · `P/N` | Taller · shorter |
|
||
| `SUPER + [` / `]` / `=` | Shrink / expand / reset split |
|
||
| `SUPER + Q` | Close |
|
||
| `SUPER + U` | Fullscreen |
|
||
| `SUPER + CTRL + C` | Toggle float |
|
||
| `SUPER + CTRL + SHIFT + C` | Pin (nearest thing to Forge's "always float") |
|
||
| `SUPER + CTRL + G` / `Z` / `V` | Toggle split / preselect right / preselect down |
|
||
| `SUPER + Tab` / `SHIFT + Tab` | Cycle windows |
|
||
| `SUPER + SHIFT + grave` | Last window |
|
||
| `SUPER + X` / `SUPER + SHIFT + X` | Restore scratchpad / minimise to scratchpad |
|
||
|
||
### Workspaces (dynamic, like GNOME)
|
||
| Key | Action |
|
||
|---|---|
|
||
| `ALT + H` / `ALT + L` | Previous / next workspace |
|
||
| `ALT + SHIFT + H/L` | Move window to previous / next |
|
||
| `ALT + 1..9`, `ALT + 0` | Jump to workspace |
|
||
| `ALT + SHIFT + 1..9` | Send window to workspace |
|
||
| `SUPER + scroll` | Change workspace |
|
||
|
||
### Launcher and shell
|
||
| Key | Action |
|
||
|---|---|
|
||
| `SUPER + A` / `R` / `Space` | Launcher (vicinae) — all three, pick your favourite |
|
||
| `SUPER + SHIFT + R` | Fallback launcher (wofi) if the shell is broken |
|
||
| `SUPER + V` | Clipboard history |
|
||
| `SUPER + .` | Emoji picker |
|
||
| `SUPER + S` | Quick settings |
|
||
| `SUPER + I` | Panama Settings |
|
||
| `SUPER + SHIFT + F` | Start or reveal focus session |
|
||
| `SUPER + B` | Notification centre |
|
||
| `SUPER + grave` | Workspace overview |
|
||
| `Print` | Screenshot / record picker |
|
||
| `SHIFT` / `ALT + Print` | Screenshot screen / window immediately |
|
||
| `SUPER + SHIFT + S` | Screen Intelligence — read text and codes from a selection |
|
||
| `SUPER + SHIFT + P` | Colour picker |
|
||
| `CTRL + ALT + L` | Lock (SUPER+L is "focus right") |
|
||
| `CTRL + ALT + Delete` | Power menu |
|
||
|
||
### Apps
|
||
`SUPER + T` terminal · `N` neovim · `W` browser · `F` files · `C` calculator ·
|
||
`E` mail · `I` Panama Settings · `CTRL + SHIFT + Esc` system monitor. GNOME
|
||
Settings remains searchable in Vicinae for hardware and account panels.
|
||
|
||
## Calendar and notifications
|
||
|
||
Click the clock to open the two-column Daybook on **Agenda**. The month grid,
|
||
today's schedule, Up Next, weather, and conditional media controls stay visible
|
||
together. Its right pane has three first-class pages:
|
||
|
||
- **Agenda** — the selected day's calendar.
|
||
- **Ongoing** — Focus, Caffeine, recording, screen sharing, camera, and
|
||
microphone activity, visible until each state ends.
|
||
- **Notifications** — grouped history, Do Not Disturb, and clear actions.
|
||
|
||
`SUPER + B` opens Notifications directly. Switching pages preserves the
|
||
selected date and visible month. Brief completed events such as screenshots and
|
||
device changes remain in Signal Glass instead of accumulating in Ongoing.
|
||
|
||
Calendar data comes from Evolution Data Server, so the Google, iCloud,
|
||
Nextcloud, and local calendars already configured for GNOME remain the source
|
||
of truth. Panama never stores account credentials. Use GNOME Calendar or Online
|
||
Accounts to add and manage accounts, and click an event in Daybook to hand off
|
||
editing and full detail to GNOME Calendar.
|
||
|
||
The right side of the bar shows a small calendar capsule only from 15 minutes
|
||
before a timed event until 5 minutes after it starts. It displays relative time
|
||
(`12m` or `Now`); the event title is available on hover but otherwise stays out
|
||
of the bar. All-day events never trigger the capsule.
|
||
|
||
## Control Center
|
||
|
||
Click the right-side status cluster or press `SUPER + S` for Panama's Control
|
||
Center. It is attached directly beneath the bar and keeps Wi-Fi, Bluetooth,
|
||
Caffeine, Night Light, Focus, audio input/output, user, settings, and power in
|
||
one place. Home and Phone continue the same surface rather than opening extra
|
||
dashboard windows.
|
||
|
||
Home shows the first four configured Home Assistant favourites as direct
|
||
controls and expands to the complete configured list. Panama reads the current
|
||
GNOME Home Assistant extension configuration and its Secret Service token as a
|
||
compatibility fallback, so the existing setup works without copying a secret.
|
||
The preferred private configuration lives in the gitignored
|
||
`config/bash/env` file:
|
||
|
||
```sh
|
||
export PANAMA_HOME_ASSISTANT_URL=https://home.example.test
|
||
export PANAMA_HOME_ASSISTANT_TOKEN=replace-with-a-long-lived-token
|
||
export PANAMA_HOME_ASSISTANT_ENTITIES=light.kitchen,light.living_room
|
||
```
|
||
|
||
The helper reads that file directly. No shell restart is required after editing
|
||
it; close and reopen Control Center to refresh. If Home Assistant is offline,
|
||
the last known values stay visible with a stale-state label and Retry action.
|
||
|
||
Phone uses KDE Connect for the capabilities the paired iPhone actually
|
||
advertises: Send File, Send Clipboard, and Ring. The device remains visible
|
||
while iOS suspends KDE Connect, but actions stay disabled until it reconnects.
|
||
No battery percentage is invented when iOS reports none, and BlueBubbles
|
||
remains the messaging experience. Active file sends appear in Daybook's
|
||
Ongoing page; successful sends become a quiet recent exchange.
|
||
|
||
## Screen Intelligence
|
||
|
||
`SUPER + SHIFT + S` opens the capture picker directly in Selection + Read
|
||
mode. Read is also available beside Screenshot and Record under `Print`, and
|
||
works on a whole display, a window, or a region. Tesseract recognizes English
|
||
text locally; ZBar recognizes QR codes and barcodes. The result sheet can copy
|
||
text, search it, translate it, or open a detected web address. No pixels leave
|
||
the workstation unless one of those explicit network actions is selected.
|
||
|
||
## Deliberate deviations from GNOME
|
||
|
||
These are the places a 1:1 port was impossible, and what was done instead:
|
||
|
||
- **Per-edge resize.** Forge resized one named edge; Hyprland resizes along an
|
||
axis and lets the layout pick the edge. The eight Forge keys collapse to four
|
||
behaviours, keeping the horizontal/vertical and grow/shrink pairing.
|
||
- **Overview on `SUPER + grave`, not a bare SUPER tap.** Tap-detection on a
|
||
modifier misfires when you're quick with SUPER combos.
|
||
- **Lock on `CTRL + ALT + L`.** `SUPER + L` is "focus right" in this keymap.
|
||
- **`SUPER + grave` was Forge's "cycle windows of same app"**, which Hyprland
|
||
has no equivalent for. "Last window" moved to `SUPER + SHIFT + grave`.
|
||
- **`gnome-control-center` is launched with `XDG_CURRENT_DESKTOP=GNOME`**,
|
||
because it hard-refuses to start otherwise. Panels backed by system services
|
||
work; panels backed by GNOME Shell (Displays, Keyboard Shortcuts,
|
||
Multitasking, Appearance) are inert — Hyprland owns those now.
|
||
|
||
## Gaming
|
||
|
||
`content = "game"` on the window rules in `rules.lua` is the keystone — it is
|
||
what `misc.vrr = 3`, `render.direct_scanout = 2` and `cursor.no_break_fs_vrr = 2`
|
||
key off. Games additionally get blur, animation, shadow and dim disabled, plus
|
||
`immediate` for tearing.
|
||
|
||
Tearing only takes effect when the game is fullscreen and the only thing on
|
||
screen — no bar, no notifications.
|
||
|
||
## Do not install a notification daemon
|
||
|
||
`mako`, `dunst` and `SwayNotificationCenter` all register
|
||
`Name=org.freedesktop.Notifications` for D-Bus activation. Installing any of
|
||
them creates a startup race against Quickshell's notification server, and
|
||
whoever wins keeps the name. If one gets installed as a dependency,
|
||
`systemctl --user mask <name>.service`.
|
||
|
||
## Troubleshooting
|
||
|
||
**Nothing autostarts** — check `systemctl --user status hyprland-session.target`
|
||
and `systemctl --user show-environment | grep WAYLAND_DISPLAY`. An empty
|
||
environment means uwsm didn't export it and units with
|
||
`ConditionEnvironment=WAYLAND_DISPLAY` silently skip.
|
||
|
||
**Screen sharing missing** — `busctl --user list | grep impl.portal` should show
|
||
`xdg-desktop-portal-hyprland`.
|
||
|
||
**Qt apps look wrong** — `QT_QPA_PLATFORMTHEME` must be `gtk3`. It is a single
|
||
value, not a list: Qt splits on `:` and uses only the first token.
|
||
|
||
**Wrong GPU / won't start** — `AQ_DRM_DEVICES` points at
|
||
`/dev/dri/amd-dgpu`, created by `config/copy/etc/udev/rules.d/99-panama-gpu.rules`.
|
||
Both `env.lua` and `uwsm/env-hyprland` guard on the symlink existing, so a
|
||
missing rule degrades to "let aquamarine choose" rather than failing to start.
|