Author SHA1 Message Date
Gabriel Brown 1360a80f07 Add a visible window switcher
Super+Tab already cycled windows, but nothing was drawn, so you chose
blind and could only confirm the choice by arriving. A visible switcher
is muscle memory for anyone arriving from macOS or GNOME, and it was the
last item of roadmap phase 03 that did not need coordination.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three of these are corrections rather than additions.

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 12:27:34 -04:00
49 changed files with 2361 additions and 107 deletions
+1
View File
@@ -21,3 +21,4 @@ __pycache__/
/config/dot/gtk-3.0/settings.ini /config/dot/gtk-3.0/settings.ini
/config/dot/gtk-4.0/settings.ini /config/dot/gtk-4.0/settings.ini
/config/dot/tmux/current-theme.conf /config/dot/tmux/current-theme.conf
/config/dot/hypr/hyprlock.conf
+3 -1
View File
@@ -9,6 +9,8 @@
-- instead. They still work here; see the session notes in autostart.lua. -- instead. They still work here; see the session notes in autostart.lua.
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
-- ── GPU selection ─────────────────────────────────────────────────────────── -- ── GPU selection ───────────────────────────────────────────────────────────
-- This box has a discrete RX 7800 XT (0000:03:00.0) and a Granite Ridge iGPU -- This box has a discrete RX 7800 XT (0000:03:00.0) and a Granite Ridge iGPU
-- (0000:12:00.0). The monitor hangs off the dGPU, so the dGPU must render. -- (0000:12:00.0). The monitor hangs off the dGPU, so the dGPU must render.
@@ -47,7 +49,7 @@ end
-- (it has cursors/ + index.theme and no hyprcursors/ directory or manifest.hl), -- (it has cursors/ + index.theme and no hyprcursors/ directory or manifest.hl),
-- so setting it would point hyprcursor at nothing. Hyprland falls back to the -- so setting it would point hyprcursor at nothing. Hyprland falls back to the
-- XCursor path, which is what we want. -- XCursor path, which is what we want.
hl.env("XCURSOR_THEME", "oreo_blue_cursors") hl.env("XCURSOR_THEME", prefs.get("cursorTheme", "oreo_blue_cursors"))
hl.env("XCURSOR_SIZE", "24") hl.env("XCURSOR_SIZE", "24")
-- ── Toolkits ──────────────────────────────────────────────────────────────── -- ── Toolkits ────────────────────────────────────────────────────────────────
@@ -1,8 +1,17 @@
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# hyprlock — lock screen # hyprlock — lock screen.
# #
# Tokyo Night Moon, matching quickshell/config/Theme.qml: # GENERATED FILE. Edit hyprlock.conf.template and re-run
# accent #82aaff fg #c8d3f5 dim #828bb8 bg #222436 red #ff757f # quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the
# next colour scheme change.
#
# The colours here follow the desktop's light/dark setting. They used to be
# hardcoded Tokyo Night Moon, which meant the one screen you see most often
# stayed dark when everything else went light.
#
# hyprlock takes rgba(r, g, b, a) in DECIMAL rather than hex, which is why the
# template carries "R, G, B" triples where the rest of Panama uses hex. The two
# Pango markup values are the exception and want ##rrggbb.
# #
# hyprlang syntax, not Lua — hyprlock is a separate project from Hyprland. # hyprlang syntax, not Lua — hyprlock is a separate project from Hyprland.
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -45,7 +54,7 @@ background {
vibrancy_darkness = 0.05 vibrancy_darkness = 0.05
# Shown if the screenshot is unavailable. # Shown if the screenshot is unavailable.
color = rgba(34, 36, 54, 1.0) color = rgba(@BG@, 1.0)
zindex = -1 zindex = -1
} }
@@ -54,7 +63,7 @@ background {
label { label {
monitor = monitor =
text = cmd[update:1000] date +"%-I:%M" text = cmd[update:1000] date +"%-I:%M"
color = rgba(200, 211, 245, 1.0) color = rgba(@FG@, 1.0)
font_size = 120 font_size = 120
font_family = Adwaita Sans Light font_family = Adwaita Sans Light
position = 0, 260 position = 0, 260
@@ -65,7 +74,7 @@ label {
label { label {
monitor = monitor =
text = cmd[update:60000] date +"%A, %B %-d" text = cmd[update:60000] date +"%A, %B %-d"
color = rgba(130, 139, 184, 1.0) color = rgba(@MUTED@, 1.0)
font_size = 24 font_size = 24
font_family = Adwaita Sans font_family = Adwaita Sans
position = 0, 160 position = 0, 160
@@ -84,18 +93,18 @@ input-field {
outline_thickness = 2 outline_thickness = 2
rounding = 26 rounding = 26
outer_color = rgba(130, 170, 255, 0.9) outer_color = rgba(@ACCENT@, 0.9)
inner_color = rgba(46, 47, 61, 0.85) inner_color = rgba(@FIELD@, 0.85)
font_color = rgba(200, 211, 245, 1.0) font_color = rgba(@FG@, 1.0)
check_color = rgba(130, 170, 255, 1.0) check_color = rgba(@ACCENT@, 1.0)
fail_color = rgba(255, 117, 127, 1.0) fail_color = rgba(@ERROR@, 1.0)
dots_size = 0.25 dots_size = 0.25
dots_spacing = 0.3 dots_spacing = 0.3
dots_center = true dots_center = true
placeholder_text = <span foreground="##828bb8"><i>Password</i></span> placeholder_text = <span foreground="##@MUTED_HEX@"><i>Password</i></span>
fail_text = <span foreground="##ff757f"><i>$FAIL ($ATTEMPTS)</i></span> fail_text = <span foreground="##@ERROR_HEX@"><i>$FAIL ($ATTEMPTS)</i></span>
fade_on_empty = false fade_on_empty = false
hide_input = false hide_input = false
@@ -105,7 +114,7 @@ input-field {
label { label {
monitor = monitor =
text = $USER text = $USER
color = rgba(200, 211, 245, 0.9) color = rgba(@FG@, 0.9)
font_size = 16 font_size = 16
font_family = Adwaita Sans font_family = Adwaita Sans
position = 0, -110 position = 0, -110
+2 -2
View File
@@ -10,9 +10,9 @@ local prefs = require("prefs")
hl.config({ hl.config({
input = { input = {
kb_layout = prefs.get("keyboardLayout", "us"), kb_layout = prefs.get("keyboardLayout", "us"),
kb_variant = "", kb_variant = prefs.get("keyboardVariant", ""),
kb_model = "", kb_model = "",
kb_options = "", kb_options = prefs.get("keyboardOptions", "caps:escape_shifted_capslock"),
kb_rules = "", kb_rules = "",
numlock_by_default = prefs.get("numlockByDefault", true), numlock_by_default = prefs.get("numlockByDefault", true),
+15 -3
View File
@@ -195,9 +195,21 @@ bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = t
bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" }) bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" }) bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
-- Window cycling (GNOME: cycle-windows on SUPER+Tab). -- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" }) -- showing what you are choosing between.
bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" }) --
-- The gesture needs three binds, not two. Tab steps the selection, and the
-- switch is only COMMITTED when the modifier is released -- which is the sole
-- way the compositor can tell the gesture is finished. That release bind is on
-- the bare modifier, so it fires on EVERY Super release in the session; the
-- handler returns immediately when no switch is open, which is why this is
-- affordable.
--
-- The release bind carries no description on purpose: it is not a shortcut
-- anyone would look up or rebind, and the Shortcuts page lists what it finds.
bind(mod .. " + Tab", hl.dsp.exec_cmd(qs("switcher", "next")), { description = "Next window" })
bind(mod .. " + SHIFT + Tab", hl.dsp.exec_cmd(qs("switcher", "previous")), { description = "Previous window" })
bind(mod, hl.dsp.exec_cmd(qs("switcher", "commit")), { release = true, description = "Commit window switch" })
-- Jump back to the previously focused window. -- Jump back to the previously focused window.
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" }) bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
+49 -20
View File
@@ -22,21 +22,22 @@ hl.config({
border_size = prefs.get("borderSize", 2), border_size = prefs.get("borderSize", 2),
col = { col = {
-- The prism: blue leads, orchid follows, on a diagonal so the pair -- The focused accent role: blue leads, orchid follows, on a
-- is visible on both a tall and a wide window. Same two colours as -- diagonal so the pair is visible on both a tall and a wide
-- the shell's hairline (quickshell/widgets/PrismEdge.qml) and the -- window. ColorScheme never writes this role; a future accent
-- tmux theme this palette came from. -- picker can own it without fighting light/dark mode.
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 }, active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
-- Unfocused windows get no colour at all. The gradient only means -- The neutral inactive role follows the colour scheme because a
-- something if exactly one window on screen is wearing it. -- dark neutral disappears against a light desktop.
-- Follows the colour scheme: a dark neutral is invisible against a -- services/ColorScheme.qml applies the same values live; this is
-- light desktop. services/ColorScheme.qml applies changes live; -- the value a fresh session starts from.
-- this is the value a fresh session starts from.
inactive_border = prefs.get("colorScheme", "dark") == "light" inactive_border = prefs.get("colorScheme", "dark") == "light"
and "rgba(a8aecb99)" or "rgba(3b426199)", and "rgba(a8aecb99)" or "rgba(3b426199)",
}, },
resize_on_border = true, resize_on_border = prefs.get("resizeOnBorder", true),
extend_border_grab_area = prefs.getInt("borderGrabArea", 15),
hover_icon_on_border = prefs.get("hoverIconOnBorder", true),
-- Enables the per-window "immediate" rule used for games in rules.lua. -- Enables the per-window "immediate" rule used for games in rules.lua.
-- Harmless on its own; tearing only happens where a rule opts in. -- Harmless on its own; tearing only happens where a rule opts in.
@@ -44,16 +45,22 @@ hl.config({
layout = "dwindle", layout = "dwindle",
snap = { enabled = true }, snap = {
enabled = true,
window_gap = prefs.getInt("snapWindowGap", 10),
monitor_gap = prefs.getInt("snapMonitorGap", 10),
respect_gaps = prefs.get("snapRespectGaps", false),
},
}, },
decoration = { decoration = {
-- 18 to match the shell's popover radius, so a window and a panel sitting -- 18 to match the shell's popover radius, so a window and a panel sitting
-- next to each other read as the same object family. -- next to each other read as the same object family.
rounding = prefs.get("windowRounding", 18), rounding = prefs.get("windowRounding", 18),
rounding_power = 2, rounding_power = prefs.get("roundingPower", 2),
active_opacity = 1.0, active_opacity = prefs.get("activeOpacity", 1.0),
fullscreen_opacity = prefs.get("fullscreenOpacity", 1.0),
inactive_opacity = prefs.get("inactiveOpacity", 1.0), inactive_opacity = prefs.get("inactiveOpacity", 1.0),
blur = { blur = {
@@ -83,9 +90,13 @@ hl.config({
shadow = { shadow = {
enabled = prefs.get("shadowEnabled", true), enabled = prefs.get("shadowEnabled", true),
range = prefs.get("shadowRange", 20), range = prefs.get("shadowRange", 20),
render_power = 3, render_power = prefs.getInt("shadowRenderPower", 3),
sharp = false, sharp = prefs.get("shadowSharp", false),
color = "rgba(15161eee)", color = "rgba(15161eee)",
-- Deliberately not a setting: a two-axis offset needs a control we
-- do not have, and a slider bound to half a value is worse than
-- leaving it alone. SystemSettings understands the vec2 shape
-- already, so adding it later is only a matter of the widget.
offset = { 0, 4 }, offset = { 0, 4 },
scale = 1.0, scale = 1.0,
}, },
@@ -110,14 +121,32 @@ hl.config({
dwindle = { dwindle = {
-- Keep the split orientation a window was created with. Closest match -- Keep the split orientation a window was created with. Closest match
-- to how the Forge extension behaved on GNOME. -- to how the Forge extension behaved on GNOME.
preserve_split = true, preserve_split = prefs.get("preserveSplit", true),
smart_resizing = true, smart_resizing = true,
}, },
-- Only in effect when the tiling layout is "master". Panama ships dwindle,
-- but Settings offers master as a choice, and a layout you can select and
-- cannot configure is barely a choice at all.
master = {
mfact = prefs.get("masterFactor", 0.55),
orientation = prefs.get("masterOrientation", "left"),
new_status = prefs.get("masterNewStatus", "slave"),
new_on_top = prefs.get("masterNewOnTop", false),
},
misc = { misc = {
force_default_wallpaper = 0, force_default_wallpaper = 0,
disable_hyprland_logo = true, -- Stored as "show the logo / show the splash" and written as Hyprland's
disable_splash_rendering = true, -- `disable_*`, matching the `invert` flag on these entries in
-- PreferenceSchema so both sides agree about which way round they are.
disable_hyprland_logo = not prefs.get("hyprlandLogo", false),
disable_splash_rendering = not prefs.get("hyprlandSplash", false),
-- Keep native Wayland selection paste and GTK's matching preference
-- in lockstep. DesktopStyle applies the GTK half only after this value
-- has been read back and stored by SystemSettings.
middle_click_paste = prefs.get("middleClickPaste", true),
-- Same setting as Theme.fontFamily in the shell. If only the QML side -- Same setting as Theme.fontFamily in the shell. If only the QML side
-- followed the preference, the compositor and the shell would disagree -- followed the preference, the compositor and the shell would disagree
@@ -172,8 +201,8 @@ hl.config({
}, },
ecosystem = { ecosystem = {
no_update_news = true, no_update_news = not prefs.get("hyprlandUpdateNews", false),
no_donation_nag = true, no_donation_nag = not prefs.get("hyprlandDonationNag", false),
}, },
xwayland = { xwayland = {
@@ -206,6 +206,147 @@ Singleton {
hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" } hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" }
}, },
{
key: "activeOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
group: "windows",
label: "Focused window opacity",
detail: "Fade even the focused window; 1.0 is fully opaque",
hypr: { path: ["decoration", "active_opacity"], option: "decoration:active_opacity", readAs: "float" }
},
{
key: "fullscreenOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
group: "windows",
label: "Fullscreen opacity",
detail: "Applied instead of the focused opacity when a window is fullscreen",
hypr: { path: ["decoration", "fullscreen_opacity"], option: "decoration:fullscreen_opacity", readAs: "float" }
},
{
key: "roundingPower", type: "real", def: 2.0, min: 2.0, max: 10.0, step: 0.5,
group: "windows",
label: "Corner shape",
detail: "2 is a circular corner; higher values approach a squircle",
hypr: { path: ["decoration", "rounding_power"], option: "decoration:rounding_power", readAs: "float" }
},
// ── Window edges ────────────────────────────────────────────────────
// How the pointer interacts with a window's border, and how windows
// behave near each other. All shipped by looks.lua with no way to
// change any of it.
{
key: "resizeOnBorder", type: "bool", def: true, group: "edges",
label: "Resize by dragging the border",
detail: "Drag a window's edge to resize it, instead of only with the keyboard",
hypr: { path: ["general", "resize_on_border"], option: "general:resize_on_border", readAs: "bool" }
},
{
key: "borderGrabArea", type: "int", def: 15, min: 0, max: 40, step: 1,
unit: "px",
group: "edges",
label: "Border grab area",
detail: "How far outside the border still counts as grabbing it. Larger is easier to hit",
hypr: { path: ["general", "extend_border_grab_area"], option: "general:extend_border_grab_area", readAs: "int" }
},
{
key: "hoverIconOnBorder", type: "bool", def: true, group: "edges",
label: "Show the resize cursor",
detail: "Change the pointer when it is over a resizable border",
hypr: { path: ["general", "hover_icon_on_border"], option: "general:hover_icon_on_border", readAs: "bool" }
},
{
key: "snapWindowGap", type: "int", def: 10, min: 0, max: 60, step: 1,
unit: "px",
group: "edges",
label: "Snap distance between windows",
detail: "How close two floating windows must be before they snap together",
hypr: { path: ["general", "snap", "window_gap"], option: "general:snap:window_gap", readAs: "int" }
},
{
key: "snapMonitorGap", type: "int", def: 10, min: 0, max: 60, step: 1,
unit: "px",
group: "edges",
label: "Snap distance to screen edges",
detail: "How close a floating window must be to an edge before it snaps to it",
hypr: { path: ["general", "snap", "monitor_gap"], option: "general:snap:monitor_gap", readAs: "int" }
},
{
key: "snapRespectGaps", type: "bool", def: false, group: "edges",
label: "Snapping respects gaps",
detail: "Snapped windows keep the configured gap instead of touching",
hypr: { path: ["general", "snap", "respect_gaps"], option: "general:snap:respect_gaps", readAs: "bool" }
},
// ── Master layout ───────────────────────────────────────────────────
// Only meaningful when the tiling layout is Master and stack. Offering
// that layout with none of its options was an omission: it is the one
// layout whose whole behaviour is in these settings.
{
key: "masterFactor", type: "real", def: 0.55, min: 0.1, max: 0.9, step: 0.05,
group: "master",
label: "Master area size",
detail: "How much of the screen the master window takes",
hypr: { path: ["master", "mfact"], option: "master:mfact", readAs: "float" }
},
{
key: "masterOrientation", type: "enum", def: "left", group: "master",
label: "Master area position",
detail: "Which side of the screen the master window occupies",
options: [
{ value: "left", label: "Left" },
{ value: "right", label: "Right" },
{ value: "top", label: "Top" },
{ value: "bottom", label: "Bottom" },
{ value: "center", label: "Centre" }
],
hypr: { path: ["master", "orientation"], option: "master:orientation", readAs: "str" }
},
{
key: "masterNewStatus", type: "enum", def: "slave", group: "master",
label: "New windows become",
detail: "Whether a new window takes the master area or joins the stack",
options: [
{ value: "master", label: "The master window" },
{ value: "slave", label: "Part of the stack" },
{ value: "inherit", label: "Whatever the focused window is" }
],
hypr: { path: ["master", "new_status"], option: "master:new_status", readAs: "str" }
},
{
key: "masterNewOnTop", type: "bool", def: false, group: "master",
label: "Add new windows at the top",
detail: "New stack windows go above the others rather than below",
hypr: { path: ["master", "new_on_top"], option: "master:new_on_top", readAs: "bool" }
},
// ── Hyprland's own notices ──────────────────────────────────────────
// Panama turns all four off on the user's behalf. That is a defensible
// default and was not a decision anyone could reverse without editing
// looks.lua, which is precisely the kind of thing this app exists to
// stop.
{
key: "hyprlandLogo", type: "bool", def: false, group: "notices",
label: "Hyprland wallpaper",
detail: "The stock background Hyprland draws when no wallpaper is set",
hypr: { path: ["misc", "disable_hyprland_logo"], option: "misc:disable_hyprland_logo", readAs: "bool", invert: true }
},
{
key: "hyprlandSplash", type: "bool", def: false, group: "notices",
label: "Splash text",
detail: "The line of text Hyprland renders over the stock background",
hypr: { path: ["misc", "disable_splash_rendering"], option: "misc:disable_splash_rendering", readAs: "bool", invert: true }
},
{
key: "hyprlandUpdateNews", type: "bool", def: false, group: "notices",
label: "Update announcements",
detail: "The window Hyprland opens after an update to describe what changed",
hypr: { path: ["ecosystem", "no_update_news"], option: "ecosystem:no_update_news", readAs: "bool", invert: true }
},
{
key: "hyprlandDonationNag", type: "bool", def: false, group: "notices",
label: "Donation reminders",
detail: "The prompt Hyprland shows twice a year asking for support",
hypr: { path: ["ecosystem", "no_donation_nag"], option: "ecosystem:no_donation_nag", readAs: "bool", invert: true }
},
// ── Effects ───────────────────────────────────────────────────────── // ── Effects ─────────────────────────────────────────────────────────
{ {
key: "blurEnabled", type: "bool", def: true, group: "effects", key: "blurEnabled", type: "bool", def: true, group: "effects",
@@ -241,6 +382,19 @@ Singleton {
detail: "How far the shadow spreads from the window edge", detail: "How far the shadow spreads from the window edge",
hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" } hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" }
}, },
{
key: "shadowSharp", type: "bool", def: false, group: "effects",
label: "Hard-edged shadow",
detail: "A crisp shadow instead of a soft falloff",
hypr: { path: ["decoration", "shadow", "sharp"], option: "decoration:shadow:sharp", readAs: "bool" }
},
{
key: "shadowRenderPower", type: "int", def: 3, min: 1, max: 4, step: 1,
group: "effects",
label: "Shadow falloff",
detail: "How sharply the shadow fades out. Higher is tighter to the window",
hypr: { path: ["decoration", "shadow", "render_power"], option: "decoration:shadow:render_power", readAs: "int" }
},
{ {
key: "glowEnabled", type: "bool", def: true, group: "effects", key: "glowEnabled", type: "bool", def: true, group: "effects",
label: "Focus glow", label: "Focus glow",
@@ -282,7 +436,7 @@ Singleton {
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" } hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
}, },
{ {
key: "keyboardOptions", type: "string", def: "", group: "input", key: "keyboardOptions", type: "string", def: "caps:escape_shifted_capslock", group: "input",
// XKB option names are colon-separated pairs in a comma-separated // XKB option names are colon-separated pairs in a comma-separated
// list, e.g. "compose:ralt,caps:escape". // list, e.g. "compose:ralt,caps:escape".
pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$", pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
@@ -349,7 +503,7 @@ Singleton {
{ {
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1, key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
unit: "s", unit: "s",
group: "input", group: "pointer",
label: "Hide pointer after", label: "Hide pointer after",
detail: "Seconds of stillness before the pointer fades out; 0 never hides it", detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
// Reported as a float even though it is only ever set to whole // Reported as a float even though it is only ever set to whole
@@ -394,6 +548,12 @@ Singleton {
detail: "Swap the primary and secondary buttons", detail: "Swap the primary and secondary buttons",
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" } hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
}, },
{
key: "middleClickPaste", type: "bool", def: true, group: "pointer",
label: "Middle-click paste",
detail: "Paste the primary selection in GTK and native Wayland applications",
hypr: { path: ["misc", "middle_click_paste"], option: "misc:middle_click_paste", readAs: "bool" }
},
// ── Touchpad ──────────────────────────────────────────────────────── // ── Touchpad ────────────────────────────────────────────────────────
// //
@@ -652,6 +812,24 @@ Singleton {
] ]
}, },
// ── Application themes ─────────────────────────────────────────────
// ColorScheme owns GTK's light/dark theme. These are the two theme
// choices GNOME applications expose independently of that palette:
// their icons and pointer. DesktopStyle only accepts names found in
// the read-only XDG catalog before storing them.
{
key: "cursorTheme", type: "string", def: "oreo_blue_cursors", group: "themes",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Pointer theme",
detail: "The pointer design used by applications and Hyprland"
},
{
key: "iconTheme", type: "string", def: "Adwaita", group: "themes",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Application icons",
detail: "The icon set used by GTK applications"
},
// ── Typography ────────────────────────────────────────────────────── // ── Typography ──────────────────────────────────────────────────────
// The single largest thing in this desktop that used to be changeable // The single largest thing in this desktop that used to be changeable
// only by editing Theme.qml. // only by editing Theme.qml.
@@ -681,6 +859,91 @@ Singleton {
label: "Interface text size", label: "Interface text size",
detail: "The base size the rest of the shell's type scales from" detail: "The base size the rest of the shell's type scales from"
}, },
{
key: "applicationFont", type: "string", def: "Adwaita Sans", group: "typography",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Application font",
detail: "Used by menus, controls, and labels in applications"
},
{
key: "applicationFontSize", type: "int", def: 11, min: 6, max: 32, step: 1,
unit: "pt", group: "typography",
label: "Application text size",
detail: "The base text size used by applications"
},
{
key: "documentFont", type: "string", def: "Adwaita Sans", group: "typography",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Document font",
detail: "Used for document content when an application follows the system choice"
},
{
key: "documentFontSize", type: "int", def: 12, min: 6, max: 32, step: 1,
unit: "pt", group: "typography",
label: "Document text size",
detail: "The default text size for document content"
},
{
key: "monospaceFont", type: "string", def: "VictorMono Nerd Font", group: "typography",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Monospace font",
detail: "Used by terminals, editors, and code fields that follow the system choice"
},
{
key: "monospaceFontSize", type: "int", def: 10, min: 6, max: 32, step: 1,
unit: "pt", group: "typography",
label: "Monospace text size",
detail: "The default text size for terminals and code"
},
{
key: "fontHinting", type: "enum", def: "slight", group: "typography",
label: "Font hinting",
detail: "How strongly text aligns to the pixel grid",
options: [
{ value: "none", label: "None" },
{ value: "slight", label: "Slight" },
{ value: "medium", label: "Medium" },
{ value: "full", label: "Full" }
]
},
{
key: "fontAntialiasing", type: "enum", def: "rgba", group: "typography",
label: "Text smoothing",
detail: "How application text softens its edges",
options: [
{ value: "none", label: "None" },
{ value: "grayscale", label: "Grayscale" },
{ value: "rgba", label: "Subpixel" }
]
},
// ── Application titlebars ──────────────────────────────────────────
// These affect applications that honour GNOME's window preferences.
// Hyprland itself has no server-side titlebar buttons, so minimize is
// deliberately absent rather than presented as a switch that lies.
{
key: "titlebarButtonSide", type: "enum", def: "right", group: "titlebar",
label: "Button side",
detail: "Place application titlebar buttons on the left or right",
options: [
{ value: "left", label: "Left" },
{ value: "right", label: "Right" }
]
},
{
key: "titlebarMaximizeButton", type: "bool", def: false, group: "titlebar",
label: "Maximize button",
detail: "Show a maximize button in application titlebars that support it"
},
{
key: "titlebarDoubleClick", type: "enum", def: "toggle-maximize", group: "titlebar",
label: "Double-click titlebar",
detail: "Choose what a double-click on an application titlebar does",
options: [
{ value: "toggle-maximize", label: "Toggle maximize" },
{ value: "none", label: "Do nothing" }
]
},
// ── Accessibility ─────────────────────────────────────────────────── // ── Accessibility ───────────────────────────────────────────────────
// Backed by gsettings so GTK applications agree with the shell, and // Backed by gsettings so GTK applications agree with the shell, and
@@ -13,6 +13,9 @@ Pill {
horizontalPadding: 8 horizontalPadding: 8
onActivated: ShellState.toggle("activity") onActivated: ShellState.toggle("activity")
// Right-click opens the settings that govern this widget. Camera, microphone and screen-sharing state is a privacy readout.
onSecondaryActivated: ShellState.openSettings("privacy")
Text { Text {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: { text: {
@@ -15,6 +15,9 @@ Pill {
horizontalPadding: 8 horizontalPadding: 8
onActivated: ShellState.openDateMenu("agenda") onActivated: ShellState.openDateMenu("agenda")
// Right-click opens the settings that govern this widget. The same place the clock leads, since this is the calendar's own reminder.
onSecondaryActivated: ShellState.openSettings("datetime")
ToolTip.visible: root.hovered && root.visible ToolTip.visible: root.hovered && root.visible
ToolTip.delay: 500 ToolTip.delay: 500
ToolTip.text: CalendarAgenda.nextEvent?.summary ?? "Upcoming event" ToolTip.text: CalendarAgenda.nextEvent?.summary ?? "Upcoming event"
@@ -26,6 +26,11 @@ Pill {
onActivated: ShellState.toggleDateMenu("agenda") onActivated: ShellState.toggleDateMenu("agenda")
// Right-click opens the settings that govern this widget. Timezone and clock format live on Date & Time. The
// format toggles are mirrored on Appearance, but someone right-clicking a
// clock is far more often after the time itself than its typography.
onSecondaryActivated: ShellState.openSettings("datetime")
Text { Text {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: Qt.formatDateTime(clock.date, root.format) text: Qt.formatDateTime(clock.date, root.format)
@@ -7,6 +7,7 @@
import QtQuick import QtQuick
import Quickshell.Services.Mpris import Quickshell.Services.Mpris
import qs.config import qs.config
import qs.services
import qs.widgets import qs.widgets
Pill { Pill {
@@ -34,6 +35,9 @@ Pill {
onActivated: if (root.player?.canTogglePlaying) onActivated: if (root.player?.canTogglePlaying)
root.player.togglePlaying() root.player.togglePlaying()
// Right-click opens the settings that govern this widget. Output device and per-application volume.
onSecondaryActivated: ShellState.openSettings("sound")
// Scroll up = previous, down = next — the same direction as the workspace // Scroll up = previous, down = next — the same direction as the workspace
// switcher, so the whole bar scrolls consistently. // switcher, so the whole bar scrolls consistently.
onScrolled: delta => { onScrolled: delta => {
@@ -24,6 +24,9 @@ Pill {
onActivated: root.requestQuickSettings() onActivated: root.requestQuickSettings()
// Right-click opens the settings that govern this widget. Network and Bluetooth, which is most of what these glyphs report.
onSecondaryActivated: ShellState.openSettings("connectivity")
// ── Audio ─────────────────────────────────────────────────────────────── // ── Audio ───────────────────────────────────────────────────────────────
// Without a tracker, volume and muted silently read as zero/false. // Without a tracker, volume and muted silently read as zero/false.
PwObjectTracker { PwObjectTracker {
@@ -13,6 +13,10 @@ import qs.widgets
Pill { Pill {
id: root id: root
// Right-click opens the settings that govern this widget. Which readouts
// appear in the bar, and how often they update.
onSecondaryActivated: ShellState.openSettings("appearance")
interactive: false interactive: false
Row { Row {
@@ -10,6 +10,9 @@ import qs.widgets
Pill { Pill {
id: root id: root
// Right-click opens the settings that govern this widget. Location, units and refresh interval are all on Home.
onSecondaryActivated: ShellState.openSettings("home")
interactive: false interactive: false
visible: Weather.available visible: Weather.available
@@ -17,8 +17,10 @@ import qs.services
SettingsPage { SettingsPage {
id: root id: root
property string expandedPicker: ""
title: "Appearance" title: "Appearance"
lede: "Drag anything below. The preview above is your real geometry, to scale." lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale."
header: Component { header: Component {
Column { Column {
@@ -83,7 +85,7 @@ SettingsPage {
} }
SettingsCard { SettingsCard {
title: "Typography" title: "Shell typography"
subtitle: Fonts.lastError !== "" subtitle: Fonts.lastError !== ""
? Fonts.lastError ? Fonts.lastError
: "Every piece of text in the shell. Samples are drawn in the font they name." : "Every piece of text in the shell. Samples are drawn in the font they name."
@@ -123,6 +125,137 @@ SettingsPage {
} }
} }
SettingsCard {
title: "Application typography"
subtitle: DesktopStyle.lastError !== ""
? DesktopStyle.lastError
: "Fonts used by applications that follow the desktop defaults. Open one family at a time to keep the page calm."
ActionRow {
label: "Application font"
detail: DesktopStyle.applicationFont
action: root.expandedPicker === "application-font" ? "Close" : "Choose"
onTriggered: root.expandedPicker = root.expandedPicker === "application-font" ? "" : "application-font"
}
FontPicker {
visible: root.expandedPicker === "application-font"
width: parent.width
families: Fonts.interfaceFonts
current: DesktopStyle.applicationFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No application fonts found"
onPicked: family => {
if (DesktopStyle.setApplicationFont(family))
root.expandedPicker = "";
}
}
SliderRow { setting: "applicationFontSize" }
ActionRow {
label: "Document font"
detail: DesktopStyle.documentFont
action: root.expandedPicker === "document-font" ? "Close" : "Choose"
onTriggered: root.expandedPicker = root.expandedPicker === "document-font" ? "" : "document-font"
}
FontPicker {
visible: root.expandedPicker === "document-font"
width: parent.width
families: Fonts.interfaceFonts
current: DesktopStyle.documentFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No document fonts found"
onPicked: family => {
if (DesktopStyle.setDocumentFont(family))
root.expandedPicker = "";
}
}
SliderRow { setting: "documentFontSize" }
ActionRow {
label: "Monospace font"
detail: DesktopStyle.monospaceFont
action: root.expandedPicker === "monospace-font" ? "Close" : "Choose"
onTriggered: root.expandedPicker = root.expandedPicker === "monospace-font" ? "" : "monospace-font"
}
FontPicker {
visible: root.expandedPicker === "monospace-font"
width: parent.width
families: Fonts.monospaceFonts
current: DesktopStyle.monospaceFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No monospace fonts found"
onPicked: family => {
if (DesktopStyle.setMonospaceFont(family))
root.expandedPicker = "";
}
}
SliderRow { setting: "monospaceFontSize" }
ChoiceRow { setting: "fontHinting" }
ChoiceRow { setting: "fontAntialiasing"; divider: false }
}
SettingsCard {
title: "Icons & pointer"
subtitle: DesktopStyle.lastError !== ""
? DesktopStyle.lastError
: "Installed themes only. The pointer updates in applications and Hyprland together."
ActionRow {
label: "Application icons"
detail: DesktopStyle.iconTheme
action: root.expandedPicker === "icon-theme" ? "Close" : "Choose"
enabled: DesktopStyle.catalogLoaded
onTriggered: root.expandedPicker = root.expandedPicker === "icon-theme" ? "" : "icon-theme"
}
SearchPicker {
visible: root.expandedPicker === "icon-theme"
width: parent.width
items: DesktopStyle.iconThemes
current: DesktopStyle.iconTheme
placeholder: "Search icon themes"
emptyText: DesktopStyle.scanning ? "Reading installed icon themes…" : "No icon themes found"
onPicked: value => {
if (DesktopStyle.setIconTheme(value))
root.expandedPicker = "";
}
}
ActionRow {
label: "Pointer theme"
detail: DesktopStyle.cursorTheme
action: root.expandedPicker === "cursor-theme" ? "Close" : "Choose"
enabled: DesktopStyle.catalogLoaded
divider: false
onTriggered: root.expandedPicker = root.expandedPicker === "cursor-theme" ? "" : "cursor-theme"
}
SearchPicker {
visible: root.expandedPicker === "cursor-theme"
width: parent.width
items: DesktopStyle.cursorThemes
current: DesktopStyle.cursorTheme
placeholder: "Search pointer themes"
emptyText: DesktopStyle.scanning ? "Reading installed pointer themes…" : "No pointer themes found"
onPicked: value => {
if (DesktopStyle.setCursorTheme(value))
root.expandedPicker = "";
}
}
}
SettingsCard {
title: "Titlebars"
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
ChoiceRow { setting: "titlebarButtonSide" }
ToggleRow { setting: "titlebarMaximizeButton" }
ChoiceRow { setting: "titlebarDoubleClick"; divider: false }
}
SettingsCard { SettingsCard {
title: "Windows" title: "Windows"
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved." subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
@@ -131,7 +264,10 @@ SettingsPage {
SliderRow { setting: "gapsIn" } SliderRow { setting: "gapsIn" }
SliderRow { setting: "gapsOut" } SliderRow { setting: "gapsOut" }
SliderRow { setting: "borderSize"; zeroLabel: "None" } SliderRow { setting: "borderSize"; zeroLabel: "None" }
SliderRow { setting: "inactiveOpacity"; divider: false } SliderRow { setting: "roundingPower" }
SliderRow { setting: "inactiveOpacity" }
SliderRow { setting: "activeOpacity" }
SliderRow { setting: "fullscreenOpacity"; divider: false }
} }
SettingsCard { SettingsCard {
@@ -143,6 +279,8 @@ SettingsPage {
SliderRow { setting: "blurPasses" } SliderRow { setting: "blurPasses" }
ToggleRow { setting: "shadowEnabled" } ToggleRow { setting: "shadowEnabled" }
SliderRow { setting: "shadowRange"; zeroLabel: "None" } SliderRow { setting: "shadowRange"; zeroLabel: "None" }
SliderRow { setting: "shadowRenderPower" }
ToggleRow { setting: "shadowSharp" }
ToggleRow { setting: "glowEnabled" } ToggleRow { setting: "glowEnabled" }
SliderRow { setting: "glowRange"; zeroLabel: "None" } SliderRow { setting: "glowRange"; zeroLabel: "None" }
ToggleRow { setting: "animationsEnabled"; divider: false } ToggleRow { setting: "animationsEnabled"; divider: false }
@@ -162,7 +300,10 @@ SettingsPage {
ToggleRow { setting: "showCpu" } ToggleRow { setting: "showCpu" }
ToggleRow { setting: "showMemory" } ToggleRow { setting: "showMemory" }
ToggleRow { setting: "showGpu"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing } ToggleRow { setting: "showGpu"; divider: true }
// Refresh interval was on the Home page, which split one concept across
// two pages -- what the vitals show here, how often they update there.
SliderRow { setting: "vitalsIntervalMs"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
// Only worth asking when there is a choice to make. // Only worth asking when there is a choice to make.
ChoiceGrid { ChoiceGrid {
@@ -182,11 +323,4 @@ SettingsPage {
} }
} }
SettingsCard {
title: "Theme"
subtitle: "This desktop has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
}
} }
@@ -12,6 +12,7 @@ SettingsPage {
lede: "Choose what opens your files and links, and what starts with your session." lede: "Choose what opens your files and links, and what starts with your session."
property string expandedRole: "" property string expandedRole: ""
property bool addingAutostart: false
readonly property var applications: DesktopEntries.applications.values readonly property var applications: DesktopEntries.applications.values
readonly property var roles: [ readonly property var roles: [
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] }, { key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
@@ -178,7 +179,28 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "User autostart" title: "User autostart"
subtitle: "These desktop entries live in your user configuration. Select a row to toggle it." subtitle: "Choose what starts with your session. Entries live in your user configuration, not the compositor."
ActionRow {
label: "Add an application"
detail: root.addingAutostart
? "Search the applications installed on this machine"
: "Start another installed application when you sign in"
action: root.addingAutostart ? "Close" : "Choose"
divider: !root.addingAutostart || DefaultApps.autostartEntries.length > 0
enabled: !DefaultApps.busy
onTriggered: root.addingAutostart = !root.addingAutostart
}
AutostartAppPicker {
visible: root.addingAutostart
width: parent.width
existing: DefaultApps.autostartEntries.map(entry => entry.id)
onPicked: id => {
DefaultApps.addAutostart(id);
root.addingAutostart = false;
}
}
TextRow { TextRow {
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0 visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
@@ -0,0 +1,77 @@
// Adds an installed application to the user's freedesktop autostart directory.
import QtQuick
import Quickshell
import qs.config
import qs.modules.clipboard
Column {
id: root
required property var existing
signal picked(string id)
spacing: 0
function desktopId(entry: var): string {
const id = String(entry?.id ?? "");
return id.endsWith(".desktop") ? id : id + ".desktop";
}
readonly property var matches: {
const needle = search.text.trim().toLowerCase();
if (needle === "")
return [];
const out = [];
for (const entry of DesktopEntries.applications.values) {
const desktopId = root.desktopId(entry);
if (entry.noDisplay || root.existing.indexOf(desktopId) >= 0)
continue;
const haystack = `${entry.name ?? ""} ${entry.genericName ?? ""} ${desktopId}`.toLowerCase();
if (haystack.indexOf(needle) >= 0)
out.push(entry);
if (out.length >= 8)
break;
}
return out;
}
SearchField {
id: search
width: parent.width
placeholder: "Search installed applications"
}
Repeater {
model: root.matches
SettingRow {
id: candidate
required property var modelData
required property int index
label: String(candidate.modelData.name || root.desktopId(candidate.modelData))
detail: String(candidate.modelData.genericName || root.desktopId(candidate.modelData))
divider: candidate.index < root.matches.length - 1
controlWidth: 86
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Add"
onClicked: {
root.picked(root.desktopId(candidate.modelData));
search.text = "";
}
}
}
}
SettingRow {
visible: search.text.trim() !== "" && root.matches.length === 0
label: "No matching applications"
detail: "Only installed desktop applications can start with the session"
divider: false
}
}
@@ -67,6 +67,45 @@ SettingsPage {
} }
// GNOME's Multitasking panel, in Hyprland's terms. // GNOME's Multitasking panel, in Hyprland's terms.
// Only meaningful when the layout above is Master and stack. Hidden
// otherwise, because a card of settings that do nothing under the layout
// you are actually running is worse than not offering the layout at all.
SettingsCard {
visible: DesktopPreferences.get("windowLayout") === "master"
title: "Master and stack"
subtitle: "How the master area behaves. These apply only while the tiling layout above is Master and stack."
SliderRow { setting: "masterFactor" }
ChoiceRow { setting: "masterOrientation" }
ChoiceRow { setting: "masterNewStatus" }
ToggleRow { setting: "masterNewOnTop"; divider: false }
}
SettingsCard {
title: "Window edges"
subtitle: "How the pointer grabs a window's border, and how floating windows behave near each other and the screen edge."
ToggleRow { setting: "resizeOnBorder" }
SliderRow { setting: "borderGrabArea"; zeroLabel: "Border only" }
ToggleRow { setting: "hoverIconOnBorder" }
SliderRow { setting: "snapWindowGap"; zeroLabel: "Touching" }
SliderRow { setting: "snapMonitorGap"; zeroLabel: "Touching" }
ToggleRow { setting: "snapRespectGaps"; divider: false }
}
// Hyprland's own interruptions. Panama turns all four off, which is a
// defensible default and was not previously a decision anyone could
// reverse without editing looks.lua.
SettingsCard {
title: "Hyprland notices"
subtitle: "Panama hides all of these by default. They are the compositor's own, not Panama's."
ToggleRow { setting: "hyprlandLogo" }
ToggleRow { setting: "hyprlandSplash" }
ToggleRow { setting: "hyprlandUpdateNews" }
ToggleRow { setting: "hyprlandDonationNag"; divider: false }
}
SettingsCard { SettingsCard {
title: "Workspaces & focus" title: "Workspaces & focus"
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set." subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
@@ -74,8 +113,7 @@ SettingsPage {
ToggleRow { setting: "workspaceBackAndForth" } ToggleRow { setting: "workspaceBackAndForth" }
ToggleRow { setting: "allowWorkspaceCycles" } ToggleRow { setting: "allowWorkspaceCycles" }
ToggleRow { setting: "focusOnActivate" } ToggleRow { setting: "focusOnActivate" }
ToggleRow { setting: "mouseMoveFocusesMonitor" } ToggleRow { setting: "mouseMoveFocusesMonitor"; divider: false }
ChoiceRow { setting: "followMouse"; divider: false }
} }
SettingsCard { SettingsCard {
@@ -117,12 +117,6 @@ SettingsPage {
SliderRow { setting: "weatherRefreshMinutes"; divider: false } SliderRow { setting: "weatherRefreshMinutes"; divider: false }
} }
SettingsCard {
title: "System vitals"
subtitle: "Processor, memory, and graphics activity in the bar"
SliderRow { setting: "vitalsIntervalMs"; divider: false }
}
Grid { Grid {
id: summaryCards id: summaryCards
@@ -31,7 +31,12 @@ SettingsPage {
ChoiceRow { setting: "accelProfile" } ChoiceRow { setting: "accelProfile" }
ToggleRow { setting: "naturalScroll" } ToggleRow { setting: "naturalScroll" }
SliderRow { setting: "scrollFactor" } SliderRow { setting: "scrollFactor" }
ToggleRow { setting: "leftHanded"; divider: false } ToggleRow { setting: "leftHanded" }
ToggleRow {
setting: "middleClickPaste"
detail: "Paste the primary selection in GTK and native Wayland applications; individual apps may choose not to support it"
divider: false
}
} }
SettingsCard { SettingsCard {
@@ -59,6 +59,36 @@ 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 `hypr/looks.lua` so the Hyprland config still stands alone with no settings
file. file.
## Setting ownership
Every preference has **one primary page**, derived from its schema `group` and
the route in `services/SettingsSearch.qml`. Search results always open that
owner. A control may appear on a second page only when the same adaptation is
part of another established mental model; otherwise use a labelled handoff to
the owner instead of duplicating it.
### Intentional mirrors
| Setting | Primary page | Mirror | Why the mirror earns its place |
|---|---|---|---|
| `animationsEnabled` | Appearance | Accessibility | Reduced motion belongs both to visual polish and motion accessibility. |
| `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behaviour but affects motor and visual access. |
| `cursorSize` | Accessibility | Mouse | Large cursors are an accessibility adaptation that users also look for beside pointer controls. |
| `inactiveOpacity` | Appearance | Accessibility | Window translucency is an appearance choice with a direct readability impact. |
| `lockMinutes` | Power | Privacy | Idle timing owns the mechanism; privacy owns the expectation that the unattended desktop locks. |
| `lockOnSleep` | Power | Privacy | Suspend owns the transition; privacy owns whether waking requires authentication. |
Mirrors must remain the same schema-backed control, never a second preference
or a copied default. Additions to this table require a concrete discoverability
reason and an update to `tests/quickshell/settings-ownership-contract.sh`.
Window border colour follows the same ownership rule. The inactive border is a
**scheme-relative role** owned by `ColorScheme.qml`: it changes only to retain
neutral contrast in light and dark modes. The focused Prism border is the
accent role owned by the visual theme (and, eventually, an accent picker).
`ColorScheme.qml` must never write the focused border, so changing schemes
cannot erase a user-selected accent.
## The rows ## The rows
| Component | For | | Component | For |
@@ -21,6 +21,25 @@ SettingsPage {
// when nothing is being captured. Held here rather than per row so that // when nothing is being captured. Held here rather than per row so that
// starting a new capture cancels any other. // starting a new capture cancels any other.
property string capturingChord: "" property string capturingChord: ""
readonly property string storedXkbOptions: String(DesktopPreferences.get("keyboardOptions") ?? "")
function xkbOptions(): var {
return root.storedXkbOptions
.split(",")
.map(option => option.trim())
.filter(option => option !== "");
}
function currentXkbOption(prefix: string): string {
return root.xkbOptions().find(option => option.indexOf(prefix) === 0) ?? "";
}
function setXkbOption(prefix: string, option: string): void {
const options = root.xkbOptions().filter(option => option.indexOf(prefix) !== 0);
if (option !== "")
options.push(option);
SystemSettings.commitPreference("keyboardOptions", options.join(","));
}
title: "Input & Shortcuts" title: "Input & Shortcuts"
lede: "The Forge mental model, carried forward into native tiling." lede: "The Forge mental model, carried forward into native tiling."
@@ -35,6 +54,52 @@ SettingsPage {
// they are real controls. // they are real controls.
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" } TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" } TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
ChoiceGrid {
width: parent.width
label: "Caps Lock"
detail: "Keep it conventional, or turn a prime keyboard position into Escape or Control"
current: root.currentXkbOption("caps:")
options: [
{ value: "", label: "Standard" },
{ value: "caps:escape_shifted_capslock", label: "Esc · Shift for Caps" },
{ value: "caps:escape", label: "Escape" },
{ value: "caps:ctrl_modifier", label: "Control" }
]
onPicked: value => root.setXkbOption("caps:", value)
}
ChoiceGrid {
width: parent.width
label: "Compose key"
detail: "Type accented characters and symbols with memorable key sequences"
current: root.currentXkbOption("compose:")
options: [
{ value: "", label: "Off" },
{ value: "compose:ralt", label: "Right Alt" },
{ value: "compose:rwin", label: "Right Super" },
{ value: "compose:menu", label: "Menu" }
]
onPicked: value => root.setXkbOption("compose:", value)
}
ChoiceGrid {
width: parent.width
label: "Layout switching"
detail: "Used when Keyboard layout contains more than one comma-separated layout"
current: root.currentXkbOption("grp:")
options: [
{ value: "", label: "Off" },
{ value: "grp:win_space_toggle", label: "Super + Space" },
{ value: "grp:alt_shift_toggle", label: "Alt + Shift" },
{ value: "grp:ctrl_shift_toggle", label: "Ctrl + Shift" },
{ value: "grp:caps_toggle", label: "Caps Lock" }
]
onPicked: value => root.setXkbOption("grp:", value)
}
// Presets preserve every option outside their own category. The raw
// value remains visible for less common xkeyboard-config features.
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" } TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
SliderRow { setting: "keyRepeatDelay" } SliderRow { setting: "keyRepeatDelay" }
SliderRow { setting: "keyRepeatRate" } SliderRow { setting: "keyRepeatRate" }
@@ -34,6 +34,7 @@ DateTimePage 1.0 DateTimePage.qml
AccessibilityPage 1.0 AccessibilityPage.qml AccessibilityPage 1.0 AccessibilityPage.qml
WallpaperPicker 1.0 WallpaperPicker.qml WallpaperPicker 1.0 WallpaperPicker.qml
ApplicationsPage 1.0 ApplicationsPage.qml ApplicationsPage 1.0 ApplicationsPage.qml
AutostartAppPicker 1.0 AutostartAppPicker.qml
DockPinsEditor 1.0 DockPinsEditor.qml DockPinsEditor 1.0 DockPinsEditor.qml
DockAppPicker 1.0 DockAppPicker.qml DockAppPicker 1.0 DockAppPicker.qml
ShortcutCapture 1.0 ShortcutCapture.qml ShortcutCapture 1.0 ShortcutCapture.qml
@@ -0,0 +1,129 @@
// The Alt-Tab overlay.
//
// Deliberately a list of names rather than thumbnails: at a glance you are
// looking for "the other terminal", and a row of small live previews is both
// slower to read and considerably more expensive to draw than the gesture
// deserves. The dock already renders app identity this way, so the two agree.
//
// Only present while a switch is in progress -- there is nothing to keep alive
// between gestures, and a hidden always-loaded overlay is a surface that can
// go wrong while nobody is looking at it.
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
import qs.widgets
Loader {
id: root
// Plain `modelData`, not `required property var screen`. Variants supplies
// modelData, and shell.qml's own comment warns about exactly this: declaring
// `required property var screen` means the screen never resolves, the window
// is constructed and silently never maps, and NOTHING is logged. Bar and
// Dock both take the screen this way.
property var modelData: null
active: WindowSwitcherState.open
asynchronous: false
sourceComponent: PanelWindow {
screen: root.modelData
// Overlay so it sits above the focused window it is describing.
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "qs-switcher"
// Nothing here is clickable: the gesture is driven entirely from the
// keyboard, and taking input would steal focus from the compositor
// mid-switch, which is the one thing that would break it.
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
color: "transparent"
anchors { top: true; bottom: true; left: true; right: true }
Rectangle {
anchors.centerIn: parent
width: Math.min(560, parent.width - 96)
implicitHeight: layout.implicitHeight + 24
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgPopover, 0.97)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.09)
Column {
id: layout
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 2
Repeater {
model: WindowSwitcherState.windows
Rectangle {
id: row
required property var modelData
required property int index
readonly property bool current: row.index === WindowSwitcherState.index
readonly property string appId: row.modelData?.wayland?.appId ?? ""
readonly property var entry: DesktopEntries.heuristicLookup(row.appId)
width: parent.width
height: 44
radius: 10
border.width: 0
color: row.current ? Theme.alpha(Theme.accent, 0.20) : "transparent"
Image {
id: icon
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
width: 24
height: 24
sourceSize.width: 24
sourceSize.height: 24
source: row.entry?.icon ? Quickshell.iconPath(row.entry.icon, true) : ""
visible: source !== ""
}
Text {
anchors.left: icon.visible ? icon.right : parent.left
anchors.leftMargin: icon.visible ? 12 : 14
anchors.right: appName.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
// A window with no title yet is still a window you
// can switch to; naming it after its application is
// better than an empty row.
text: row.modelData?.title || row.entry?.name || row.appId
elide: Text.ElideRight
color: row.current ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: row.current ? Font.DemiBold : Font.Normal
}
Text {
id: appName
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
visible: (row.entry?.name ?? "") !== "" && row.entry.name !== row.modelData?.title
text: row.entry?.name ?? ""
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
}
}
}
}
@@ -37,8 +37,8 @@ def xdg_data_roots() -> list[Path]:
return [data_home, *(Path(item) for item in data_dirs.split(":") if item)] return [data_home, *(Path(item) for item in data_dirs.split(":") if item)]
def discovered_desktop_ids() -> set[str]: def discovered_desktop_files() -> dict[str, Path]:
desktop_ids: set[str] = set() desktop_files: dict[str, Path] = {}
for root in xdg_data_roots(): for root in xdg_data_roots():
applications = root / "applications" applications = root / "applications"
if not applications.is_dir(): if not applications.is_dir():
@@ -47,8 +47,12 @@ def discovered_desktop_ids() -> set[str]:
if not path.is_file(): if not path.is_file():
continue continue
relative = path.relative_to(applications) relative = path.relative_to(applications)
desktop_ids.add("-".join(relative.parts)) desktop_files.setdefault("-".join(relative.parts), path)
return desktop_ids return desktop_files
def discovered_desktop_ids() -> set[str]:
return set(discovered_desktop_files())
def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None: def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None:
@@ -184,12 +188,7 @@ def set_default(role: str, desktop_id: str) -> None:
run(command) run(command)
def update_hidden(path: Path, *, hidden: bool) -> None: def with_hidden(original: str, *, hidden: bool) -> str:
try:
original = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise BoundaryError("That autostart entry could not be read.") from error
lines = original.splitlines() lines = original.splitlines()
output: list[str] = [] output: list[str] = []
section = "" section = ""
@@ -216,24 +215,63 @@ def update_hidden(path: Path, *, hidden: bool) -> None:
raise BoundaryError("That autostart entry is not a desktop file.") raise BoundaryError("That autostart entry is not a desktop file.")
if not wrote_hidden: if not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}") output.append(f"Hidden={'true' if hidden else 'false'}")
return "\n".join(output) + "\n"
mode = path.stat().st_mode
def write_atomic(path: Path, text: str, *, mode: int) -> None:
temporary_path: Path | None = None
try: try:
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
) as temporary: ) as temporary:
temporary.write("\n".join(output) + "\n") temporary.write(text)
temporary.flush() temporary.flush()
os.fsync(temporary.fileno()) os.fsync(temporary.fileno())
temporary_path = Path(temporary.name) temporary_path = Path(temporary.name)
temporary_path.chmod(mode) temporary_path.chmod(mode)
os.replace(temporary_path, path) os.replace(temporary_path, path)
except OSError as error: except OSError as error:
if "temporary_path" in locals(): if temporary_path is not None:
temporary_path.unlink(missing_ok=True) temporary_path.unlink(missing_ok=True)
raise BoundaryError("That autostart entry could not be updated.") from error raise BoundaryError("That autostart entry could not be updated.") from error
def update_hidden(path: Path, *, hidden: bool) -> None:
try:
original = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise BoundaryError("That autostart entry could not be read.") from error
mode = path.stat().st_mode
write_atomic(path, with_hidden(original, hidden=hidden), mode=mode)
def add_autostart(desktop_id: str) -> None:
desktop_files = discovered_desktop_files()
require_desktop_id(desktop_id, discovered=set(desktop_files))
source = desktop_files[desktop_id]
directory = autostart_directory()
try:
directory.mkdir(parents=True, exist_ok=True)
except OSError as error:
raise BoundaryError("The user autostart directory could not be created.") from error
target = directory / desktop_id
if target.is_symlink():
raise BoundaryError("That autostart entry is not available.")
if target.exists():
if not target.is_file():
raise BoundaryError("That autostart entry is not available.")
update_hidden(target, hidden=False)
return
try:
original = source.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise BoundaryError("That application could not be read.") from error
write_atomic(target, with_hidden(original, hidden=False), mode=0o644)
def set_autostart(desktop_id: str, enabled_text: str) -> None: def set_autostart(desktop_id: str, enabled_text: str) -> None:
if enabled_text not in {"true", "false"}: if enabled_text not in {"true", "false"}:
raise BoundaryError("Autostart state must be true or false.") raise BoundaryError("Autostart state must be true or false.")
@@ -260,10 +298,12 @@ def main(arguments: list[str]) -> int:
set_default(arguments[1], arguments[2]) set_default(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-autostart": elif len(arguments) == 3 and arguments[0] == "set-autostart":
set_autostart(arguments[1], arguments[2]) set_autostart(arguments[1], arguments[2])
elif len(arguments) == 2 and arguments[0] == "add-autostart":
add_autostart(arguments[1])
else: else:
raise BoundaryError( raise BoundaryError(
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | " "Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | "
"set-autostart DESKTOP_ID true|false" "set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID"
) )
except BoundaryError as error: except BoundaryError as error:
print(str(error), file=sys.stderr) print(str(error), file=sys.stderr)
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Report installed cursor and icon themes from the standard XDG roots.
This helper is intentionally read-only and argument-free. Theme paths come
only from XDG_DATA_HOME and XDG_DATA_DIRS; directory symlinks are not followed.
"""
from __future__ import annotations
import json
import os
import stat
import sys
from pathlib import Path
def icon_roots() -> list[Path]:
home = Path(os.environ.get("HOME") or "/nonexistent")
data_home = Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share")
data_dirs = os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share"
roots = [data_home / "icons"]
roots.extend(Path(directory) / "icons" for directory in data_dirs.split(":") if directory)
# XDG paths are required to be absolute. Ignoring malformed relative
# entries also prevents the helper's working directory becoming an
# accidental caller-controlled search root.
return [root for root in roots if root.is_absolute()]
def is_real_directory(path: Path) -> bool:
try:
return stat.S_ISDIR(path.lstat().st_mode)
except OSError:
return False
def is_real_file(path: Path) -> bool:
try:
return stat.S_ISREG(path.lstat().st_mode)
except OSError:
return False
def has_icon_directories(index_path: Path) -> bool:
try:
with index_path.open(encoding="utf-8", errors="replace") as handle:
for raw_line in handle:
line = raw_line.strip()
if line.startswith(("#", ";")) or "=" not in line:
continue
key, value = line.split("=", 1)
if key.strip() == "Directories":
return bool(value.strip())
except OSError:
return False
return False
def catalog() -> dict[str, list[str]]:
cursor_themes: set[str] = set()
icon_themes: set[str] = set()
for root in icon_roots():
if not is_real_directory(root):
continue
try:
entries = list(os.scandir(root))
except OSError:
continue
for entry in entries:
if not entry.is_dir(follow_symlinks=False):
continue
theme = Path(entry.path)
if is_real_directory(theme / "cursors"):
cursor_themes.add(entry.name)
index_path = theme / "index.theme"
if is_real_file(index_path) and has_icon_directories(index_path):
icon_themes.add(entry.name)
return {
"cursorThemes": sorted(cursor_themes, key=str.casefold),
"iconThemes": sorted(icon_themes, key=str.casefold),
}
def main() -> int:
if len(sys.argv) != 1:
print("panama-desktop-style takes no arguments", file=sys.stderr)
return 2
print(json.dumps(catalog(), ensure_ascii=False, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+16 -2
View File
@@ -130,7 +130,7 @@ RUNTIME_LINK_TARGETS = (
("vicinae", Path("config/dot/vicinae")), ("vicinae", Path("config/dot/vicinae")),
) )
REPAIR_IDS = frozenset((*REPAIR_COMMANDS.keys(), "panama.runtime-links", "panama.vicinae-commands", "panama.caffeine")) REPAIR_IDS = frozenset((*REPAIR_COMMANDS.keys(), "panama.runtime-links", "panama.vicinae-commands", "panama.caffeine"))
PROCESS_NAMES = ("quickshell", "vicinae", "hyprpaper", "hypridle") PROCESS_NAMES = ("vicinae", "hyprpaper", "hypridle")
VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b") VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b")
REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE) REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
PROBE_ENVIRONMENT_KEYS = ( PROBE_ENVIRONMENT_KEYS = (
@@ -436,7 +436,21 @@ def executable_check(check_id: str, title: str, executable: str, config: DoctorC
def check_processes(config: DoctorConfig) -> Check: def check_processes(config: DoctorConfig) -> Check:
counts: list[int] = [] # `qs` is both the long-running shell and every short-lived IPC client.
# Counting it with pgrep races the other parallel health probes and reports
# duplicates whenever one of them happens to call `qs ipc`. The instance
# list is the authoritative view and contains only actual shells.
quickshell = run_command(("qs", "list"), config)
if quickshell.state == "ok":
quickshell_count = sum(
line.startswith("Instance ") for line in quickshell.stdout.splitlines()
)
elif quickshell.state == "failed":
quickshell_count = 0
else:
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe is unavailable.")
counts: list[int] = [quickshell_count]
for name in PROCESS_NAMES: for name in PROCESS_NAMES:
result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config) result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config)
if result.state == "ok": if result.state == "ok":
@@ -31,6 +31,59 @@ case "$scheme" in
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;; *) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
esac esac
# ── hyprlock ─────────────────────────────────────────────────────────────────
# The lock screen. hyprlock is launched fresh on every lock (`pidof hyprlock ||
# hyprlock`), so it reads this file each time and needs no restart.
#
# It takes rgba(r, g, b, a) in DECIMAL, not hex, so the palette is expressed as
# "R, G, B" triples here rather than the hex used everywhere else. The two
# _HEX values are the exception: they sit inside Pango markup, where hyprlock
# wants ##rrggbb.
lock_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hypr"
lock_template="$lock_dir/hyprlock.conf.template"
if [[ "$scheme" == "light" ]]; then
lock_fg="55, 96, 191" # #3760bf
lock_muted="97, 114, 176" # #6172b0
lock_accent="46, 125, 233" # #2e7de9
lock_error="245, 42, 101" # #f52a65
lock_bg="225, 226, 231" # #e1e2e7
lock_field="208, 213, 227" # #d0d5e3
lock_muted_hex="6172b0"
lock_error_hex="f52a65"
else
lock_fg="200, 211, 245" # #c8d3f5
lock_muted="130, 139, 184" # #828bb8
lock_accent="130, 170, 255" # #82aaff
lock_error="255, 117, 127" # #ff757f
lock_bg="34, 36, 54" # #222436
lock_field="46, 47, 61" # #2e2f3d
lock_muted_hex="828bb8"
lock_error_hex="ff757f"
fi
status_hyprlock="skipped"
if [[ -r "$lock_template" ]]; then
# Written atomically: a lock triggered mid-write would otherwise read a
# truncated config and fall back to hyprlock's own defaults, which is a
# bright grey screen with none of this desktop's identity.
if sed -e "s/@FG@/$lock_fg/g" \
-e "s/@MUTED@/$lock_muted/g" \
-e "s/@ACCENT@/$lock_accent/g" \
-e "s/@ERROR@/$lock_error/g" \
-e "s/@BG@/$lock_bg/g" \
-e "s/@FIELD@/$lock_field/g" \
-e "s/@MUTED_HEX@/$lock_muted_hex/g" \
-e "s/@ERROR_HEX@/$lock_error_hex/g" \
"$lock_template" >"$lock_dir/hyprlock.conf.tmp" 2>/dev/null \
&& mv "$lock_dir/hyprlock.conf.tmp" "$lock_dir/hyprlock.conf" 2>/dev/null; then
status_hyprlock="written"
else
rm -f "$lock_dir/hyprlock.conf.tmp"
status_hyprlock="failed"
fi
fi
# ── tmux ───────────────────────────────────────────────────────────────────── # ── tmux ─────────────────────────────────────────────────────────────────────
# Generated like kitty's: tmux.conf sources current-theme.conf, and that file is # Generated like kitty's: tmux.conf sources current-theme.conf, and that file is
# machine state rather than configuration. Running servers are re-sourced so an # machine state rather than configuration. Running servers are re-sourced so an
@@ -146,4 +199,5 @@ jq -cn \
--arg gtk "$status_gtk" \ --arg gtk "$status_gtk" \
--arg btop "$status_btop" \ --arg btop "$status_btop" \
--arg tmux "$status_tmux" \ --arg tmux "$status_tmux" \
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux}' --arg hyprlock "$status_hyprlock" \
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux, hyprlock: $hyprlock}'
@@ -25,25 +25,14 @@ import qs.config
Singleton { Singleton {
id: root id: root
property string cursorTheme: ""
property string lastError: "" property string lastError: ""
readonly property bool busy: themeQuery.running || runner.running || root.pending.length > 0 readonly property bool busy: runner.running || root.pending.length > 0
readonly property string cursorTheme: DesktopPreferences.get("cursorTheme")
readonly property int cursorSize: DesktopPreferences.get("cursorSize") readonly property int cursorSize: DesktopPreferences.get("cursorSize")
readonly property real textScale: DesktopPreferences.get("textScale") readonly property real textScale: DesktopPreferences.get("textScale")
Process {
id: themeQuery
command: ["gsettings", "get", "org.gnome.desktop.interface", "cursor-theme"]
stdout: StdioCollector {
onStreamFinished: {
// gsettings quotes strings: 'oreo_blue_cursors'
root.cursorTheme = this.text.trim().replace(/^'|'$/g, "");
}
}
}
// A short queue, because applying one setting takes several commands and // A short queue, because applying one setting takes several commands and
// Process runs one at a time. // Process runs one at a time.
property var pending: [] property var pending: []
@@ -71,7 +60,6 @@ Singleton {
} }
Component.onCompleted: { Component.onCompleted: {
themeQuery.running = true;
settle.restart(); settle.restart();
} }
@@ -102,9 +90,6 @@ Singleton {
["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size], ["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size],
["gsettings", "set", "org.gnome.desktop.interface", "text-scaling-factor", String(root.textScale)] ["gsettings", "set", "org.gnome.desktop.interface", "text-scaling-factor", String(root.textScale)]
]; ];
// setcursor needs a theme name; skip it rather than guess if gsettings
// has not answered yet. The next change will catch up.
if (root.cursorTheme !== "")
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]); commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
root.enqueue(commands); root.enqueue(commands);
} }
@@ -24,6 +24,11 @@ Singleton {
readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps" readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light" readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
// A neutral contrast role, not an accent. The focused Prism border belongs
// to the visual theme and must remain untouched when this role changes.
readonly property string inactiveBorderDark: "rgba(3b426199)"
readonly property string inactiveBorderLight: "rgba(a8aecb99)"
readonly property string inactiveBorder: root.dark ? root.inactiveBorderDark : root.inactiveBorderLight
property string lastError: "" property string lastError: ""
// Applied one command at a time: Process runs a single command, and several // Applied one command at a time: Process runs a single command, and several
@@ -99,12 +104,10 @@ Singleton {
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme] ["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
]; ];
// Unfocused window borders. The focused border is the prism gradient and // Unfocused window borders need scheme-relative contrast. The focused
// is already scheme-independent; the inactive one is a flat neutral that // Prism border is deliberately owned by the accent/theme layer.
// would be invisible against the opposite background.
const inactive = root.dark ? "rgba(3b426199)" : "rgba(a8aecb99)";
commands.push(["hyprctl", "eval", commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { inactive_border = "${inactive}" } } })`]); `hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
// Applications that predate org.freedesktop.appearance and carry their // Applications that predate org.freedesktop.appearance and carry their
// own palettes -- terminals, chiefly. Everything that reads the portal // own palettes -- terminals, chiefly. Everything that reads the portal
@@ -98,5 +98,16 @@ Singleton {
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]); mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]);
} }
function addAutostart(desktopId: string): void {
if (root.busy)
return;
if (!root.knownDesktopId(desktopId)) {
root.lastError = "Choose an installed application.";
return;
}
root.lastError = "";
mutationProcess.exec([root.helper, "add-autostart", desktopId]);
}
Component.onCompleted: root.refresh() Component.onCompleted: root.refresh()
} }
@@ -0,0 +1,260 @@
pragma Singleton
// Application-facing desktop style.
//
// Panama owns the durable choices; gsettings is an output boundary for GTK
// and applications that follow GNOME's desktop schemas. Commands are arrays,
// values are validated before storage, and no user text is ever sent through a
// shell. Hyprland's pointer setting stays live through Accessibility.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-desktop-style"
// SearchPicker consumes [{ value, label, detail }]. Keep the raw names as
// a separate allow-list so a caller cannot smuggle a display label into a
// stored theme name.
property var cursorThemes: []
property var iconThemes: []
property var cursorThemeNames: []
property var iconThemeNames: []
property bool catalogLoaded: false
property bool scanning: false
property bool startupApplied: false
property string lastError: ""
property var pending: []
readonly property bool busy: root.scanning || catalogProcess.running
|| runner.running || root.pending.length > 0
readonly property int preferenceRevision: DesktopPreferences.revision
readonly property string cursorTheme: DesktopPreferences.get("cursorTheme")
readonly property string iconTheme: DesktopPreferences.get("iconTheme")
readonly property string applicationFont: DesktopPreferences.get("applicationFont")
readonly property string documentFont: DesktopPreferences.get("documentFont")
readonly property string monospaceFont: DesktopPreferences.get("monospaceFont")
Process {
id: catalogProcess
stdout: StdioCollector {
onStreamFinished: root.acceptCatalog(this.text)
}
onExited: (exitCode, exitStatus) => {
root.scanning = false;
if (exitCode !== 0) {
root.catalogLoaded = false;
root.lastError = "Installed icon and pointer themes could not be read.";
}
}
}
Process {
id: runner
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "One desktop style setting could not be applied.";
root.drain();
}
}
Component.onCompleted: {
root.ensureStarted();
// Accessing the singleton here keeps its existing hyprctl setcursor
// path alive for cursor-theme changes as well as cursor-size changes.
Accessibility.applyAll();
}
Timer {
id: startupApply
interval: 1200
onTriggered: {
root.startupApplied = true;
root.applyAll();
}
}
Connections {
target: DesktopPreferences
function onRevisionChanged(): void { applyCoalesce.restart(); }
}
Timer {
id: applyCoalesce
interval: 180
onTriggered: root.applyAll()
}
function ensureStarted(): void {
if (!root.catalogLoaded && !root.scanning)
root.refreshCatalog();
if (!root.startupApplied && !startupApply.running)
startupApply.restart();
}
function refreshCatalog(): void {
if (catalogProcess.running)
return;
root.scanning = true;
catalogProcess.exec([root.helperPath]);
}
function acceptCatalog(text: string): void {
try {
const parsed = JSON.parse(text);
if (!parsed || !Array.isArray(parsed.cursorThemes) || !Array.isArray(parsed.iconThemes))
throw new Error("invalid catalog shape");
const cursors = parsed.cursorThemes.filter(name =>
typeof name === "string" && PreferenceSchema.coerce("cursorTheme", name) !== undefined);
const icons = parsed.iconThemes.filter(name =>
typeof name === "string" && PreferenceSchema.coerce("iconTheme", name) !== undefined);
root.cursorThemeNames = cursors;
root.iconThemeNames = icons;
root.cursorThemes = cursors.map(name => ({
value: name,
label: name,
detail: "Pointer theme"
}));
root.iconThemes = icons.map(name => ({
value: name,
label: name,
detail: "Application icon theme"
}));
root.catalogLoaded = true;
root.lastError = "";
} catch (error) {
root.cursorThemes = [];
root.iconThemes = [];
root.cursorThemeNames = [];
root.iconThemeNames = [];
root.catalogLoaded = false;
root.lastError = "Installed icon and pointer themes could not be read.";
}
root.scanning = false;
}
function drain(): void {
if (runner.running || root.pending.length === 0)
return;
const next = root.pending[0];
root.pending = root.pending.slice(1);
runner.exec(next);
}
function enqueue(commands: var): void {
// A fresh revision supersedes commands that have not started yet. The
// currently running command is allowed to finish, then the newest full
// state is replayed in a deterministic order.
root.pending = commands;
root.drain();
}
// GVariant accepts JSON-style quoted strings. JSON.stringify escapes every
// quote, backslash, and control character, and the schema patterns further
// constrain stored font/theme names. Arguments still travel directly to
// gsettings rather than through a shell.
function gvariant(value: var): string {
if (typeof value === "boolean")
return value ? "true" : "false";
if (typeof value === "number")
return String(value);
return JSON.stringify(String(value));
}
function fontName(familyKey: string, sizeKey: string): string {
return `${DesktopPreferences.get(familyKey)} ${DesktopPreferences.get(sizeKey)}`;
}
function buttonLayout(): string {
const side = DesktopPreferences.get("titlebarButtonSide");
const maximize = DesktopPreferences.get("titlebarMaximizeButton") === true;
// Tokens are fixed. Only their side and whether maximize is present
// vary, so preference data can never become command syntax.
if (side === "left")
return (maximize ? "close,maximize" : "close") + ":appmenu";
return "appmenu:" + (maximize ? "maximize,close" : "close");
}
function setting(schema: string, key: string, value: var): var {
return ["gsettings", "set", schema, key, root.gvariant(value)];
}
function applyAll(): void {
root.lastError = "";
root.enqueue([
root.setting("org.gnome.desktop.interface", "icon-theme", root.iconTheme),
root.setting("org.gnome.desktop.interface", "cursor-theme", root.cursorTheme),
root.setting("org.gnome.desktop.interface", "font-name",
root.fontName("applicationFont", "applicationFontSize")),
root.setting("org.gnome.desktop.interface", "document-font-name",
root.fontName("documentFont", "documentFontSize")),
root.setting("org.gnome.desktop.interface", "monospace-font-name",
root.fontName("monospaceFont", "monospaceFontSize")),
root.setting("org.gnome.desktop.interface", "font-hinting",
DesktopPreferences.get("fontHinting")),
root.setting("org.gnome.desktop.interface", "font-antialiasing",
DesktopPreferences.get("fontAntialiasing")),
root.setting("org.gnome.desktop.interface", "gtk-enable-primary-paste",
DesktopPreferences.get("middleClickPaste")),
root.setting("org.gnome.desktop.wm.preferences", "button-layout", root.buttonLayout()),
root.setting("org.gnome.desktop.wm.preferences", "action-double-click-titlebar",
DesktopPreferences.get("titlebarDoubleClick"))
]);
}
function storeCatalogChoice(key: string, value: string, allowed: var, kind: string): bool {
if (!root.catalogLoaded || allowed.indexOf(value) < 0) {
root.lastError = `That ${kind} theme is not installed.`;
return false;
}
if (!DesktopPreferences.set(key, value)) {
root.lastError = `That ${kind} theme name could not be saved.`;
return false;
}
root.lastError = "";
return true;
}
function setCursorTheme(value: string): bool {
return root.storeCatalogChoice("cursorTheme", value, root.cursorThemeNames, "pointer");
}
function setIconTheme(value: string): bool {
return root.storeCatalogChoice("iconTheme", value, root.iconThemeNames, "icon");
}
function storeFont(key: string, family: string, allowed: var, kind: string): bool {
if (allowed.indexOf(family) < 0) {
root.lastError = `That ${kind} font is not installed.`;
return false;
}
if (!DesktopPreferences.set(key, family)) {
root.lastError = `That ${kind} font name could not be saved.`;
return false;
}
root.lastError = "";
return true;
}
function setApplicationFont(family: string): bool {
return root.storeFont("applicationFont", family, Fonts.interfaceFonts, "application");
}
function setDocumentFont(family: string): bool {
return root.storeFont("documentFont", family, Fonts.interfaceFonts, "document");
}
function setMonospaceFont(family: string): bool {
return root.storeFont("monospaceFont", family, Fonts.monospaceFonts, "monospace");
}
}
@@ -17,12 +17,20 @@ import qs.config
Singleton { Singleton {
id: root id: root
// SettingsSearch is part of the always-constructed sidebar. Touching the
// desktop-style service here gives application preferences their startup
// replay even when Appearance is not the page that opens first.
Component.onCompleted: DesktopStyle.ensureStarted()
// Which page shows the settings in a given schema group. A group with no // Which page shows the settings in a given schema group. A group with no
// entry here still appears in results and routes to Home rather than being // entry here still appears in results and routes to Home rather than being
// dropped, so adding a group can never make a setting unreachable. // dropped, so adding a group can never make a setting unreachable.
readonly property var groupPages: ({ readonly property var groupPages: ({
"clock": "appearance", "clock": "appearance",
"vitals": "appearance", "vitals": "appearance",
"typography": "appearance",
"themes": "appearance",
"titlebar": "appearance",
"windows": "appearance", "windows": "appearance",
"effects": "appearance", "effects": "appearance",
"wallpaper": "appearance", "wallpaper": "appearance",
@@ -36,7 +44,10 @@ Singleton {
"pointer": "mouse", "pointer": "mouse",
"touchpad": "mouse", "touchpad": "mouse",
"multitasking": "desktop", "multitasking": "desktop",
"weather": "appearance", "edges": "desktop",
"master": "desktop",
"notices": "desktop",
"weather": "home",
"notifications": "notifications", "notifications": "notifications",
"capture": "screen-intelligence" "capture": "screen-intelligence"
}) })
@@ -273,6 +273,15 @@ Singleton {
// decides, and config/dot/hypr/prefs.lua does the same conversion via // decides, and config/dot/hypr/prefs.lua does the same conversion via
// prefs.getInt so both sides agree. // prefs.getInt so both sides agree.
function hyprValue(entry: var, value: var): var { function hyprValue(entry: var, value: var): var {
// Some options are phrased as a negative by the compositor -- the four
// Hyprland notices are all `disable_x` -- while the setting reads as
// "show x", because a switch labelled "Disable splash text" that must
// be ON to hide something is a small cruelty. `invert` bridges the two,
// in exactly one place, so nothing downstream has to remember which
// options are backwards.
if (entry.hypr.invert === true && typeof value === "boolean")
value = !value;
if (typeof value === "boolean" && entry.hypr.readAs !== "bool") if (typeof value === "boolean" && entry.hypr.readAs !== "bool")
return value ? 1 : 0; return value ? 1 : 0;
return value; return value;
@@ -300,6 +309,24 @@ Singleton {
return value ? "true" : "false"; return value ? "true" : "false";
if (typeof value === "number") if (typeof value === "number")
return String(value); return String(value);
// A gradient is the one setting whose Lua form is not a scalar. The
// stubs declare it as `string|{colors:string[], angle?:number}`, and
// the string form only ever carries ONE stop -- writing
// "rgba(a) rgba(b) 45deg" as a string is accepted and silently keeps
// the previous value, which is how a two-stop write looks like it
// worked and did nothing. Multi-stop must be the table form.
if (value && typeof value === "object" && Array.isArray(value.colors)) {
const stops = value.colors
.map(stop => `"${String(stop).replace(/["\\]/g, "")}"`)
.join(", ");
const angle = Number(value.angle);
return `{ colors = { ${stops} }` + (isFinite(angle) ? `, angle = ${angle} }` : ` }`);
}
// A vec2 reaches Lua as a two-element table.
if (Array.isArray(value) && value.length === 2)
return `{ ${Number(value[0])}, ${Number(value[1])} }`;
// Strings only reach here after the schema's pattern check; quoting is // Strings only reach here after the schema's pattern check; quoting is
// belt-and-braces rather than the primary defence. // belt-and-braces rather than the primary defence.
return `"${String(value).replace(/["\\]/g, "")}"`; return `"${String(value).replace(/["\\]/g, "")}"`;
@@ -309,7 +336,15 @@ Singleton {
const parts = []; const parts = [];
for (const name in node) { for (const name in node) {
const child = node[name]; const child = node[name];
parts.push(`${name} = ${typeof child === "string" ? child : root.serialiseTable(child)}`); // A leaf arrives pre-serialised as a string; anything else is
// either a nested section or a structured value (gradient, vec2)
// that serialiseValue knows how to render.
const rendered = typeof child === "string"
? child
: (Array.isArray(child) || (child && child.colors !== undefined)
? root.serialiseValue(child)
: root.serialiseTable(child));
parts.push(`${name} = ${rendered}`);
} }
return `{ ${parts.join(", ")} }`; return `{ ${parts.join(", ")} }`;
} }
@@ -353,6 +388,59 @@ Singleton {
root.drainQueue(); root.drainQueue();
} }
// Gradients are written in one notation and read back in another, so they
// cannot be compared directly the way every other type can.
//
// written: { colors = { "rgba(3b426199)" }, angle = 45 }
// read: "993b4261 45deg"
//
// The stops swap to AARRGGBB order, lose their wrapper, and the angle is
// always appended even when it was never given. Comparing the raw strings
// reports every gradient write as rejected, which is what would have
// happened had this been added with readAs: "str".
function gradientMatches(expected: var, observed: string): bool {
if (typeof observed !== "string")
return false;
return root.normaliseGradient(expected) === root.normaliseGradient(observed);
}
// Both notations reduced to "aarrggbb aarrggbb Ndeg".
function normaliseGradient(value: var): string {
const stops = [];
let angle = 0;
const readStop = function (text: string): void {
const rgba = String(text).match(/rgba?\(\s*([0-9a-fA-F]{6,8})\s*\)/);
if (rgba) {
let hex = rgba[1].toLowerCase();
// rgb() has no alpha; the compositor reports it as fully opaque.
if (hex.length === 6)
hex = hex + "ff";
// RRGGBBAA in, AARRGGBB out.
stops.push(hex.slice(6, 8) + hex.slice(0, 6));
return;
}
const bare = String(text).match(/^([0-9a-fA-F]{8})$/);
if (bare) {
stops.push(bare[1].toLowerCase());
return;
}
const deg = String(text).match(/^(-?[0-9.]+)deg$/);
if (deg)
angle = Number(deg[1]);
};
if (value && typeof value === "object" && Array.isArray(value.colors)) {
value.colors.forEach(readStop);
if (value.angle !== undefined && isFinite(Number(value.angle)))
angle = Number(value.angle);
} else {
String(value).trim().split(/\s+/).forEach(readStop);
}
return stops.join(" ") + " " + angle + "deg";
}
function matchesObserved(entry: var, value: var, answer: var): bool { function matchesObserved(entry: var, value: var, answer: var): bool {
if (!answer) if (!answer)
return false; return false;
@@ -370,6 +458,12 @@ Singleton {
case "css": case "css":
// Gaps read back as a box, e.g. "10 10 10 10". // Gaps read back as a box, e.g. "10 10 10 10".
return Number(String(answer.css).trim().split(/\s+/)[0]) === expected; return Number(String(answer.css).trim().split(/\s+/)[0]) === expected;
case "gradient":
return root.gradientMatches(expected, answer.gradient);
case "vec2":
return Array.isArray(answer.vec2) && Array.isArray(expected)
&& Number(answer.vec2[0]) === Number(expected[0])
&& Number(answer.vec2[1]) === Number(expected[1]);
} }
return false; return false;
} }
@@ -0,0 +1,134 @@
pragma Singleton
// Alt-Tab, with something on screen while you do it.
//
// Named WindowSwitcherState rather than WindowSwitcher: the overlay component
// in modules/switcher already owns that name, and a singleton sharing it is
// silently shadowed wherever both are imported -- the same failure that made an
// earlier Locale singleton resolve to QML's built-in type instead.
//
// Super+Tab already cycled windows; nothing was drawn, so you were choosing
// blind and could only confirm by arriving. This holds the selection while a
// switch is in progress and lets the overlay render it.
//
// MOST-RECENTLY-USED ORDER
//
// The list is ordered by when each window last had focus, not by when it was
// opened, because that is what makes the gesture useful: one Tab returns to the
// window you just came from, which is the overwhelmingly common case. Creation
// order would send you to whichever window happens to be first in Hyprland's
// list, which is arbitrary from the user's point of view.
//
// Hyprland does not report an MRU order, so it is tracked here: every time a
// toplevel becomes active it moves to the front. Addresses are used as the key
// because they are stable for a window's lifetime, where titles and app ids are
// not.
//
// HOW A SWITCH ENDS
//
// The compositor fires a bind on Super RELEASE, which commits. That is the only
// way to know the gesture is over -- there is no "modifier released" signal
// otherwise. It means close() runs on every Super release in the session, so it
// must be cheap and a no-op when nothing is open.
import Quickshell
import Quickshell.Hyprland
import QtQuick
Singleton {
id: root
property bool open: false
property int index: 0
// Window addresses, most recently focused first.
property var recent: []
// The switch candidates, resolved fresh each time the gesture starts.
property var windows: []
readonly property var selected: (root.index >= 0 && root.index < root.windows.length)
? root.windows[root.index] : null
// Ordered by the MRU list, with anything unseen appended in Hyprland's own
// order so a brand new window is still reachable.
function orderedWindows(): var {
const all = (Hyprland.toplevels?.values ?? []).filter(t => t && t.wayland && t.wayland.appId);
const byAddress = {};
for (const toplevel of all)
byAddress[String(toplevel.address)] = toplevel;
const ordered = [];
for (const address of root.recent) {
const match = byAddress[address];
if (match) {
ordered.push(match);
delete byAddress[address];
}
}
for (const toplevel of all)
if (byAddress[String(toplevel.address)])
ordered.push(toplevel);
return ordered;
}
// Starts the gesture if it is not already running, then steps. The first
// Tab lands on the PREVIOUS window rather than the current one, which is
// what every other implementation of this gesture does.
function step(forward: bool): void {
if (!root.open) {
root.windows = root.orderedWindows();
if (root.windows.length < 2)
return;
root.open = true;
root.index = forward ? 1 : root.windows.length - 1;
return;
}
if (root.windows.length === 0)
return;
const count = root.windows.length;
root.index = forward
? (root.index + 1) % count
: (root.index - 1 + count) % count;
}
// Runs on every Super release in the session, so it does as little as
// possible when no switch is in progress.
function commit(): void {
if (!root.open)
return;
const target = root.selected;
root.open = false;
root.windows = [];
root.index = 0;
if (target && target.wayland)
target.wayland.activate();
}
function cancel(): void {
root.open = false;
root.windows = [];
root.index = 0;
}
// Focus changes maintain the MRU order. This runs whether or not a switch
// is in progress, because ordinary clicking between windows is most of how
// the order is established.
Connections {
target: Hyprland
function onActiveToplevelChanged(): void {
const active = Hyprland.activeToplevel;
if (!active || !active.address)
return;
const address = String(active.address);
const next = [address];
for (const existing of root.recent)
if (existing !== address)
next.push(existing);
// Bounded: a session can accumulate a lot of closed addresses, and
// this list is only ever used to order what is currently open.
root.recent = next.slice(0, 64);
}
}
}
+37
View File
@@ -28,6 +28,7 @@ import qs.config
import qs.services import qs.services
import qs.modules.bar import qs.modules.bar
import qs.modules.dock import qs.modules.dock
import qs.modules.switcher
import qs.modules.overview import qs.modules.overview
import qs.modules.quicksettings import qs.modules.quicksettings
import qs.modules.notifications import qs.modules.notifications
@@ -68,6 +69,13 @@ ShellRoot {
Dock {} Dock {}
} }
// The Alt-Tab overlay. Present only while a switch is in progress; the
// Loader inside it keeps the window unbuilt the rest of the time.
Variants {
model: Quickshell.screens
WindowSwitcher {}
}
Variants { Variants {
model: Quickshell.screens model: Quickshell.screens
SignalGlass {} SignalGlass {}
@@ -103,6 +111,18 @@ ShellRoot {
// Every parameter AND the return type must be annotated, or Quickshell // Every parameter AND the return type must be annotated, or Quickshell
// silently declines to register the function — it will not warn you. // silently declines to register the function — it will not warn you.
// Driven entirely from keybinds: Super+Tab steps, and a bind on Super
// RELEASE commits. `commit` therefore runs on every Super release in the
// session, so it returns immediately when no switch is open.
IpcHandler {
target: "switcher"
function next(): void { WindowSwitcherState.step(true); }
function previous(): void { WindowSwitcherState.step(false); }
function commit(): void { WindowSwitcherState.commit(); }
function cancel(): void { WindowSwitcherState.cancel(); }
}
IpcHandler { IpcHandler {
target: "overview" target: "overview"
function toggle(): void { ShellState.toggle("overview"); } function toggle(): void { ShellState.toggle("overview"); }
@@ -165,6 +185,23 @@ ShellRoot {
function repair(id: string): bool { return Health.repair(id, true); } function repair(id: string): bool { return Health.repair(id, true); }
} }
// Health checks the wallpaper service through the same typed IPC boundary
// as capture and clipboard. This is deliberately read-only: choosing an
// image remains an explicit Settings action.
IpcHandler {
target: "wallpaper"
function refresh(): void { Wallpaper.refreshActive(); }
function status(): string {
return JSON.stringify({
active: Wallpaper.active,
configured: Wallpaper.configured,
availableCount: Wallpaper.available.length,
lastError: Wallpaper.lastError
});
}
}
// A small diagnostics surface doubles as a deterministic contract harness. // A small diagnostics surface doubles as a deterministic contract harness.
// Real producers call StatusEvents.publish() directly; fixtures never run // Real producers call StatusEvents.publish() directly; fixtures never run
// unless explicitly requested over IPC by the test suite. // unless explicitly requested over IPC by the test suite.
+32
View File
@@ -89,6 +89,38 @@ elif [ -d "$PANAMA_DOT/tmux/themes" ]; then
log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME" log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME"
fi fi
# hyprlock.conf is generated from a template on every colour scheme change and
# is not committed. Seed it so the FIRST lock of a fresh install is themed --
# without it hyprlock falls back to its own defaults, which is a bare grey
# screen with none of this desktop's identity, and the first time anyone would
# find out is when they walked away from the machine.
HYPRLOCK_TEMPLATE="$PANAMA_DOT/hypr/hyprlock.conf.template"
HYPRLOCK_CONF="$PANAMA_DOT/hypr/hyprlock.conf"
if [ -e "$HYPRLOCK_CONF" ]; then
log "Keeping existing hyprlock config at $HYPRLOCK_CONF"
elif [ -r "$HYPRLOCK_TEMPLATE" ]; then
lock_scheme="dark"
lock_prefs="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
if [ -r "$lock_prefs" ]; then
lock_stored="$(jq -r '.colorScheme // "dark"' "$lock_prefs" 2>/dev/null || echo dark)"
[ "$lock_stored" = "light" ] && lock_scheme="light"
fi
if [ "$lock_scheme" = "light" ]; then
sed -e "s/@FG@/55, 96, 191/g" -e "s/@MUTED@/97, 114, 176/g" \
-e "s/@ACCENT@/46, 125, 233/g" -e "s/@ERROR@/245, 42, 101/g" \
-e "s/@BG@/225, 226, 231/g" -e "s/@FIELD@/208, 213, 227/g" \
-e "s/@MUTED_HEX@/6172b0/g" -e "s/@ERROR_HEX@/f52a65/g" \
"$HYPRLOCK_TEMPLATE" > "$HYPRLOCK_CONF"
else
sed -e "s/@FG@/200, 211, 245/g" -e "s/@MUTED@/130, 139, 184/g" \
-e "s/@ACCENT@/130, 170, 255/g" -e "s/@ERROR@/255, 117, 127/g" \
-e "s/@BG@/34, 36, 54/g" -e "s/@FIELD@/46, 47, 61/g" \
-e "s/@MUTED_HEX@/828bb8/g" -e "s/@ERROR_HEX@/ff757f/g" \
"$HYPRLOCK_TEMPLATE" > "$HYPRLOCK_CONF"
fi
log "Seeded hyprlock $lock_scheme theme → $HYPRLOCK_CONF"
fi
# btop reads themes from its own config directory, but OWNS btop.conf -- it # btop reads themes from its own config directory, but OWNS btop.conf -- it
# rewrites that file on exit -- so only the theme files are exposed, per file, # rewrites that file on exit -- so only the theme files are exposed, per file,
# and the config itself is left to btop. panama-theme-apps edits the single # and the config itself is left to btop. panama-theme-apps edits the single
@@ -33,6 +33,9 @@ done
assert_contains 'title: "Default applications"' assert_contains 'title: "Default applications"'
assert_contains 'title: "User autostart"' assert_contains 'title: "User autostart"'
assert_contains 'title: "Compositor autostart"' assert_contains 'title: "Compositor autostart"'
assert_contains 'AutostartAppPicker {'
assert_contains 'DefaultApps.addAutostart('
assert_contains 'label: "Add an application"'
assert_contains 'categories' assert_contains 'categories'
assert_contains 'genericName' assert_contains 'genericName'
assert_contains '.sort(' assert_contains '.sort('
@@ -123,4 +126,14 @@ fi
[[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \ [[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \
|| fail 'default and autostart rows are not both whole-row activatable' || fail 'default and autostart rows are not both whole-row activatable'
picker="$project_root/config/dot/quickshell/modules/settings/AutostartAppPicker.qml"
qmldir="$project_root/config/dot/quickshell/modules/settings/qmldir"
[[ -f "$picker" ]] || fail 'autostart application picker is missing'
rg -Fq 'required property var existing' "$picker" \
|| fail 'autostart picker cannot exclude existing entries'
rg -Fq 'signal picked(string id)' "$picker" \
|| fail 'autostart picker does not emit a validated desktop id'
rg -q '^AutostartAppPicker 1\.0 AutostartAppPicker\.qml$' "$qmldir" \
|| fail 'autostart picker is not registered in the Settings module'
printf 'applications settings contract: PASS\n' printf 'applications settings contract: PASS\n'
+24
View File
@@ -29,6 +29,7 @@ assert_service_contains 'property string lastError'
assert_service_contains 'function refresh(): void' assert_service_contains 'function refresh(): void'
assert_service_contains 'function setDefault(role: string, desktopId: string): void' assert_service_contains 'function setDefault(role: string, desktopId: string): void'
assert_service_contains 'function setAutostart(desktopId: string, enabled: bool): void' assert_service_contains 'function setAutostart(desktopId: string, enabled: bool): void'
assert_service_contains 'function addAutostart(desktopId: string): void'
assert_service_contains 'DesktopEntries.applications.values' assert_service_contains 'DesktopEntries.applications.values'
if rg --quiet 'command\s*:\s*"' "$service"; then if rg --quiet 'command\s*:\s*"' "$service"; then
fail 'Process command must be an argument array' fail 'Process command must be an argument array'
@@ -227,4 +228,27 @@ if $helper set-autostart 'hyprland:1' false >/dev/null 2>&1; then
fail 'read-only compositor entry was accepted for mutation' fail 'read-only compositor entry was accepted for mutation'
fi fi
$helper add-autostart org.mozilla.firefox.desktop
firefox_autostart="$config_home/autostart/org.mozilla.firefox.desktop"
[[ -f "$firefox_autostart" && ! -L "$firefox_autostart" ]] \
|| fail 'adding an installed application did not create a regular user autostart entry'
rg --quiet '^Name=Firefox$' "$firefox_autostart" \
|| fail 'adding an application did not preserve its desktop entry'
rg --quiet '^Hidden=false$' "$firefox_autostart" \
|| fail 'a newly added application was not enabled'
[[ "$(rg --count '^Hidden=' "$firefox_autostart")" == "1" ]] \
|| fail 'adding an application wrote more than one Hidden key'
$helper set-autostart org.mozilla.firefox.desktop false
$helper add-autostart org.mozilla.firefox.desktop
rg --quiet '^Hidden=false$' "$firefox_autostart" \
|| fail 'adding an existing disabled application did not re-enable it'
if $helper add-autostart org.example.Missing.desktop >/dev/null 2>&1; then
fail 'an undiscovered application was accepted for autostart'
fi
if $helper add-autostart ../escape.desktop >/dev/null 2>&1; then
fail 'an unsafe desktop id was accepted for autostart'
fi
printf 'default apps contract: PASS\n' printf 'default apps contract: PASS\n'
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env bash
# Desktop style stays split across three boundaries that can all be checked
# without starting the shell: the read-only icon catalog, the preference/schema
# wiring, and the QML surfaces that consume it. The catalog runs only against
# disposable XDG roots so this contract never reads or changes the live desktop.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-desktop-style"
service="$repo_dir/config/dot/quickshell/services/DesktopStyle.qml"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
appearance="$repo_dir/config/dot/quickshell/modules/settings/AppearancePage.qml"
mouse="$repo_dir/config/dot/quickshell/modules/settings/MousePage.qml"
accessibility="$repo_dir/config/dot/quickshell/services/Accessibility.qml"
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
env_lua="$repo_dir/config/dot/hypr/env.lua"
looks_lua="$repo_dir/config/dot/hypr/looks.lua"
fail() {
printf 'desktop style contract: %s\n' "$1" >&2
exit 1
}
for file in "$helper" "$service" "$schema" "$appearance" "$mouse" \
"$accessibility" "$search" "$env_lua" "$looks_lua"; do
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
done
[[ -x "$helper" ]] || fail 'desktop-style catalog is not executable'
# ── Read-only catalog semantics ─────────────────────────────────────────────
fixture="$(mktemp -d /tmp/panama-desktop-style.XXXXXX)"
trap 'rm -rf "$fixture"' EXIT
home="$fixture/home"
data_home="$fixture/data-home"
data_one="$fixture/data-one"
data_two="$fixture/data-two"
mkdir -p "$home" \
"$data_home/icons/CursorOnly/cursors" \
"$data_one/icons/IconOnly/16x16/apps" \
"$data_one/icons/Both/cursors" \
"$data_one/icons/Both/scalable/apps" \
"$data_one/icons/EmptyDirectories" \
"$data_two/icons/Both/cursors" \
"$data_two/icons/Both/scalable/apps" \
"$data_two/icons/NoIndex/16x16/apps"
cat >"$data_home/icons/CursorOnly/index.theme" <<'EOF'
[Icon Theme]
Name=Cursor only
Directories=
EOF
cat >"$data_one/icons/IconOnly/index.theme" <<'EOF'
[Icon Theme]
Name=Icon only
Directories=16x16/apps
EOF
cat >"$data_one/icons/Both/index.theme" <<'EOF'
[Icon Theme]
Name=Both
Directories=scalable/apps
EOF
cat >"$data_one/icons/EmptyDirectories/index.theme" <<'EOF'
[Icon Theme]
Name=Not an icon catalog entry
Directories=
EOF
cp "$data_one/icons/Both/index.theme" "$data_two/icons/Both/index.theme"
catalog="$(
HOME="$home" \
XDG_DATA_HOME="$data_home" \
XDG_DATA_DIRS="$data_one:$data_two" \
"$helper"
)" || fail 'catalog helper failed against disposable XDG roots'
jq -e '
type == "object"
and (keys | sort) == ["cursorThemes", "iconThemes"]
and .cursorThemes == ["Both", "CursorOnly"]
and .iconThemes == ["Both", "IconOnly"]
and (all(.cursorThemes[]; type == "string" and length > 0))
and (all(.iconThemes[]; type == "string" and length > 0))
' <<<"$catalog" >/dev/null \
|| fail "catalog JSON shape or theme classification is wrong: $catalog"
if HOME="$home" XDG_DATA_HOME="$data_home" XDG_DATA_DIRS="$data_one:$data_two" \
"$helper" "$fixture/not-an-xdg-root" >/dev/null 2>&1; then
fail 'catalog accepted a caller-supplied path'
fi
# ── Schema, compositor replay, service, and UI wiring ───────────────────────
python3 - "$schema" <<'PY' || fail 'desktop-style schema entries are missing or malformed'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
expected = {
"cursorTheme": ("string", '"oreo_blue_cursors"', "themes"),
"iconTheme": ("string", '"Adwaita"', "themes"),
"applicationFont": ("string", '"Adwaita Sans"', "typography"),
"applicationFontSize": ("int", "11", "typography"),
"documentFont": ("string", '"Adwaita Sans"', "typography"),
"documentFontSize": ("int", "12", "typography"),
"monospaceFont": ("string", '"VictorMono Nerd Font"', "typography"),
"monospaceFontSize": ("int", "10", "typography"),
"fontHinting": ("enum", '"slight"', "typography"),
"fontAntialiasing": ("enum", '"rgba"', "typography"),
"titlebarButtonSide": ("enum", '"right"', "titlebar"),
"titlebarMaximizeButton": ("bool", "false", "titlebar"),
"titlebarDoubleClick": ("enum", '"toggle-maximize"', "titlebar"),
"middleClickPaste": ("bool", "true", "pointer"),
}
def block_for(key: str) -> str:
match = re.search(r"\{\s*\n\s*key:\s*\"" + re.escape(key) + r"\".*?\n\s{8}\}", text, re.S)
if not match:
raise SystemExit(f"missing {key}")
return match.group(0)
for key, (kind, default, group) in expected.items():
block = block_for(key)
if not re.search(rf'type:\s*"{re.escape(kind)}"', block):
raise SystemExit(f"{key} has wrong type")
if not re.search(rf'def:\s*{re.escape(default)}', block):
raise SystemExit(f"{key} has wrong default")
if not re.search(rf'group:\s*"{re.escape(group)}"', block):
raise SystemExit(f"{key} has wrong group")
enum_values = {
"fontHinting": ["none", "slight", "medium", "full"],
"fontAntialiasing": ["none", "grayscale", "rgba"],
"titlebarButtonSide": ["left", "right"],
"titlebarDoubleClick": ["toggle-maximize", "none"],
}
for key, values in enum_values.items():
block = block_for(key)
actual = re.findall(r'value:\s*"([^"]+)"', block)
if actual != values:
raise SystemExit(f"{key} options are {actual}, expected {values}")
middle = block_for("middleClickPaste")
for fragment in (
'path: ["misc", "middle_click_paste"]',
'option: "misc:middle_click_paste"',
'readAs: "bool"',
):
if fragment not in middle:
raise SystemExit(f"middleClickPaste is missing {fragment}")
PY
rg -q 'prefs\.get\("cursorTheme", "oreo_blue_cursors"\)' "$env_lua" \
|| fail 'Hyprland environment does not replay cursorTheme'
rg -q 'middle_click_paste\s*=\s*prefs\.get\("middleClickPaste", true\)' "$looks_lua" \
|| fail 'Hyprland misc does not replay middleClickPaste'
rg -q 'DesktopPreferences\.get\("cursorTheme"\)' "$accessibility" \
|| fail 'Accessibility does not use the stored cursor theme'
! rg -q 'gsettings.*cursor-theme|themeQuery' "$accessibility" \
|| fail 'Accessibility still queries cursor-theme from gsettings'
for needle in \
'property var cursorThemes' \
'property var iconThemes' \
'DesktopPreferences.revision' \
'Fonts.interfaceFonts' \
'Fonts.monospaceFonts' \
'gtk-enable-primary-paste' \
'button-layout' \
'action-double-click-titlebar'; do
rg -Fq "$needle" "$service" || fail "DesktopStyle is missing $needle"
done
! rg -q 'sh -c|bash -c' "$service" \
|| fail 'DesktopStyle routes gsettings through a shell'
for group in typography themes titlebar; do
rg -q "\"$group\": \"appearance\"" "$search" \
|| fail "settings search does not route $group to Appearance"
done
rg -q '"pointer": "mouse"' "$search" \
|| fail 'settings search no longer routes pointer controls to Mouse'
for needle in \
'title: "Application typography"' \
'setting: "applicationFontSize"' \
'setting: "documentFontSize"' \
'setting: "monospaceFontSize"' \
'setting: "fontHinting"' \
'setting: "fontAntialiasing"' \
'title: "Icons & pointer"' \
'DesktopStyle.cursorThemes' \
'DesktopStyle.iconThemes' \
'title: "Titlebars"' \
'setting: "titlebarButtonSide"' \
'setting: "titlebarMaximizeButton"' \
'setting: "titlebarDoubleClick"'; do
rg -Fq "$needle" "$appearance" || fail "Appearance is missing $needle"
done
! rg -q 'setting: "titlebarMinimize|GTK theme|Shell theme' "$appearance" \
|| fail 'Appearance exposes an inert or separately-owned theme control'
rg -q 'setting: "middleClickPaste"' "$mouse" \
|| fail 'Mouse does not expose middle-click paste'
rg -q 'GTK.*Wayland|Wayland.*GTK' "$mouse" \
|| fail 'Mouse does not explain the GTK and Wayland scope honestly'
printf 'desktop style contract: PASS\n'
+7
View File
@@ -4,6 +4,13 @@ set -euo pipefail
case "${1:-}" in case "${1:-}" in
--version) printf '%s\n' "${PANAMA_DOCTOR_FIXTURE_QS_VERSION:-Quickshell 0.2.0}" ;; --version) printf '%s\n' "${PANAMA_DOCTOR_FIXTURE_QS_VERSION:-Quickshell 0.2.0}" ;;
list)
case ",${PANAMA_DOCTOR_FIXTURE_PROCESSES:-}," in
*,qs:duplicate,*) printf '%s\n' 'Instance fixture-one:' 'Instance fixture-two:' ;;
*,qs:missing,*) ;;
*) printf '%s\n' 'Instance fixture-one:' ;;
esac
;;
ipc) ipc)
if [[ "${PANAMA_DOCTOR_FIXTURE_QS:-ready}" == "malformed" ]]; then if [[ "${PANAMA_DOCTOR_FIXTURE_QS:-ready}" == "malformed" ]]; then
printf 'fixture-secret-token AA:BB:CC:DD:EE:FF\n' printf 'fixture-secret-token AA:BB:CC:DD:EE:FF\n'
@@ -47,6 +47,8 @@ fail() {
[[ -f "$service" ]] || fail 'Health.qml is missing' [[ -f "$service" ]] || fail 'Health.qml is missing'
[[ -f "$source_harness" ]] || fail 'health harness is missing' [[ -f "$source_harness" ]] || fail 'health harness is missing'
[[ -f "$shell" ]] || fail 'shell.qml is missing' [[ -f "$shell" ]] || fail 'shell.qml is missing'
rg -q 'target: "wallpaper"' "$shell" \
|| fail 'wallpaper health target is not exported by the shell'
# shell.qml is not started here: it is the active desktop shell. Keep this # shell.qml is not started here: it is the active desktop shell. Keep this
# contract static while pinning the typed, redacted IPC boundary it exports. # contract static while pinning the typed, redacted IPC boundary it exports.
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# The lock screen follows the colour scheme.
#
# It did not. hyprlock.conf shipped with Tokyo Night Moon hardcoded in six
# places, so choosing light mode left the one screen a user sees most often
# stubbornly dark. Every other surface -- kitty, GTK, the launcher, btop, tmux,
# neovim -- had been taught to follow the scheme; this was the last one.
#
# It is also the worst place to discover a theming bug, because you find out
# while locked out of the machine and cannot fix it from there. Hence a test.
#
# Generated into a fixture, never the live config: this contract must not
# retheme the lock screen of the desktop it is running on.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
template="$repo_dir/config/dot/hypr/hyprlock.conf.template"
theme_apps="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
fail() {
printf 'lock screen theme contract: %s\n' "$1" >&2
exit 1
}
[[ -r "$template" ]] || fail 'hyprlock.conf.template is missing, so nothing generates the lock screen'
# The committed template must carry no literal colours. One left behind is a
# colour that silently stays dark in light mode -- exactly the original bug.
literal="$(grep -vE '^\s*#' "$template" | grep -oE 'rgba\([0-9]+, *[0-9]+, *[0-9]+' || true)"
[[ -z "$literal" ]] \
|| fail "the template still contains hardcoded colours, which will not follow the scheme: $literal"
fixture="$(mktemp -d /tmp/panama-lockscreen.XXXXXX)"
trap 'rm -rf "$fixture"' EXIT
mkdir -p "$fixture/hypr"
cp "$template" "$fixture/hypr/"
for scheme in dark light; do
XDG_CONFIG_HOME="$fixture" "$theme_apps" "$scheme" >/dev/null 2>&1
generated="$fixture/hypr/hyprlock.conf"
[[ -r "$generated" ]] || fail "no hyprlock.conf was generated for $scheme"
# An unsubstituted placeholder is not a parse error to hyprlock; it is an
# invalid colour it quietly ignores, falling back to its own default.
leftover="$(grep -oE '@[A-Z_]+@' "$generated" || true)"
[[ -z "$leftover" ]] \
|| fail "$scheme left placeholders unsubstituted: $leftover"
# Every colour hyprlock is given must be a complete decimal triple. It
# takes rgba(r, g, b, a), NOT hex, and a hex value here is silently ignored.
while read -r colour; do
[[ -n "$colour" ]] || continue
grep -qE '^rgba\([0-9]{1,3}, [0-9]{1,3}, [0-9]{1,3}$' <<<"$colour" \
|| fail "$scheme produced a malformed colour: $colour"
done < <(grep -vE '^\s*#' "$generated" | grep -oE 'rgba\([^)]*' | sed 's/,[^,]*$//')
# The Pango markup values are hex, and hyprlock wants them doubled-hashed.
while read -r pango; do
[[ -n "$pango" ]] || continue
grep -qE '^##[0-9a-f]{6}$' <<<"$pango" \
|| fail "$scheme produced malformed Pango markup colour: $pango"
done < <(grep -vE '^\s*#' "$generated" | grep -oE '##[0-9a-fA-F]{6}')
# The whole background block, not a fixed number of lines after it: the
# colour sits near the end, after the blur and noise settings.
background="$(awk '/^background \{/,/^\}/' "$generated" \
| grep -vE '^\s*#' | grep -oE 'color = rgba\([0-9]+' | grep -oE '[0-9]+$' | head -1)"
[[ -n "$background" ]] || fail "$scheme produced no background colour"
# The point of the whole exercise: a light lock screen must actually be
# light. 128 splits the two cleanly for these palettes.
if [[ "$scheme" == "light" ]]; then
(( background > 128 )) \
|| fail "light mode produced a DARK lock screen background (red channel $background) -- the original bug"
else
(( background < 128 )) \
|| fail "dark mode produced a LIGHT lock screen background (red channel $background)"
fi
done
# The two schemes must actually differ, or the substitution is a no-op that
# passes every check above.
XDG_CONFIG_HOME="$fixture" "$theme_apps" dark >/dev/null 2>&1
dark_hash="$(sha256sum "$fixture/hypr/hyprlock.conf" | cut -d' ' -f1)"
XDG_CONFIG_HOME="$fixture" "$theme_apps" light >/dev/null 2>&1
light_hash="$(sha256sum "$fixture/hypr/hyprlock.conf" | cut -d' ' -f1)"
[[ "$dark_hash" != "$light_hash" ]] \
|| fail 'the light and dark lock screens are byte-identical, so the scheme is not being applied'
printf 'lock screen theme contract: PASS\n'
+4 -1
View File
@@ -216,11 +216,14 @@ jq -e '.checks[] | select(.id == "panama.caffeine")
|| fail 'Caffeine detail exposed inhibitor PIDs' || fail 'Caffeine detail exposed inhibitor PIDs'
# Process counts use only exact authored names and never expose command lines or PIDs. # Process counts use only exact authored names and never expose command lines or PIDs.
duplicated_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=quickshell:duplicate run_doctor --json)" duplicated_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=qs:duplicate run_doctor --json)"
check_status "$duplicated_processes" panama.processes warning check_status "$duplicated_processes" panama.processes warning
! jq -r '.checks[] | select(.id == "panama.processes") | .detail' <<<"$duplicated_processes" | grep -Eq '[0-9]{3,}' \ ! jq -r '.checks[] | select(.id == "panama.processes") | .detail' <<<"$duplicated_processes" | grep -Eq '[0-9]{3,}' \
|| fail 'process detail exposed a PID' || fail 'process detail exposed a PID'
missing_quickshell_process="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=qs:missing run_doctor --json)"
check_status "$missing_quickshell_process" panama.processes error
# Invalid output for a non-Quickshell authored process is not a normal zero # Invalid output for a non-Quickshell authored process is not a normal zero
# count that can be hidden by the running Quickshell process. # count that can be hidden by the running Quickshell process.
malformed_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=hyprpaper:malformed run_doctor --json)" malformed_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=hyprpaper:malformed run_doctor --json)"
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Anything instantiated per screen must take its screen from `modelData`.
#
# Variants supplies each delegate a `modelData` holding the screen. A component
# that instead declares `required property var screen` is constructed, never
# receives a screen, and its window silently never maps -- with nothing logged,
# no error, and no visible failure beyond the surface simply not being there.
#
# shell.qml has warned about this in a comment since the Bar hit it. The comment
# did not stop the window switcher hitting it again, which is the argument for a
# test: the failure is invisible, so review does not catch it either.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
shell_file="$repo_dir/config/dot/quickshell/shell.qml"
modules="$repo_dir/config/dot/quickshell/modules"
fail() {
printf 'per-screen surface contract: %s\n' "$1" >&2
exit 1
}
[[ -r "$shell_file" ]] || fail "cannot read shell.qml"
# The component named inside each `Variants { model: Quickshell.screens ... }`.
delegates="$(awk '
/Variants \{/ { inside = 1; next }
inside && /model: Quickshell.screens/ { armed = 1; next }
armed && /^[[:space:]]*[A-Z][A-Za-z]* *\{/ {
match($0, /[A-Z][A-Za-z]*/)
print substr($0, RSTART, RLENGTH)
armed = 0; inside = 0
}
' "$shell_file" | sort -u)"
[[ -n "$delegates" ]] || fail 'found no per-screen delegates -- this contract is not reading shell.qml correctly'
checked=0
while read -r name; do
[[ -n "$name" ]] || continue
file="$(find "$modules" -name "$name.qml" -print -quit 2>/dev/null)"
[[ -n "$file" ]] || fail "shell.qml instantiates $name per screen, but $name.qml was not found"
if grep -qE '^\s*required property var screen\b' "$file"; then
fail "$name declares 'required property var screen', but Variants supplies modelData -- the window is built and never maps, silently. Use 'property var modelData' and bind screen to it, as Bar and Dock do."
fi
grep -qE '^\s*property var modelData' "$file" \
|| fail "$name is instantiated per screen but never declares 'property var modelData', so it cannot know which screen it is on"
grep -qE 'screen: (root\.)?modelData' "$file" \
|| fail "$name declares modelData but never binds a screen to it"
checked=$((checked + 1))
done <<<"$delegates"
printf 'per-screen surface contract: PASS (%d per-screen surfaces)\n' "$checked"
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Every "open the settings for this" jump must land somewhere real.
#
# ShellState.openSettings() validates its argument against an allow-list and
# falls back to Home for anything unknown. That fallback is sensible and it is
# also completely silent: a typo, or a page renamed later, turns a right-click
# into "opens Settings on the wrong page" with nothing logged and no error.
#
# Before this, exactly four places in the entire shell could reach Settings, so
# the risk was small. The bar now offers a jump on every widget, which makes the
# fallback worth guarding.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
modules="$repo_dir/config/dot/quickshell/modules"
fail() {
printf 'settings jump contract: %s\n' "$1" >&2
exit 1
}
allowed_line="$(grep -m1 'const allowed = \[' "$shell_state")" \
|| fail 'could not find the allow-list in ShellState'
jumps="$(grep -rhoE 'openSettings\("[a-z-]+"\)' "$modules" 2>/dev/null \
| sed 's/openSettings("//; s/")//' | sort -u)"
[[ -n "$jumps" ]] || fail 'found no settings jumps at all -- this contract is not reading the modules correctly'
count=0
while read -r page; do
[[ -n "$page" ]] || continue
grep -qF "\"$page\"" <<<"$allowed_line" \
|| fail "a jump opens \"$page\", which ShellState does not allow -- openSettings falls back to Home silently, so this reads as a right-click that goes to the wrong page"
count=$((count + 1))
done <<<"$jumps"
# The bar is where a person looks first, and Pill has offered a right-click
# signal all along that nothing connected -- so the gesture did nothing on every
# widget in the bar. Anything built on Pill that can be configured should say so.
for widget in Clock WeatherWidget VitalsWidget StatusCluster MediaWidget; do
file="$modules/bar/$widget.qml"
[[ -r "$file" ]] || continue
grep -q 'onSecondaryActivated' "$file" \
|| fail "$widget has no right-click jump; Pill routes right-click to secondaryActivated, so leaving it unconnected makes the gesture silently inert"
done
printf 'settings jump contract: PASS (%d distinct destinations)\n' "$count"
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# A setting has one schema-routed owner. A second page may mirror it only when
# this contract names the exact owner and mirror set. The same ownership rule
# keeps colour scheme propagation away from the focused Prism border.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
pages_dir="$repo_dir/config/dot/quickshell/modules/settings"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml"
looks="$repo_dir/config/dot/hypr/looks.lua"
readme="$pages_dir/README.md"
fail() {
printf 'settings ownership contract: %s\n' "$1" >&2
exit 1
}
python3 - "$pages_dir" "$schema" "$search" <<'PY' \
|| fail 'page ownership or intentional mirrors drifted'
from __future__ import annotations
import re
import sys
from collections import defaultdict
from pathlib import Path
pages_dir = Path(sys.argv[1])
schema_text = Path(sys.argv[2]).read_text(encoding="utf-8")
search_text = Path(sys.argv[3]).read_text(encoding="utf-8")
expected = {
"animationsEnabled": {"owner": "appearance", "mirrors": {"accessibility"}},
"cursorInactiveTimeout": {"owner": "mouse", "mirrors": {"accessibility"}},
"cursorSize": {"owner": "accessibility", "mirrors": {"mouse"}},
"inactiveOpacity": {"owner": "appearance", "mirrors": {"accessibility"}},
"lockMinutes": {"owner": "power", "mirrors": {"privacy"}},
"lockOnSleep": {"owner": "power", "mirrors": {"privacy"}},
}
def strip_comments(text: str) -> str:
return re.sub(r"//.*", "", text)
def page_name(path: Path) -> str:
stem = path.stem.removesuffix("Page")
return re.sub(r"(?<!^)(?=[A-Z])", "-", stem).lower()
rows: dict[str, list[str]] = defaultdict(list)
row_pattern = re.compile(
r"(?:ToggleRow|SliderRow|ChoiceRow|TextEntryRow|TimeOfDayRow)\s*\{(?P<body>.*?)\}",
re.S,
)
for page_path in pages_dir.glob("*Page.qml"):
text = strip_comments(page_path.read_text(encoding="utf-8"))
for match in row_pattern.finditer(text):
setting = re.search(r'setting\s*:\s*"([^"]+)"', match.group("body"))
if setting:
rows[setting.group(1)].append(page_name(page_path))
duplicates = {key: set(pages) for key, pages in rows.items() if len(pages) > 1}
if set(duplicates) != set(expected):
raise SystemExit(
f"duplicate keys are {sorted(duplicates)}, expected {sorted(expected)}"
)
group_pages = dict(re.findall(r'"([^"]+)"\s*:\s*"([^"]+)"', search_text))
for key, policy in expected.items():
wanted_pages = {policy["owner"], *policy["mirrors"]}
if duplicates[key] != wanted_pages:
raise SystemExit(f"{key} appears on {sorted(duplicates[key])}, expected {sorted(wanted_pages)}")
block = re.search(
r'\{\s*\n\s*key:\s*"' + re.escape(key) + r'"(?P<body>.*?)\n\s*\}',
schema_text,
re.S,
)
if not block:
raise SystemExit(f"schema entry missing for {key}")
group = re.search(r'group:\s*"([^"]+)"', block.group("body"))
if not group:
raise SystemExit(f"schema group missing for {key}")
routed = group_pages.get(group.group(1))
if routed != policy["owner"]:
raise SystemExit(
f"{key} routes to {routed!r}, expected primary owner {policy['owner']!r}"
)
PY
for needle in \
'## Setting ownership' \
'one primary page' \
'Intentional mirrors' \
'`animationsEnabled`' \
'`cursorInactiveTimeout`' \
'`cursorSize`' \
'`inactiveOpacity`' \
'`lockMinutes`' \
'`lockOnSleep`' \
'scheme-relative role'; do
rg -Fq "$needle" "$readme" || fail "README is missing $needle"
done
python3 - "$scheme" "$looks" <<'PY' \
|| fail 'scheme-relative border ownership drifted'
import re
import sys
scheme = open(sys.argv[1], encoding="utf-8").read()
looks = open(sys.argv[2], encoding="utf-8").read()
dark = re.search(r'property string inactiveBorderDark:\s*"([^"]+)"', scheme)
light = re.search(r'property string inactiveBorderLight:\s*"([^"]+)"', scheme)
effective = re.search(r'property string inactiveBorder:\s*root\.dark\s*\?\s*root\.inactiveBorderDark\s*:\s*root\.inactiveBorderLight', scheme)
if not dark or not light or not effective:
raise SystemExit("ColorScheme does not expose the two inactive-border roles")
if dark.group(1) not in looks or light.group(1) not in looks:
raise SystemExit("Hyprland startup values disagree with the live scheme roles")
without_comments = re.sub(r"//.*", "", scheme)
if re.search(r"(?<![A-Za-z_])active_border\b", without_comments):
raise SystemExit("ColorScheme writes the focused border")
if 'inactive_border = "${root.inactiveBorder}"' not in scheme:
raise SystemExit("ColorScheme does not apply its effective inactive role")
PY
printf 'settings ownership contract: PASS\n'
+6 -1
View File
@@ -50,7 +50,12 @@ PY
home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePage.qml" home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePage.qml"
require_row "$home_page" ChoiceRow temperatureUnit require_row "$home_page" ChoiceRow temperatureUnit
require_row "$home_page" SliderRow weatherRefreshMinutes require_row "$home_page" SliderRow weatherRefreshMinutes
require_row "$home_page" SliderRow vitalsIntervalMs # vitalsIntervalMs moved to Appearance, beside the toggles it governs. It sat
# on Home while showCpu/showMemory/showGpu sat on Appearance -- one concept
# across two pages, which the ownership rule forbids and which made a search
# for it open a page that did not contain it.
appearance_page="$repo_dir/config/dot/quickshell/modules/settings/AppearancePage.qml"
require_row "$appearance_page" SliderRow vitalsIntervalMs
notifications_page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml" notifications_page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml"
for setting in notificationTimeoutMs notificationTimeoutCriticalMs notificationHistoryLimit maxVisibleToasts; do for setting in notificationTimeoutMs notificationTimeoutCriticalMs notificationHistoryLimit maxVisibleToasts; do
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Common XKB behavior should be discoverable without hiding the raw option
# string from advanced users. This is source-only so it never remaps the live
# keyboard while the desktop is in use.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
page="$repo_dir/config/dot/quickshell/modules/settings/ShortcutsPage.qml"
input="$repo_dir/config/dot/hypr/input.lua"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
fail() {
printf 'xkb presets contract: %s\n' "$1" >&2
exit 1
}
for needle in \
'function currentXkbOption(prefix: string): string' \
'function setXkbOption(prefix: string, option: string): void' \
'label: "Caps Lock"' \
'value: "caps:escape_shifted_capslock"' \
'value: "caps:ctrl_modifier"' \
'label: "Compose key"' \
'value: "compose:ralt"' \
'label: "Layout switching"' \
'value: "grp:win_space_toggle"' \
'SystemSettings.commitPreference("keyboardOptions"' \
'TextEntryRow { setting: "keyboardOptions"'; do
rg -Fq "$needle" "$page" || fail "Shortcuts is missing $needle"
done
# Picking one category must replace only that category, preserving advanced
# options from every other group.
rg -Fq 'option.indexOf(prefix) !== 0' "$page" \
|| fail 'preset updates do not preserve unrelated XKB options'
rg -Fq 'kb_variant = prefs.get("keyboardVariant", "")' "$input" \
|| fail 'Hyprland does not replay the stored keyboard variant'
rg -Fq 'key: "keyboardOptions", type: "string", def: "caps:escape_shifted_capslock"' "$schema" \
|| fail 'the shipped XKB default no longer matches the migrated GNOME behavior'
rg -Fq 'kb_options = prefs.get("keyboardOptions", "caps:escape_shifted_capslock")' "$input" \
|| fail 'Hyprland does not replay the stored keyboard options'
printf 'xkb presets contract: PASS\n'