colour -> color, behaviour -> behavior, centre -> center, favourite -> favorite, and about twenty other pairs, applied consistently across comments, docs, error/UI copy, and a handful of QML identifiers that used the British spelling as their actual name: SystemSettings' serialiseValue/serialiseTable/normaliseGradient, Displays' normaliseModes, Wallpaper's normalisePolicy, SettingsBackup's serialiseHomeState, DateTime's ntpSynchronised property, Clipboard's _normalise helper, and ShortcutCapture's cancelled signal (with its onCancelled handler in ShortcutsPage.qml). Every call site and the two tests that assert on the literal source text (settings-ownership and settings-backup-live contracts) were updated in lockstep. Left untouched: config/dot/espanso/match/packages/misspell-en/ is a vendored third-party autocorrect dictionary -- its entries are typo corrections, not our prose, and rewriting them would fight the package's own purpose (and any future re-sync from upstream). The already-American `favorites` property (Home page pinned accessories) was never actually misspelled -- only nearby comments and error strings said "favourites" -- so no data migration was needed there. Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
410 lines
18 KiB
Markdown
410 lines
18 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 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` | Colors, 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
|
||
```
|
||
|
||
## Generated configuration
|
||
|
||
Two ecosystem tools cannot read the shared settings file, and their configs live
|
||
in this directory — which is a symlink into the Panama repository, so writing to
|
||
them at runtime would put machine state into a tracked file. Both are therefore
|
||
generated elsewhere:
|
||
|
||
| Tool | Generated to | Pointed there by |
|
||
|---|---|---|
|
||
| `hypridle` | `$XDG_STATE_HOME/panama/hypridle.conf` | a systemd user drop-in installed by `panama-idle install` |
|
||
| `hyprpaper` | not generated — the wallpaper is applied over IPC and re-applied at shell start | — |
|
||
|
||
`quickshell/scripts/panama-idle` regenerates the hypridle config from the
|
||
settings store and restarts the daemon. `hypridle.conf` in this directory
|
||
remains the shipped default and is what runs when the drop-in is not installed;
|
||
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:
|
||
|
||
```sh
|
||
~/.config/quickshell/scripts/panama-idle remove
|
||
```
|
||
|
||
## 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.
|
||
- **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
|
||
`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 customizations and nothing else;
|
||
`tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still
|
||
accepts the config in each of those states.
|
||
|
||
### Adding a keybind: use `bind`, not `hl.bind`
|
||
|
||
Every bind in `keybinds.lua` goes through a local `bind()` wrapper that
|
||
substitutes the chord from a stored override, so shortcuts can be moved from
|
||
the Settings app without editing this file.
|
||
|
||
```lua
|
||
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
||
```
|
||
|
||
Only the **chord** is ever taken from settings — the action is always the Lua
|
||
value written here. A stored override can therefore move a shortcut but can
|
||
never make one do something else, which is what makes reading overrides from a
|
||
file the user can edit safe.
|
||
|
||
Overrides are keyed by the **shipped chord**, not the description. Descriptions
|
||
are not unique: "Calculator" is both `SUPER + C` and the `XF86Calculator`
|
||
hardware key, and keying by description moved both onto the same new chord,
|
||
silently costing the hardware key.
|
||
|
||
An override whose value is not a plausible chord is ignored in favour of the
|
||
shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
|
||
|
||
### Keybind descriptions are required
|
||
|
||
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
|
||
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
|
||
one has nothing readable beside its chord, and 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.
|
||
|
||
```lua
|
||
hl.bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
||
```
|
||
|
||
The page groups shortcuts by what the description says they do, so a new bind
|
||
lands in the right section with no change to the UI.
|
||
|
||
### Never use `hyprctl keyword`
|
||
|
||
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 color 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 recolor. Where a
|
||
themed freedesktop icon is wanted instead, `quickshell/widgets/ThemedIcon.qml`
|
||
paints a palette color 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` · `,/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 / minimize 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 favorite |
|
||
| `SUPER + SHIFT + R` | Fallback launcher (wofi) if the shell is broken |
|
||
| `SUPER + V` | Clipboard history |
|
||
| `SUPER + .` | Emoji picker |
|
||
| `SUPER + S` | Quick settings |
|
||
| `SUPER + I` | Settings |
|
||
| `SUPER + SHIFT + F` | Start or reveal focus session |
|
||
| `SUPER + B` | Notification center |
|
||
| `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` | Color 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` 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 selected favorites at rest and every selected light
|
||
when expanded. Use **Settings → Home & Phone** to choose favorites,
|
||
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
|
||
level.
|
||
|
||
Credentials stay private in the gitignored `config/bash/env` file, with the
|
||
existing GNOME extension and Secret Service setup retained as a compatibility
|
||
fallback. Favorites, aliases, and order live in Quickshell state. No shell
|
||
restart is required after changing credentials; 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 those actions stay disabled until it
|
||
reconnects. Messages opens BlueBubbles independently of KDE Connect. No battery
|
||
percentage is invented when iOS reports none. 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
|
||
behaviors, 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.
|