Shortcuts you invent, rules you write, gestures you own - all still just data

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-25 01:51:59 -04:00
parent f9e5d3f470
commit 06c53d6c21
48 changed files with 4749 additions and 140 deletions
+1 -1
View File
@@ -151,7 +151,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests ## Tests
176 of them, under `tests/`. Run the lot, or a subset by pattern: 177 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh ```sh
panama test # everything panama test # everything
+3 -1
View File
@@ -33,9 +33,11 @@ Don't "fix" them.
| `looks.lua` | Colors, blur, glow, shadows, animations, VRR, scanout | | `looks.lua` | Colors, blur, glow, shadows, animations, VRR, scanout |
| `input.lua` | Keyboard/mouse. Click-to-focus, like GNOME | | `input.lua` | Keyboard/mouse. Click-to-focus, like GNOME |
| `rules.lua` | Window rules, gaming rules, layer rules for the shell | | `rules.lua` | Window rules, gaming rules, layer rules for the shell |
| `keybinds.lua` | The full keymap | | `keybinds.lua` | The full keymap, including the custom shortcuts the Settings app stores |
| `actions.lua` | Named-action resolver: the whitelist tables that turn stored `{kind, target}` data into binds, gestures — never into free-form commands |
| `autostart.lua` | Session startup | | `autostart.lua` | Session startup |
| `overrides.lua` | Per-machine escape hatch, loaded last | | `overrides.lua` | Per-machine escape hatch, loaded last |
| `shaders/` | Whole-screen color-filter shaders (grayscale and the three color-blindness corrections) `looks.lua` maps the `colorFilter` setting onto |
| `hyprlock.conf` / `hypridle.conf` / `hyprpaper.conf` / `hyprtoolkit.conf` | Ecosystem tools (hyprlang) | | `hyprlock.conf` / `hypridle.conf` / `hyprpaper.conf` / `hyprtoolkit.conf` | Ecosystem tools (hyprlang) |
Validate any change without leaving your session: Validate any change without leaving your session:
+226
View File
@@ -0,0 +1,226 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Named actions
--
-- The one thing that makes it safe for settings.json to describe a shortcut.
--
-- Panama Settings lets a person invent a keyboard shortcut and assign a
-- four-finger gesture. Both are stored in the same user-editable JSON file the
-- rest of the desktop reads, and both have to end up as something the
-- compositor executes -- which is exactly the shape of every configuration
-- format that turned out to be a shell injection.
--
-- It is not one here, and this file is why. A stored action is DATA:
--
-- { kind = "app" | "shell" | "window", target = "<id>", label = "<text>" }
--
-- `kind` is an enum with three members. `target` is either a key of one of the
-- whitelist tables below -- whose values are literals written here, in Lua, by
-- a human -- or, for `app`, an identifier that has to match a character class
-- containing no shell metacharacter at all, and which is then quoted as a
-- single argv element for panama-launch rather than pasted into a command.
--
-- So the worst a hand-edited (or maliciously written) settings file can do is
-- pick a different entry from a list that is fixed at ship time, or launch an
-- application by id. It cannot introduce a command. There is no path from a
-- stored string to a new exec string; the table lookups are the only source of
-- one.
--
-- Everything invalid returns nil and the caller skips the bind or gesture --
-- the prefs.lua philosophy: never raise, never guess. A malformed entry costs
-- one shortcut, never the keymap and never the compositor.
--
-- Required by keybinds.lua (custom shortcuts) and input.lua (four-finger
-- gestures). services/Keybinds.qml's describeAction() is the QML mirror of the
-- vocabulary below; the two lists have to be edited together.
-- ─────────────────────────────────────────────────────────────────────────────
local actions = {}
-- ── Shell verbs ─────────────────────────────────────────────────────────────
--
-- Every entry is a command string written HERE. Nothing stored anywhere else
-- contributes a character to one; `target` only chooses which of these to use.
--
-- The `qs ipc call` targets and functions are the ones quickshell/shell.qml
-- actually registers -- an IpcHandler silently declines to register a function
-- it cannot type-check, so a verb invented here would be a shortcut that does
-- nothing. Checked against shell.qml, not remembered.
--
-- The three that are not IPC (`launcher`, `color-picker`, `lock`) are the same
-- literal commands the shipped binds in keybinds.lua use, for the same reason
-- they use them: they are the tools, not the shell.
local SHELL = {
["dnd-toggle"] = { label = "Do Not Disturb", command = "qs ipc call notifications dnd" },
["notifications"] = { label = "Notifications", command = "qs ipc call notifications toggle" },
["overview"] = { label = "Overview", command = "qs ipc call overview toggle" },
["launcher"] = { label = "Launcher", command = "vicinae toggle" },
["clipboard"] = { label = "Clipboard history", command = "qs ipc call clipboard toggle" },
["screenshot"] = { label = "Screenshot / record", command = "qs ipc call capture open" },
["screenshot-screen"] = { label = "Screenshot: whole screen", command = "qs ipc call capture screenNow" },
["screenshot-window"] = { label = "Screenshot: window", command = "qs ipc call capture windowNow" },
["screen-intelligence"]= { label = "Screen Intelligence", command = "qs ipc call screen-intelligence open" },
["color-picker"] = { label = "Color picker", command = "hyprpicker -a -f hex" },
["quick-settings"] = { label = "Quick settings", command = "qs ipc call quicksettings toggle" },
["settings"] = { label = "Settings", command = "qs ipc call settings toggle" },
["cheatsheet"] = { label = "Keyboard shortcuts", command = "qs ipc call cheatsheet toggle" },
["focus-session"] = { label = "Focus session", command = "qs ipc call focus reveal" },
["caffeine"] = { label = "Keep awake", command = "qs ipc call caffeine toggle" },
["night-light"] = { label = "Night Light", command = "qs ipc call night-light toggle" },
["activity"] = { label = "Activity", command = "qs ipc call activity toggle" },
["power-menu"] = { label = "Power menu", command = "qs ipc call powermenu toggle" },
["lock"] = { label = "Lock", command = "loginctl lock-session" },
}
-- ── Window verbs ────────────────────────────────────────────────────────────
--
-- Builders rather than dispatchers, so nothing is constructed for a verb that
-- is never chosen, and so a gesture builds its dispatcher when the fingers
-- move rather than holding one from config time.
--
-- `workspace:N` is not in the table: it is ten entries that differ by a number,
-- and the number is validated as 1..10 in `window_action` below.
local WINDOW = {
["float-toggle"] = { label = "Toggle float", build = function() return hl.dsp.window.float({ action = "toggle" }) end },
["fullscreen"] = { label = "Fullscreen", build = function() return hl.dsp.window.fullscreen({ mode = "fullscreen" }) end },
["pin"] = { label = "Pin window", build = function() return hl.dsp.window.pin({ action = "toggle" }) end },
}
-- Published so a contract can read the vocabulary without parsing this file,
-- and so the ten workspace verbs have one definition rather than two.
actions.shell_verbs = SHELL
actions.window_verbs = WINDOW
actions.workspace_min = 1
actions.workspace_max = 10
-- ── Targets ─────────────────────────────────────────────────────────────────
-- Letters, digits, and the four punctuation marks a desktop id actually uses.
-- Deliberately excludes every shell metacharacter, quote, slash and space, so
-- an id that passes cannot change the meaning of a command line even before it
-- is quoted -- the quoting below is the second lock on the same door.
local APP_TARGET = "^[A-Za-z0-9@._%-]+$"
local function valid_app_target(target)
return type(target) == "string"
and #target >= 1 and #target <= 128
and target:match(APP_TARGET) ~= nil
end
-- Single-quoted for the shell, with the one escape single quotes need. Same
-- function keybinds.lua uses for the go-to patterns, and used here for the
-- same reason: the value reaches the command as one argument.
local function shell_quote(value)
return "'" .. value:gsub("'", "'\\''") .. "'"
end
-- Google RE2 metacharacters, escaped so the id matches itself literally.
-- panama-launch takes a regular expression, and an unescaped "org.gnome.Files"
-- would also match "orgxgnomexFiles".
local function escape_regex(value)
return (value:gsub("[%^%$%(%)%%%.%[%]%*%+%-%?%{%}%|\\]", "\\%0"))
end
-- ── Resolution ──────────────────────────────────────────────────────────────
local launcher_bin = "$HOME/.local/share/Panama/bin/panama-launch"
-- The launch-or-focus path the shipped application keys use: raise the window
-- if it is already open, start it if it is not. The id is the class pattern
-- (anchored, escaped) and the thing to start; on Wayland an application's
-- desktop id and its window class are the same string often enough that this
-- is the right first guess, and the wrong guess costs a second window rather
-- than an error.
--
-- gtk-launch activates a desktop entry by id, which is what the applications
-- catalog in Settings offers -- a desktop id is not a binary and cannot be
-- exec'd directly.
local function app_action(target)
if not valid_app_target(target) then
return nil
end
local launch_command = table.concat({
launcher_bin,
"--class", shell_quote("^" .. escape_regex(target) .. "$"),
"--", "gtk-launch", shell_quote(target),
}, " ")
return function() return hl.dsp.exec_cmd(launch_command) end
end
local function shell_action(target)
if type(target) ~= "string" then
return nil
end
local verb = SHELL[target]
if verb == nil then
return nil
end
return function() return hl.dsp.exec_cmd(verb.command) end
end
local function window_action(target)
if type(target) ~= "string" then
return nil
end
local verb = WINDOW[target]
if verb ~= nil then
return verb.build
end
local index = target:match("^workspace:(%d+)$")
if index == nil then
return nil
end
local number = tonumber(index)
if number == nil or number < actions.workspace_min or number > actions.workspace_max then
return nil
end
return function() return hl.dsp.focus({ workspace = number }) end
end
local KINDS = {
app = app_action,
shell = shell_action,
window = window_action,
}
-- The builder for one stored entry, or nil when the entry is anything this
-- file does not recognise. Everything above funnels through here, so there is
-- exactly one place where a stored value becomes an action.
local function builder(entry)
if type(entry) ~= "table" then
return nil
end
local resolve = KINDS[entry.kind]
if resolve == nil then
return nil
end
return resolve(entry.target)
end
-- For hl.bind: the dispatcher itself, or nil.
function actions.dispatcher(entry)
local build = builder(entry)
if build == nil then
return nil
end
return build()
end
-- For hl.gesture: a function, which is what a gesture action has to be when it
-- is not one of Hyprland's own built-in names ("workspace" and friends).
function actions.gesture(entry)
local build = builder(entry)
if build == nil then
return nil
end
return function() hl.dispatch(build()) end
end
-- True when an entry resolves to something. Cheap enough to call twice; used
-- where the caller wants to check before it commits to emitting anything.
function actions.valid(entry)
return builder(entry) ~= nil
end
return actions
+31
View File
@@ -6,6 +6,7 @@
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs") local prefs = require("prefs")
local actions = require("actions")
hl.config({ hl.config({
input = { input = {
@@ -141,4 +142,34 @@ hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
hl.gesture({ fingers = 3, direction = "up", action = overview("open") }) hl.gesture({ fingers = 3, direction = "up", action = overview("open") })
hl.gesture({ fingers = 3, direction = "down", action = overview("close") }) hl.gesture({ fingers = 3, direction = "down", action = overview("close") })
-- ── Four-finger gestures ────────────────────────────────────────────────────
--
-- The three above are the desktop's, fixed. These four are the user's: each
-- holds a named action from settings.json, or {} for unassigned, and the
-- vocabulary is exactly the one custom shortcuts use -- actions.lua resolves
-- both, through the same whitelist tables, so a gesture can no more introduce
-- a command than a keybind can.
--
-- Nothing is emitted for an unassigned direction. That matters more here than
-- it looks: a registration is read at config time and there is no way to
-- remove one afterwards, so emitting a no-op gesture for every direction would
-- consume the four-finger swipes permanently, including for whatever the
-- compositor might do with them later.
--
-- Four rather than three because three is spoken for, and because four fingers
-- is the largest number of them a touchpad this size can tell apart.
local custom_gestures = {
{ pref = "gestureFourUp", direction = "up" },
{ pref = "gestureFourDown", direction = "down" },
{ pref = "gestureFourLeft", direction = "left" },
{ pref = "gestureFourRight", direction = "right" },
}
for _, gesture in ipairs(custom_gestures) do
local action = actions.gesture(prefs.get(gesture.pref, {}))
if action ~= nil then
hl.gesture({ fingers = 4, direction = gesture.direction, action = action })
end
end
return true return true
+47
View File
@@ -12,6 +12,7 @@
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs") local prefs = require("prefs")
local actions = require("actions")
local mod = "SUPER" local mod = "SUPER"
@@ -574,6 +575,52 @@ bind("XF86WWW", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind("XF86Mail", hl.dsp.exec_cmd(mail), { description = "Mail" }) bind("XF86Mail", hl.dsp.exec_cmd(mail), { description = "Mail" })
bind("XF86Search", hl.dsp.exec_cmd(launcher), { description = "Launcher" }) bind("XF86Search", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
-- ── Custom shortcuts ────────────────────────────────────────────────────────
--
-- Shortcuts the user invented, from `customBinds` in settings.json. Each entry
-- is { chord, kind, target, label } -- data, never a command. actions.lua turns
-- the kind/target pair into a dispatcher through whitelist tables; an entry it
-- does not recognise resolves to nil and is silently not emitted.
--
-- Emitted LAST, and through hl.bind rather than the `bind` wrapper above. Two
-- separate reasons, both about keeping the two rebinding mechanisms apart:
--
-- * `keybindOverrides` is keyed by a SHIPPED chord. A custom bind has no
-- shipped chord -- it is rebound by rewriting its own entry -- so putting
-- one through `bind` would let an override for some shipped key silently
-- move a custom one that happened to share a chord.
-- * last means a custom chord that collides with a shipped one loses, which
-- is checked explicitly below rather than left to Hyprland's ordering.
--
-- Settings prevents a collision upstream; this is the second lock, because the
-- file is hand-editable and losing a shipped key to a typo is not acceptable.
category("Custom")
local function custom_bind(chord, dispatcher, label)
categories[chord] = current_category
return hl.bind(chord, dispatcher, { description = label })
end
for _, entry in ipairs(prefs.get("customBinds", {})) do
if type(entry) == "table" then
local chord = entry.chord
local label = entry.label
-- A description is not decoration: keybinds-contract fails a build
-- with a description-less bind, and the cheatsheet and Shortcuts page
-- both list what they find. A nameless shortcut is unfindable.
if valid_chord(chord)
and type(label) == "string" and label ~= ""
and categories[chord] == nil
then
local dispatcher = actions.dispatcher(entry)
if dispatcher ~= nil then
custom_bind(chord, dispatcher, label)
end
end
end
end
write_categories() write_categories()
return true return true
+52
View File
@@ -37,6 +37,53 @@ local accentPair = accents[prefs.get("accentName", "blue")] or accents.blue
local accentStart = accentScheme == "light" and accentPair.light or accentPair.dark local accentStart = accentScheme == "light" and accentPair.light or accentPair.dark
local accentEnd = accentScheme == "light" and accentPair.lightSecondary or accentPair.darkSecondary local accentEnd = accentScheme == "light" and accentPair.lightSecondary or accentPair.darkSecondary
-- ── Color filters ───────────────────────────────────────────────────────────
--
-- Grayscale and three color-blindness corrections, as end-of-pipe screen
-- shaders. Hyprland composites the desktop and then runs one fragment shader
-- over the result, so a filter here covers every window, the shell, the cursor
-- and video alike -- which is the only way a filter is honest.
--
-- The PREFERENCE is the enum, not the path. That split is deliberate: storing
-- the path would put a filesystem location a person can edit into the value
-- that becomes `decoration:screen_shader`, and it would make the stored value
-- disagree with what hyprctl reports back (which is the path), failing the
-- schema shape and write-sweep contracts. So PreferenceSchema's colorFilter
-- entry carries no `hypr:` block, and the enum→path mapping is written twice
-- on purpose: here, for reloads and for the moment before the shell starts,
-- and in services/SystemSettings.qml's applyColorFilter for the live apply.
-- The two lists have to be edited together.
--
-- "none" and anything unrecognised both produce the empty string, which is
-- what Hyprland reads as "no shader" -- and is the value it needs to be given
-- to turn one OFF, since there is no way to unset the option.
local shaderDir = (function()
local configHome = os.getenv("XDG_CONFIG_HOME")
if configHome == nil or configHome == "" then
local home = os.getenv("HOME")
if home == nil or home == "" then
return nil
end
configHome = home .. "/.config"
end
return configHome .. "/hypr/shaders"
end)()
local colorFilters = {
grayscale = "grayscale.frag",
protanopia = "protanopia.frag",
deuteranopia = "deuteranopia.frag",
tritanopia = "tritanopia.frag",
}
local colorFilterShader = ""
do
local file = colorFilters[prefs.get("colorFilter", "none")]
if file ~= nil and shaderDir ~= nil then
colorFilterShader = shaderDir .. "/" .. file
end
end
hl.config({ hl.config({
general = { general = {
gaps_in = prefs.get("gapsIn", 5), gaps_in = prefs.get("gapsIn", 5),
@@ -145,6 +192,11 @@ hl.config({
-- Off: costs real frame time and reads as smeary on a 60Hz panel. -- Off: costs real frame time and reads as smeary on a 60Hz panel.
motion_blur = { enabled = false }, motion_blur = { enabled = false },
-- The accessibility color filter, resolved above. Empty when off, and
-- empty costs nothing: Hyprland skips the pass entirely rather than
-- running an identity shader.
screen_shader = colorFilterShader,
}, },
animations = { enabled = prefs.get("animationsEnabled", true) }, animations = { enabled = prefs.get("animationsEnabled", true) },
+93
View File
@@ -9,6 +9,8 @@
-- to invert it. -- to invert it.
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
-- ── Upstream sanity rules ─────────────────────────────────────────────────── -- ── Upstream sanity rules ───────────────────────────────────────────────────
hl.window_rule({ hl.window_rule({
name = "suppress-maximize-events", name = "suppress-maximize-events",
@@ -136,6 +138,97 @@ hl.window_rule({
no_dim = true, no_dim = true,
}) })
-- ── Per-application rules the user wrote ────────────────────────────────────
--
-- `windowRules` in settings.json, edited from Settings' Windows page. Each
-- entry is data and nothing else:
--
-- { class, label, float, center, size = {w, h}, workspace, noAnim, game,
-- noDim, pin }
--
-- `class` is matched LITERALLY. Hyprland matches with RE2, so a class typed
-- into a text field is a regular expression unless something escapes it -- and
-- "org.gnome.Files" as a pattern also matches "orgxgnomexFiles", while a
-- half-typed "(" is a pattern error rather than a rule that matches nothing.
-- Escaped and anchored here, so what the user typed is what gets matched.
--
-- Emitted AFTER the shipped rules and deliberately WITHOUT a name. Hyprland
-- evaluates every named rule before every anonymous one, so a named user rule
-- would silently outrank the anonymous shipped rules above it -- the opposite
-- of the intended precedence. Anonymous, last, is what "the user's rule wins"
-- actually means here.
--
-- An entry that fails any check is skipped whole rather than emitted with the
-- bad field dropped: a rule that half-applies is harder to understand than one
-- that is not there, and the settings page can see the same thing is wrong.
local function escape_regex(value)
return (value:gsub("[%^%$%(%)%%%.%[%]%*%+%-%?%{%}%|\\]", "\\%0"))
end
-- The shell's own surfaces are layers, not windows -- but Quickshell's helper
-- windows are not, and a rule that floats or moves one of them would be a user
-- breaking their own desktop from the Windows page. Refused at both ends; this
-- is the end that matters, because the file is hand-editable.
local function reserved_class(class)
local lowered = class:lower()
return lowered:match("^quickshell") ~= nil or lowered:match("^qs%-") ~= nil
end
local function positive_integer(value, low, high)
if type(value) ~= "number" or value ~= math.floor(value) then
return nil
end
if value < low or value > high then
return nil
end
return value
end
for _, entry in ipairs(prefs.get("windowRules", {})) do
if type(entry) == "table" and type(entry.class) == "string" then
local class = entry.class
local rule = nil
if #class >= 1 and #class <= 128 and not reserved_class(class) then
rule = { match = { class = "^" .. escape_regex(class) .. "$" } }
if entry.float == true then rule.float = true end
if entry.center == true then rule.center = true end
if entry.noAnim == true then rule.no_anim = true end
if entry.noDim == true then rule.no_dim = true end
if entry.pin == true then rule.pin = true end
-- The keystone the gaming rules above use: misc.vrr,
-- render.direct_scanout and cursor.no_break_fs_vrr all key off it.
if entry.game == true then rule.content = "game" end
if entry.size ~= nil then
local size = entry.size
local width = type(size) == "table" and positive_integer(size[1], 50, 10000) or nil
local height = type(size) == "table" and positive_integer(size[2], 50, 10000) or nil
if width == nil or height == nil then
rule = nil
else
rule.size = { width, height }
end
end
if rule ~= nil and entry.workspace ~= nil then
local workspace = positive_integer(entry.workspace, 1, 10)
if workspace == nil then
rule = nil
else
rule.workspace = workspace
end
end
end
if rule ~= nil then
hl.window_rule(rule)
end
end
end
-- ── Workspace rules ───────────────────────────────────────────────────────── -- ── Workspace rules ─────────────────────────────────────────────────────────
-- Deliberately NO "smart gaps". -- Deliberately NO "smart gaps".
-- --
+28
View File
@@ -0,0 +1,28 @@
// Deuteranopia -- green-blind.
//
// Panama's accessibility color filters. Selected by the `colorFilter`
// preference; hypr/looks.lua maps the enum to this path at config time and
// services/SystemSettings.qml does the same live.
//
// Same family of matrices as protanopia.frag, weighted for the missing green
// cone instead of the red one. See that file for why this is a correction
// rather than a simulation, and for the column-major note.
#version 300 es
precision mediump float;
in vec2 v_texcoord;
layout(location = 0) out vec4 fragColor;
uniform sampler2D tex;
const mat3 deuteranopia = mat3(
0.625, 0.700, 0.000,
0.375, 0.300, 0.300,
0.000, 0.000, 0.700
);
void main() {
vec4 pixColor = texture(tex, v_texcoord);
fragColor = vec4(clamp(deuteranopia * pixColor.rgb, 0.0, 1.0), pixColor.a);
}
+28
View File
@@ -0,0 +1,28 @@
// Grayscale.
//
// Panama's accessibility color filters. Selected by the `colorFilter`
// preference; hypr/looks.lua maps the enum to this path at config time and
// services/SystemSettings.qml does the same live.
//
// Hyprland runs one fragment shader over the finished frame, so this covers
// every window, the shell, video and the cursor alike.
//
// Rec. 709 luminance weights -- the same ones an SVG <feColorMatrix
// type="saturate" values="0"> uses. A flat average would make reds and blues
// far too bright and greens far too dark, because the eye does not weigh the
// channels equally.
#version 300 es
precision mediump float;
in vec2 v_texcoord;
layout(location = 0) out vec4 fragColor;
uniform sampler2D tex;
void main() {
vec4 pixColor = texture(tex, v_texcoord);
float luminance = dot(pixColor.rgb, vec3(0.2126, 0.7152, 0.0722));
fragColor = vec4(vec3(luminance), pixColor.a);
}
+33
View File
@@ -0,0 +1,33 @@
// Protanopia -- red-blind.
//
// Panama's accessibility color filters. Selected by the `colorFilter`
// preference; hypr/looks.lua maps the enum to this path at config time and
// services/SystemSettings.qml does the same live.
//
// The matrix is the feColorMatrix set the mock uses, which is the widely
// carried HCIRN-derived one: it redistributes the red channel into the two the
// eye can still separate, so a red/green pair that was one colour becomes two
// distinguishable ones. It is a CORRECTION, not a simulation -- the point is to
// make the screen readable, not to show what protanopia looks like.
//
// Row-major here, column-major to GLSL: mat3 takes its arguments column by
// column, so the transpose below is the matrix as written in the SVG.
#version 300 es
precision mediump float;
in vec2 v_texcoord;
layout(location = 0) out vec4 fragColor;
uniform sampler2D tex;
const mat3 protanopia = mat3(
0.567, 0.558, 0.000,
0.433, 0.442, 0.242,
0.000, 0.000, 0.758
);
void main() {
vec4 pixColor = texture(tex, v_texcoord);
fragColor = vec4(clamp(protanopia * pixColor.rgb, 0.0, 1.0), pixColor.a);
}
+28
View File
@@ -0,0 +1,28 @@
// Tritanopia -- blue-blind.
//
// Panama's accessibility color filters. Selected by the `colorFilter`
// preference; hypr/looks.lua maps the enum to this path at config time and
// services/SystemSettings.qml does the same live.
//
// Same family of matrices as protanopia.frag, weighted for the missing blue
// cone. See that file for why this is a correction rather than a simulation,
// and for the column-major note.
#version 300 es
precision mediump float;
in vec2 v_texcoord;
layout(location = 0) out vec4 fragColor;
uniform sampler2D tex;
const mat3 tritanopia = mat3(
0.950, 0.000, 0.000,
0.050, 0.433, 0.475,
0.000, 0.567, 0.525
);
void main() {
vec4 pixColor = texture(tex, v_texcoord);
fragColor = vec4(clamp(tritanopia * pixColor.rgb, 0.0, 1.0), pixColor.a);
}
@@ -1059,6 +1059,23 @@ Singleton {
detail: "How much darker unfocused windows are", detail: "How much darker unfocused windows are",
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" } hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" }
}, },
{
key: "colorFilter", type: "enum", def: "none", group: "accessibility",
label: "Color filter",
detail: "A whole-screen filter rendered by the compositor — grayscale, or a correction for one kind of color blindness. Costs nothing when off.",
// No hypr mapping, deliberately: hyprctl stores decoration:screen_shader
// as a shader *path*, not this enum, so a hypr: block would fail the
// shape and sweep contracts on read-back. hypr/looks.lua maps the enum
// to a shipped shader for reloads; SystemSettings.applyColorFilter does
// the same mapping live.
options: [
{ value: "none", label: "None" },
{ value: "grayscale", label: "Grayscale" },
{ value: "protanopia", label: "Protanopia" },
{ value: "deuteranopia", label: "Deuteranopia" },
{ value: "tritanopia", label: "Tritanopia" }
]
},
{ {
key: "visualAlerts", type: "bool", def: false, group: "accessibility", key: "visualAlerts", type: "bool", def: false, group: "accessibility",
label: "Flash the screen for notifications", label: "Flash the screen for notifications",
@@ -1757,6 +1774,63 @@ Singleton {
detail: "Shortcuts you have moved from their shipped chord" detail: "Shortcuts you have moved from their shipped chord"
}, },
// ── Custom shortcuts ────────────────────────────────────────────────
// [ { chord, kind, target, label } ]. Data, never code: `kind` is one of
// app | shell | window, `target` is a validated id resolved through the
// whitelist tables in hypr/actions.lua, and an entry that fails any check
// is silently not emitted. This is what keeps a user-editable file from
// being executable even though it now describes shortcuts the user
// invented. Edited through the Keyboard page, hence internal.
{
key: "customBinds", type: "json", def: [], group: "input",
internal: true,
label: "Custom shortcuts",
detail: "Shortcuts you invented: each one launches an application, triggers a shell action, or moves a window"
},
// ── Per-application window rules ────────────────────────────────────
// [ { class, label, float, center, size, workspace, noAnim, game,
// noDim, pin } ]. `class` is matched literally (hypr/rules.lua escapes
// it before Hyprland's RE2 sees it); `size` is [w, h] or null;
// `workspace` is 1..10 or null; everything else is a boolean. Rules
// matching the shell's own surfaces are refused at both ends. Edited
// through the Windows page, hence internal.
{
key: "windowRules", type: "json", def: [], group: "multitasking",
internal: true,
label: "Application window rules",
detail: "How specific applications behave when they open: floating, size, workspace, animations"
},
// ── Four-finger gestures ────────────────────────────────────────────
// Each holds {} (unassigned) or a named action { kind, target, label },
// the same shape customBinds stores and the same whitelists resolve.
// Registered at compositor config time, so assigning one reloads.
{
key: "gestureFourUp", type: "json", def: ({}), group: "touchpad",
internal: true,
label: "Four-finger swipe up",
detail: "What a four-finger upward swipe does"
},
{
key: "gestureFourDown", type: "json", def: ({}), group: "touchpad",
internal: true,
label: "Four-finger swipe down",
detail: "What a four-finger downward swipe does"
},
{
key: "gestureFourLeft", type: "json", def: ({}), group: "touchpad",
internal: true,
label: "Four-finger swipe left",
detail: "What a four-finger leftward swipe does"
},
{
key: "gestureFourRight", type: "json", def: ({}), group: "touchpad",
internal: true,
label: "Four-finger swipe right",
detail: "What a four-finger rightward swipe does"
},
// ── Display configuration ─────────────────────────────────────────── // ── Display configuration ───────────────────────────────────────────
// { "<output>": { mode, scale, transform, x, y, primary, vrrMode, // { "<output>": { mode, scale, transform, x, y, primary, vrrMode,
// colorProfile, bitdepth, sdrBrightness, sdrSaturation, mirrorOf } }, // colorProfile, bitdepth, sdrBrightness, sdrSaturation, mirrorOf } },
@@ -170,6 +170,22 @@ Item {
onToggled: Caffeine.toggle() onToggled: Caffeine.toggle()
} }
// Do Not Disturb on its own, beside Presentation. The service, the
// IPC verb and the settings row all existed; only the tile was
// missing, so the one-press way to silence banners was a shortcut
// you had to already know. Presentation keeps its combined role --
// this is the half of it people want without the awake half.
Toggle {
width: root.cellWidth
icon: Notifs.doNotDisturb
? "notifications-disabled-symbolic"
: "preferences-system-notifications-symbolic"
label: "Do Not Disturb"
sublabel: Notifs.doNotDisturb ? "Banners held" : "Off"
active: Notifs.doNotDisturb
onToggled: Notifs.doNotDisturb = !Notifs.doNotDisturb
}
// Caffeine plus Do Not Disturb as one switch, for the projector: // Caffeine plus Do Not Disturb as one switch, for the projector:
// the half you forget to arm is the one that fires a message // the half you forget to arm is the one that fires a message
// preview onto the big screen. Restores both exactly as found. // preview onto the big screen. Restores both exactly as found.
@@ -101,7 +101,35 @@ SettingsPage {
ToggleRow { setting: "magnifierRigid" } ToggleRow { setting: "magnifierRigid" }
SliderRow { setting: "textScale" } SliderRow { setting: "textScale" }
SliderRow { setting: "cursorSize" } SliderRow { setting: "cursorSize" }
ToggleRow { setting: "highContrast"; divider: false } ToggleRow { setting: "highContrast" }
// The stored value is the enum; what the compositor wants is a shader
// path. SystemSettings owns that mapping and applies it live, and
// hypr/looks.lua does the same lookup at config time -- so the filter
// survives a reload without this page having to reload anything.
//
// Applied on change rather than on load: the compositor already read
// the preference at launch, and re-applying the value it is already
// running would be a hyprctl call for nothing every time this page
// opens.
ChoiceRow {
id: colorFilterRow
property string appliedFilter: ""
setting: "colorFilter"
divider: false
Component.onCompleted: colorFilterRow.appliedFilter = String(colorFilterRow.current)
onCurrentChanged: {
const next = String(colorFilterRow.current);
if (next === colorFilterRow.appliedFilter)
return;
colorFilterRow.appliedFilter = next;
SystemSettings.applyColorFilter(next);
}
}
} }
SettingsCard { SettingsCard {
@@ -1,29 +1,48 @@
// The facts about one connection that otherwise need a terminal. // The facts about one connection that otherwise need a terminal, and the two
// settings that change them.
// //
// IP address, gateway, DNS and MAC are the four things people leave this app // IP address, gateway, DNS and MAC are the four things people leave this app
// for, and they were the reason the Connections page still pointed at GNOME. // for, and they were the reason the Connections page still pointed at GNOME.
// They are read-only here: editing them properly means static addressing, which // The facts above are what is on the wire; the editors below are what the
// is a page of its own rather than four fields smuggled into a details drawer. // PROFILE asks for, which is a different question -- a static address that has
// not been applied yet is in the second and not the first, and a component that
// showed only the first would look like it had forgotten what was typed.
// //
// Nothing here is ever a secret. panama-network's `details` verb returns // Nothing here is ever a secret. panama-network's `details` verb returns
// addresses only -- no PSK, no enterprise password -- so this component can be // addresses only -- no PSK, no enterprise password -- so this component can be
// shown for any connection without deciding what is safe to draw. // shown for any connection without deciding what is safe to draw.
// //
// Nothing applies until Apply. A half-typed address is a draft, not a broken
// network: committing per keystroke would take the connection down somewhere
// around the second octet. Same proxy-draft shape ConnectivityPage uses -- each
// field starts as a binding to the profile and stops being one at the first
// edit, so a reply landing mid-edit cannot empty the box being typed into.
//
// The values are set in the interface face with tabular figures rather than a // The values are set in the interface face with tabular figures rather than a
// monospaced one. Theme bans monospaced text outright (fontMono is the icon // monospaced one. Theme bans monospaced text outright (fontMono is the icon
// face, not a text face), and an address only needs its digits to line up. // face, not a text face), and an address only needs its digits to line up.
import QtQuick import QtQuick
import qs.config import qs.config
import qs.services
Column { Column {
id: root id: root
// { ip4, gateway, dns: [], mac, macRandomized } as the helper reports it, // { ip4, gateway, dns: [], mac, macRandomized, metered, ip4Method … } as
// or null while the read has not come back. Null is NOT "no address": the // the helper reports it, or null while the read has not come back. Null is
// component says it is still reading rather than claiming an answer. // NOT "no address": the component says it is still reading rather than
// claiming an answer.
property var details: null property var details: null
// The profile these facts belong to. Empty means "facts only" -- there is
// nothing to write to, so the editors do not appear. Every caller that has
// a connection name should pass it.
property string connection: ""
// Editing needs both a name to write to and an answer to edit from.
readonly property bool editable: root.connection !== "" && !!root.details
// [{ key, value, note }] -- only the facts that actually have a value, so a // [{ key, value, note }] -- only the facts that actually have a value, so a
// connection with no gateway shows three rows rather than a blank one. // connection with no gateway shows three rows rather than a blank one.
readonly property var facts: { readonly property var facts: {
@@ -145,4 +164,192 @@ Column {
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
} }
// ── Metered ─────────────────────────────────────────────────────────────
//
// Three states in NetworkManager, two in this switch, and the difference is
// said rather than hidden: "automatic" is NetworkManager deciding from what
// the network told it, which is a guess, and the detail line says so while
// it is the state in force.
SwitchRow {
width: parent.width
visible: root.editable
label: "Metered connection"
detail: String(root.details?.metered ?? "auto") === "auto"
? "NetworkManager is deciding for itself. Turn this on to hold updates and large downloads back until you are somewhere unmetered."
: "Updates and large downloads wait until you are somewhere unmetered"
checked: String(root.details?.metered ?? "auto") === "yes"
enabled: !NetworkTools.busy
onToggled: value => NetworkTools.setMetered(root.connection, value ? "yes" : "no")
}
// ── Addressing, one stack at a time ─────────────────────────────────────
//
// A Repeater over the two families rather than two hand-written copies: the
// drafts, the validation and the Apply are identical, and the only things
// that differ are the property prefix and what an address looks like.
//
// Each stack applies on its own. The helper writes a whole stack in one
// nmcli call and reactivates the connection afterwards, and NetworkTools
// runs one mutation at a time -- so a single Apply for both would silently
// drop one of them.
Repeater {
model: [
{
family: "4",
label: "IPv4",
addressHint: "192.168.1.50/24",
gatewayHint: "192.168.1.1",
dnsHint: "1.1.1.1, 9.9.9.9"
},
{
family: "6",
label: "IPv6",
addressHint: "fd00::42/64",
gatewayHint: "fd00::1",
dnsHint: "2606:4700:4700::1111"
}
]
Column {
id: stack
required property var modelData
readonly property bool six: String(stack.modelData.family) === "6"
// What the profile says now. Bound, so an Apply that succeeds
// reseeds every field nobody has touched.
readonly property string profileMethod:
String((stack.six ? root.details?.ip6Method : root.details?.ip4Method) ?? "")
=== "manual" ? "manual" : "auto"
readonly property string profileAddress: {
const list = (stack.six ? root.details?.ip6Addresses : root.details?.ip4Addresses) ?? [];
return list.length > 0 ? String(list[0]) : "";
}
readonly property string profileGateway:
String((stack.six ? root.details?.ip6Gateway : root.details?.ip4Gateway) ?? "")
readonly property string profileDns: {
const list = (stack.six ? root.details?.ip6Dns : root.details?.ip4Dns) ?? [];
return list.map(entry => String(entry)).join(", ");
}
// The drafts. Bindings until the first edit, and the user's after
// it -- see the header.
property string draftMethod: stack.profileMethod
property string draftAddress: stack.profileAddress
property string draftGateway: stack.profileGateway
property string draftDns: stack.profileDns
// The helper validates properly and is the authority. This is the
// same shape one step earlier, so Apply is dark rather than a round
// trip that comes back refused.
readonly property bool addressValid: stack.six
? (stack.draftAddress.indexOf(":") >= 0
&& /^[0-9A-Fa-f:]{2,45}\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/.test(stack.draftAddress))
: /^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\/(3[0-2]|[12]?[0-9])$/.test(stack.draftAddress)
readonly property bool dirty: stack.draftMethod !== stack.profileMethod
|| (stack.draftMethod === "manual"
&& (stack.draftAddress !== stack.profileAddress
|| stack.draftGateway !== stack.profileGateway
|| stack.draftDns !== stack.profileDns))
function apply(): void {
if (root.connection === "")
return;
if (stack.draftMethod === "auto") {
NetworkTools.setIpAuto(root.connection, stack.modelData.family);
return;
}
if (!stack.addressValid)
return;
NetworkTools.setIpManual(root.connection, stack.modelData.family,
stack.draftAddress.trim(),
stack.draftGateway.trim(),
stack.draftDns.trim());
}
width: parent.width
visible: root.editable
OptionPickerRow {
width: parent.width
label: String(stack.modelData.label)
detail: stack.profileMethod === "manual"
? "This connection asks for an address you chose"
: "This connection takes whatever the network hands it"
enabled: !NetworkTools.busy
options: [
{
value: "auto",
label: "Automatic",
detail: stack.six
? "Router advertisements and DHCPv6, as the network offers them"
: "DHCP, as the network offers it"
},
{
value: "manual",
label: "Manual",
detail: "An address, gateway and nameservers you enter"
}
]
current: stack.draftMethod
onPicked: value => stack.draftMethod = String(value)
}
TextFieldRow {
width: parent.width
visible: stack.draftMethod === "manual"
label: "Address / prefix"
detail: stack.draftAddress !== "" && !stack.addressValid
? "Not an address yet — it needs a prefix, like " + stack.modelData.addressHint
: "The address this machine takes on the network, with its prefix length"
placeholder: String(stack.modelData.addressHint)
text: stack.draftAddress
enabled: !NetworkTools.busy
onAccepted: value => stack.draftAddress = value.trim()
}
TextFieldRow {
width: parent.width
visible: stack.draftMethod === "manual"
label: "Gateway"
detail: "The router traffic leaves through. Leave it empty on a segment with no way out."
placeholder: String(stack.modelData.gatewayHint)
text: stack.draftGateway
enabled: !NetworkTools.busy
onAccepted: value => stack.draftGateway = value.trim()
}
TextFieldRow {
width: parent.width
visible: stack.draftMethod === "manual"
label: "DNS"
detail: "Nameservers, separated by commas. These replace the ones the network hands out rather than joining them."
placeholder: String(stack.modelData.dnsHint)
text: stack.draftDns
enabled: !NetworkTools.busy
onAccepted: value => stack.draftDns = value.trim()
}
ActionRow {
width: parent.width
visible: stack.dirty
label: "Apply " + String(stack.modelData.label)
detail: {
if (stack.draftMethod === "manual" && !stack.addressValid)
return "Fill in an address with a prefix first — nothing is written until this looks like an address.";
if (stack.draftMethod === "auto")
return "Clears the static address and takes what the network offers. This connection reconnects.";
return "Writes these to this connection only, and reconnects it.";
}
action: NetworkTools.busy ? "Working…" : "Apply"
enabled: !NetworkTools.busy
&& (stack.draftMethod === "auto" || stack.addressValid)
divider: !stack.six
onTriggered: stack.apply()
}
}
}
} }
@@ -48,6 +48,20 @@ SettingsPage {
property bool importOpen: false property bool importOpen: false
property string importPath: "" property string importPath: ""
// The saved list, folded at the house cap. Sorted by the helper with the
// active and autoconnecting profiles first, so a slice keeps the ones worth
// seeing and folds the tail.
property bool savedShowAll: false
readonly property int savedCap: 6
readonly property var savedShown: {
const list = NetworkTools.savedConnections;
if (root.savedShowAll || list.length <= root.savedCap)
return list;
return list.slice(0, root.savedCap);
}
readonly property int savedHidden:
NetworkTools.savedConnections.length - root.savedShown.length
// The proxy dropdown and its address, page-local until they add up to a // The proxy dropdown and its address, page-local until they add up to a
// whole setting. // whole setting.
// //
@@ -187,6 +201,7 @@ SettingsPage {
ConnectionDetails { ConnectionDetails {
width: parent.width width: parent.width
visible: root.wiredOpen && Connectivity.wiredOn visible: root.wiredOpen && Connectivity.wiredOn
connection: root.wiredConnection
details: root.wiredConnection !== "" details: root.wiredConnection !== ""
? NetworkTools.detailsFor(root.wiredConnection) : null ? NetworkTools.detailsFor(root.wiredConnection) : null
} }
@@ -303,6 +318,88 @@ SettingsPage {
} }
} }
// ── Saved networks ───────────────────────────────────────────────────────
//
// The list above is what is nearby. This is what this machine REMEMBERS,
// which is a different set and the more useful one to tidy: a profile you
// want rid of is invisible in a scan-driven list until you are standing
// next to it, which is exactly when you are least able to deal with it.
SettingsCard {
title: "Saved networks"
visible: NetworkTools.savedConnections.length > 0
subtitle: NetworkTools.savedConnections.length
+ (NetworkTools.savedConnections.length === 1 ? " profile" : " profiles")
+ " NetworkManager holds, including the ones nowhere near you."
Repeater {
model: root.savedShown
delegate: SettingRow {
id: savedRow
required property var modelData
required property int index
readonly property string name: String(savedRow.modelData.name ?? "")
readonly property bool wireless: savedRow.modelData.wifi === true
width: parent.width
label: savedRow.name
// Armed, the row stops describing the profile and names what
// the confirming press costs. The saved passphrase goes with
// the profile, and nothing else on this page says so.
detail: {
if (forgetSaved.armed)
return "This deletes the saved profile and its password. Rejoining "
+ savedRow.name + " means typing it again.";
const bits = [];
if (savedRow.modelData.active === true)
bits.push("Connected");
else if (savedRow.wireless)
bits.push(savedRow.modelData.inRange === true ? "In range" : "Out of range");
else
bits.push(String(savedRow.modelData.type ?? ""));
bits.push(savedRow.modelData.autoconnect === true
? "autoconnects" : "never autoconnects");
return bits.join(" · ");
}
value: savedRow.modelData.active === true ? "this one" : ""
controlWidth: 200
divider: savedRow.index < root.savedShown.length - 1 || root.savedHidden > 0
|| root.savedShowAll
ConfirmAction {
id: forgetSaved
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: savedRow.modelData.active !== true
actionId: "forget-saved-network:" + savedRow.name
armText: "Forget…"
confirmText: "Forget it"
enabled: !NetworkTools.busy
onConfirmed: NetworkTools.forget(savedRow.name)
}
}
}
SettingRow {
width: parent.width
visible: root.savedHidden > 0
|| (root.savedShowAll && NetworkTools.savedConnections.length > root.savedCap)
label: root.savedShowAll
? "Show fewer"
: root.savedHidden + (root.savedHidden === 1 ? " more" : " more")
detail: root.savedShowAll
? ""
: "Folded to keep the list short — the ones you use least are at the bottom"
activatable: true
divider: false
onActivated: root.savedShowAll = !root.savedShowAll
}
}
// ── VPN ────────────────────────────────────────────────────────────────── // ── VPN ──────────────────────────────────────────────────────────────────
SettingsCard { SettingsCard {
@@ -0,0 +1,276 @@
// The two-step flow behind " Add shortcut".
//
// Step one records the chord, because a shortcut whose keys are already taken
// is not worth choosing an action for -- the conflict is reported here, before
// anything is stored. Step two picks what it does, from three fixed
// vocabularies: an installed application, one of the shell's own actions, or
// one of the compositor's window verbs.
//
// Nothing typed here becomes a command. The editor reports an enum kind and a
// target that Keybinds.describeAction() already recognises; hypr/actions.lua
// resolves that to something runnable through whitelist tables of its own. That
// is the whole reason there is no "run this command" option: settings.json has
// to stay a file it is safe to hand somebody.
import QtQuick
import Quickshell
import qs.config
import qs.services
import qs.modules.clipboard
Column {
id: root
// { chord, kind, target, label }
signal committed(string chord, string kind, string target, string label)
signal canceled
property string chord: ""
property string kind: "app"
property string target: ""
property string targetLabel: ""
// The action already holding a chord somebody just pressed, and the chord
// itself, so the refusal can name both.
property string conflict: ""
property string conflictChord: ""
readonly property bool capturing: root.chord === ""
readonly property bool complete: root.chord !== "" && root.target !== "" && root.targetLabel !== ""
function reset(): void {
root.chord = "";
root.kind = "app";
root.target = "";
root.targetLabel = "";
root.conflict = "";
root.conflictChord = "";
appSearch.text = "";
}
width: parent ? parent.width : 620
spacing: 0
SectionLabel {
text: root.capturing ? "New shortcut · press the keys" : "New shortcut · pick what it does"
count: root.chord === "" ? "" : root.chord
}
// ── Step one: the chord ─────────────────────────────────────────────────
Item {
width: parent.width
height: root.capturing ? 56 : 0
visible: root.capturing
clip: true
ShortcutCapture {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 300
height: 32
focus: root.visible && root.capturing
message: root.conflict === ""
? ""
: root.conflictChord + " is already " + root.conflict
onCaptured: chord => {
const taken = Keybinds.boundTo(chord, "");
if (taken !== "") {
root.conflict = taken;
root.conflictChord = chord;
return;
}
if (Keybinds.isCustomChord(chord)) {
root.conflict = "one of your own shortcuts";
root.conflictChord = chord;
return;
}
root.conflict = "";
root.chord = chord;
}
onCanceled: root.canceled()
}
Text {
anchors.left: parent.left
anchors.leftMargin: 312
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "A modifier is required. Esc cancels. A chord another action holds is reported, never taken."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
// ── Step two: the action ────────────────────────────────────────────────
Column {
width: parent.width
visible: !root.capturing
spacing: 0
ChoiceGrid {
width: parent.width
label: "What it does"
detail: "Three kinds, and only three: Panama resolves the name you pick when you press the keys, so no shortcut ever stores a command."
current: root.kind
options: [
{ value: "app", label: "Launch an application" },
{ value: "shell", label: "Shell action" },
{ value: "window", label: "Window & workspace" }
]
onPicked: value => {
root.kind = String(value);
root.target = "";
root.targetLabel = "";
}
}
// Applications are searched rather than listed: a normal machine has
// several hundred desktop entries, and a flow of pills for all of them
// is a page nobody can read.
Column {
width: parent.width
visible: root.kind === "app"
spacing: 0
SearchField {
id: appSearch
width: parent.width
placeholder: "Search installed applications"
}
Repeater {
model: root.appMatches
SettingRow {
id: candidate
required property var modelData
required property int index
label: String(candidate.modelData.name ?? "")
detail: String(candidate.modelData.id ?? "")
value: candidate.modelData.id === root.target ? "Chosen" : ""
controlWidth: 96
divider: candidate.index < root.appMatches.length - 1
activatable: true
onActivated: {
root.target = String(candidate.modelData.id ?? "");
root.targetLabel = "Launch " + String(candidate.modelData.name ?? "");
}
}
}
Text {
width: parent.width
visible: root.appMatches.length === 0
text: appSearch.text.trim() === ""
? "Type to find an application."
: "Nothing installed matches that."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
topPadding: 10
bottomPadding: 10
}
}
ChoiceGrid {
width: parent.width
visible: root.kind === "shell"
label: "Shell action"
detail: "The shell's own surfaces, each one something Panama already answers over IPC."
current: root.target
options: root.shellOptions
divider: false
onPicked: value => {
root.target = String(value);
root.targetLabel = Keybinds.shellActionLabel(root.target);
}
}
ChoiceGrid {
width: parent.width
visible: root.kind === "window"
label: "Window & workspace"
detail: "Compositor verbs: the first three act on the focused window, the rest go to a workspace."
current: root.target
options: root.windowOptions
divider: false
onPicked: value => {
root.target = String(value);
root.targetLabel = Keybinds.windowActionLabel(root.target);
}
}
Item {
width: parent.width
height: 50
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
tone: "accent"
text: "Add it"
enabled: root.complete && !Keybinds.reloading
onClicked: root.committed(root.chord, root.kind, root.target, root.targetLabel)
}
SettingsButton {
text: "Cancel"
onClicked: root.canceled()
}
}
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 200
horizontalAlignment: Text.AlignRight
text: root.complete
? root.targetLabel + " · saves, then the compositor reloads — same as rebinding"
: "Pick what the shortcut does."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
// ── The vocabularies ────────────────────────────────────────────────────
// Shell and window targets come from Keybinds, which is the single QML
// authority on what hypr/actions.lua will resolve. Listing them here would
// be a second opinion.
readonly property var shellOptions:
Keybinds.shellActions.map(action => ({ value: action.target, label: action.label }))
readonly property var windowOptions:
Keybinds.windowActions.map(action => ({ value: action.target, label: action.label }))
readonly property var appMatches: {
const needle = appSearch.text.trim().toLowerCase();
if (needle === "")
return [];
const out = [];
for (const entry of DesktopEntries.applications.values) {
if (entry.noDisplay)
continue;
if (String(entry.name).toLowerCase().indexOf(needle) >= 0)
out.push(entry);
if (out.length >= 8)
break;
}
return out;
}
}
@@ -0,0 +1,94 @@
// One shortcut the user invented, in the pinned Custom group.
//
// ShortcutRow renders a bind the compositor reported; this renders a stored
// `customBinds` entry, which is a different thing and deliberately not the same
// component. A custom bind has an action to describe (the compositor reports
// every Lua bind as "__lua" plus a bytecode offset, so the description has to
// come from the entry), it is removable, and it is never "overridden" -- a
// rebind rewrites the entry in place rather than adding to the override map.
//
// The row reports and never decides: conflicts, the write and the reload all
// belong to Keybinds, and the page owns the one capture at a time.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
required property var entry
property bool capturing: false
// Shown inside the capture field -- the page puts a refused chord here.
property string message: ""
signal rebindRequested
signal removeRequested
signal captured(string chord)
signal canceled
readonly property string chord: String(root.entry?.chord ?? "")
readonly property string action: Keybinds.describeAction(root.entry)
// A stored entry is an intention; the keymap is the fact. hypr/keybinds.lua
// skips an entry whose action does not resolve or whose chord a shipped
// bind already holds, and a shortcut that quietly does nothing is exactly
// what a settings page must not draw as working.
readonly property bool live: root.action !== "" && Keybinds.customBindApplied(root.entry)
label: String(root.entry?.label ?? "")
detail: root.action === ""
? "This shortcut names an action Panama no longer has — remove it"
: (root.live
? root.action
: root.action + " · not answering yet — the compositor reloads on save")
labelColor: root.action === "" ? Theme.danger : Theme.fg
controlWidth: root.capturing ? 250 : (removeConfirm.armed ? 330 : 250)
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: !root.capturing
spacing: 8
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: "Rebind"
enabled: !Keybinds.reloading
onClicked: root.rebindRequested()
}
ConfirmAction {
id: removeConfirm
anchors.verticalCenter: parent.verticalCenter
actionId: "custom-bind-remove:" + root.chord
armText: "Remove…"
confirmText: "Remove it"
enabled: !Keybinds.reloading
onConfirmed: root.removeRequested()
}
KeycapChord {
anchors.verticalCenter: parent.verticalCenter
chord: root.chord
}
}
Loader {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 240
height: 30
active: root.capturing
// Focus has to travel through the Loader for the capture inside it to
// ever see a key press.
focus: root.capturing
sourceComponent: ShortcutCapture {
focus: true
message: root.message
onCaptured: chord => root.captured(chord)
onCanceled: root.canceled()
}
}
}
@@ -426,51 +426,28 @@ SettingsPage {
} }
} }
// The one thing left that GNOME genuinely owns.
//
// This card used to be headed "Fedora system settings" and led with an
// umbrella button reading "Open GNOME Settings", which landed on the System
// panel. That button was the last door of its kind, and by the end it was
// pointing at a house Panama had bought: Users, Sharing, Printers, Online
// Accounts, Privacy, Region, Colour and the whole of Connections are pages
// here now. A generic front door to a settings app you no longer need is
// not a boundary, it is a habit -- so it is gone, and gnome-handoff-contract
// holds the door shut by naming `system` in its OWNED map.
//
// Screen time is the exception, and it is a real one: GNOME's wellbeing
// panel does something Panama does not, and that button genuinely works.
// Colour profiles used to sit here too, with a detail line explaining that
// pressing the button changed nothing, because the colord daemon that
// applies an ICC profile is not running under Hyprland. A handoff that
// documents its own uselessness is a dead button with an apology attached,
// so that one went first.
SettingsCard { SettingsCard {
title: "Fedora system settings" title: "Digital wellbeing"
subtitle: "These areas remain owned by Fedora and GNOME's mature system panels." subtitle: "Screen time and break reminders are GNOME's, and this is the one panel of theirs that still does something Panama does not."
Item {
width: parent.width
implicitHeight: 42
Text {
anchors.left: parent.left
anchors.right: gnomeSettingsButton.left
anchors.rightMargin: 18
anchors.verticalCenter: parent.verticalCenter
text: "Use GNOME Settings for the parts of the system this app does not manage."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
// Lands on System rather than Network. This button used to open the
// Network panel as a generic front door, which stopped being true
// the moment Connections absorbed VPN, proxies, hotspot and
// enterprise Wi-Fi: sending someone to GNOME for a page Panama now
// owns is exactly what gnome-handoff-contract exists to catch.
SettingsButton {
id: gnomeSettingsButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Open GNOME Settings"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: SystemSettings.openGnomePanel("system")
Keys.onReturnPressed: SystemSettings.openGnomePanel("system")
Keys.onSpacePressed: SystemSettings.openGnomePanel("system")
}
}
// Color profiles used to have a row here whose own detail explained
// that pressing it changed nothing -- the colord daemon that applies an
// ICC profile is not running under Hyprland. A handoff that documents
// its own uselessness is not a boundary, it is a dead button with an
// apology attached, so it is gone. Digital wellbeing stays: GNOME
// genuinely owns screen time, and that button genuinely works.
ActionRow { ActionRow {
objectName: "health-fedora-handoff:wellbeing" objectName: "health-fedora-handoff:wellbeing"
label: "Digital wellbeing" label: "Digital wellbeing"
@@ -11,10 +11,11 @@
// with no members, so the user would be toggling settings that can never affect // with no members, so the user would be toggling settings that can never affect
// anything with nothing to say so. // anything with nothing to say so.
// //
// The gestures that were a card of their own now sit at the bottom of the // Gestures are a card again. They were folded into the touchpad card when the
// touchpad card. They are touchpad settings -- a three-finger swipe has nowhere // only thing to say about them was how far a swipe travels and which way round
// else to happen -- and a card holding two sliders was a heading standing in // it goes -- a heading standing in for a section. Four assignable four-finger
// for a section. // directions is a subject, so the heading has content now, and the two feel
// knobs come back up with it: they are about gestures, not about the touchpad.
import QtQuick import QtQuick
import qs.config import qs.config
@@ -126,10 +127,132 @@ SettingsPage {
} }
} }
// ── Gestures ────────────────────────────────────────────────────────────
// Three fingers are the desktop's and stay put: they are registered at
// config time, they reproduce GNOME's muscle memory, and a preference that
// needed a reload to turn them off would be worse than the nothing they
// cost. Four fingers are the user's, from the same named-action vocabulary
// custom shortcuts use -- an enum kind and a validated target, never a
// command. Assigning one is read at config time too, so it reloads.
readonly property var gestureKeys: [
{ key: "gestureFourUp", label: "Swipe up" },
{ key: "gestureFourDown", label: "Swipe down" },
{ key: "gestureFourLeft", label: "Swipe left" },
{ key: "gestureFourRight", label: "Swipe right" }
]
// A named action is two fields; OptionPickerRow picks one value. They are
// joined on the first colon, which `window` targets already use for
// "workspace:4", so the split has to take the first one only.
function gestureValue(key: string): string {
const stored = DesktopPreferences.get(key);
if (Keybinds.describeAction(stored) === "")
return "";
return String(stored.kind) + ":" + String(stored.target);
}
function assignGesture(key: string, value: string): void {
const at = String(value).indexOf(":");
if (at < 0) {
DesktopPreferences.set(key, ({}));
Keybinds.applyReload();
return;
}
const kind = String(value).slice(0, at);
const target = String(value).slice(at + 1);
const label = kind === "shell"
? Keybinds.shellActionLabel(target)
: Keybinds.windowActionLabel(target);
if (Keybinds.describeAction({ kind: kind, target: target }) === "" || label === "")
return;
DesktopPreferences.set(key, { kind: kind, target: target, label: label });
Keybinds.applyReload();
}
// Nothing, then the shell's own actions, then the compositor's window
// verbs -- the same lists the shortcut editor offers, read from Keybinds so
// this page never becomes a second opinion on what resolves.
//
// Applications are deliberately absent: assigning one means searching
// several hundred desktop entries, which a picker of this shape cannot do.
// A gesture that already holds an application (written by the shortcut
// vocabulary elsewhere) still shows, so it can be read and cleared.
function gestureOptions(key: string): var {
const out = [{ value: "", label: "Nothing", detail: "The swipe passes through to whatever is under it" }];
const stored = DesktopPreferences.get(key);
if (stored && stored.kind === "app" && Keybinds.describeAction(stored) !== "")
out.push({
value: "app:" + String(stored.target),
label: String(stored.label),
detail: "Application · launch-or-focus"
});
for (const action of Keybinds.shellActions)
out.push({ value: "shell:" + action.target, label: action.label, detail: "Shell action" });
for (const action of Keybinds.windowActions)
out.push({ value: "window:" + action.target, label: action.label, detail: "Window & workspace" });
return out;
}
SettingsCard {
visible: InputDevices.hasTouchpad
title: "Gestures"
subtitle: "Three fingers are Panama's — they drive workspaces and Mission Control everywhere and stay put. Four fingers are yours: give each direction a job from the same actions your shortcuts use, or leave it unassigned."
SectionLabel { text: "Three fingers"; count: "· shipped" }
TextRow {
label: "Swipe left or right"
detail: "Moves between workspaces, following your fingers"
value: "Switch workspace"
}
TextRow {
label: "Swipe up"
value: "Open Mission Control"
}
TextRow {
label: "Swipe down"
detail: "Open and close rather than one toggle, so a swipe never undoes itself mid-gesture"
value: "Close Mission Control"
divider: false
}
SectionLabel { text: "Four fingers"; count: "· yours" }
Repeater {
model: root.gestureKeys
OptionPickerRow {
id: gestureRow
required property var modelData
required property int index
label: String(gestureRow.modelData.label)
detail: gestureRow.index === root.gestureKeys.length - 1
? "Assigning one reloads the compositor — a beat of black, then it works"
: ""
options: root.gestureOptions(String(gestureRow.modelData.key))
current: root.gestureValue(String(gestureRow.modelData.key))
enabled: !Keybinds.reloading
divider: gestureRow.index < root.gestureKeys.length - 1
onPicked: value => root.assignGesture(String(gestureRow.modelData.key), String(value))
}
}
SectionLabel { text: "Feel" }
// How far a swipe has to travel and which way round it goes: the two
// knobs that are about gestures rather than about the touchpad, which
// is why they moved up out of the Touchpad card.
SliderRow { setting: "swipeDistance" }
ToggleRow { setting: "swipeInvert"; divider: false }
}
SettingsCard { SettingsCard {
visible: InputDevices.hasTouchpad visible: InputDevices.hasTouchpad
title: "Touchpad" title: "Touchpad"
subtitle: "Separate from the mouse on purpose: libinput keeps them apart, and a touchpad and a mouse usually want to scroll in opposite directions." subtitle: "Separate from the mouse on purpose: libinput keeps them apart, and a touchpad and a mouse usually want to scroll in opposite directions. The two swipe knobs moved up into Gestures, which is what they are about."
ToggleRow { setting: "touchpadTapToClick" } ToggleRow { setting: "touchpadTapToClick" }
ToggleRow { setting: "touchpadClickfinger" } ToggleRow { setting: "touchpadClickfinger" }
@@ -138,13 +261,7 @@ SettingsPage {
ToggleRow { setting: "touchpadNaturalScroll" } ToggleRow { setting: "touchpadNaturalScroll" }
SliderRow { setting: "touchpadScrollFactor" } SliderRow { setting: "touchpadScrollFactor" }
ToggleRow { setting: "touchpadDisableWhileTyping" } ToggleRow { setting: "touchpadDisableWhileTyping" }
ToggleRow { setting: "touchpadMiddleButtonEmulation" } ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false }
// Which gestures exist is fixed by the compositor at startup -- three
// fingers sideways moves between workspaces, up opens the overview.
// What they feel like is here.
SliderRow { setting: "swipeDistance" }
ToggleRow { setting: "swipeInvert"; divider: false }
} }
SettingsCard { SettingsCard {
@@ -22,6 +22,20 @@ Rectangle {
return SettingsRoutes.breadcrumb(page); return SettingsRoutes.breadcrumb(page);
} }
// A result that names a section opens the page ON that section rather than
// on whichever tab the page opens by default -- finding "Theme editor" and
// landing on the Themes tab is the search half-working. Results without one
// go through pageRequested exactly as before, which is every result the
// schema produces.
function openResult(result: var): void {
const section = String(result.section ?? "");
if (section === "") {
root.pageRequested(String(result.page));
return;
}
ShellState.openSettingsSection(String(result.page), section);
}
// One row per category. SettingsRoutes owns the taxonomy; a category with // One row per category. SettingsRoutes owns the taxonomy; a category with
// tabs is opened at its first available tab by ShellState's resolution. // tabs is opened at its first available tab by ShellState's resolution.
readonly property var destinations: SettingsRoutes.categories readonly property var destinations: SettingsRoutes.categories
@@ -186,7 +200,7 @@ Rectangle {
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
root.pageRequested(hit.modelData.page); root.openResult(hit.modelData);
searchInput.text = ""; searchInput.text = "";
} }
} }
@@ -106,13 +106,46 @@ SettingsPage {
readonly property bool filtering: root.filter.trim() !== "" readonly property bool filtering: root.filter.trim() !== ""
readonly property int overrideCount: Object.keys(Keybinds.overrides).length readonly property int overrideCount: Object.keys(Keybinds.overrides).length
// ── The shortcuts you invented ──────────────────────────────────────────
// Rendered from the stored `customBinds` array rather than from the
// compositor's report, for two reasons: the stored entry is the only place
// the ACTION is written down (Hyprland reports every Lua bind as "__lua"
// plus a bytecode offset), and a shortcut has to appear the moment it is
// added rather than after the reload settles. The row asks Keybinds whether
// the compositor is actually answering it, so nothing here claims a bind
// that did not take.
property bool adding: false
// The chord of the custom bind being re-recorded, empty when none is.
property string rebindingChord: ""
readonly property var customBinds: {
const needle = root.filter.trim().toLowerCase();
if (needle === "")
return Keybinds.customBinds;
return Keybinds.customBinds.filter(entry =>
String(entry?.label ?? "").toLowerCase().indexOf(needle) >= 0
|| "custom".indexOf(needle) >= 0);
}
function customMessage(): string {
return root.conflict === ""
? ""
: root.conflictChord + " is already " + root.conflict;
}
// Every group the compositor reports, in Keybinds' own order, narrowed by // Every group the compositor reports, in Keybinds' own order, narrowed by
// the filter. An empty filter narrows nothing: the page's job is to show // the filter. An empty filter narrows nothing: the page's job is to show
// the whole keymap, and searching is an extra rather than a gate. // the whole keymap, and searching is an extra rather than a gate.
//
// "Custom" is dropped here: those binds are drawn above from the stored
// entries, and showing them twice would read as two shortcuts on one chord.
readonly property var groups: { readonly property var groups: {
const needle = root.filter.trim().toLowerCase(); const needle = root.filter.trim().toLowerCase();
const out = []; const out = [];
for (const group of Keybinds.grouped()) { for (const group of Keybinds.grouped()) {
if (group.name === "Custom")
continue;
const hits = needle === "" const hits = needle === ""
? group.binds ? group.binds
: group.binds.filter(bind => : group.binds.filter(bind =>
@@ -264,7 +297,7 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Shortcuts" title: "Shortcuts"
subtitle: "Click Change and press the new keys. A shortcut another action holds is refused, never stolen." subtitle: "Click Change and press the new keys. A shortcut another action holds is refused, never stolen. Your own shortcuts live in the Custom group — none of them stores a command: each names an application, a shell action, or a window move, and Panama resolves the name when you press it."
Item { Item {
width: parent.width width: parent.width
@@ -283,14 +316,107 @@ SettingsPage {
Text { Text {
id: counts id: counts
anchors.right: parent.right anchors.right: addButton.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: Keybinds.binds.length + " bound · " + root.overrideCount + " changed" text: Keybinds.binds.length + " bound · " + root.overrideCount + " changed · "
+ Keybinds.customBinds.length + " custom"
color: Theme.fgMuted color: Theme.fgMuted
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.features: Theme.tabularFigures font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
} }
SettingsButton {
id: addButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
tone: "accent"
text: " Add shortcut"
enabled: !root.adding && !Keybinds.reloading
onClicked: {
root.capturingChord = "";
root.rebindingChord = "";
root.conflict = "";
root.conflictChord = "";
addEditor.reset();
root.adding = true;
}
}
}
CustomShortcutEditor {
id: addEditor
width: parent.width
visible: root.adding
onCommitted: (chord, kind, target, label) => {
if (Keybinds.addCustomBind(chord, kind, target, label))
root.adding = false;
}
onCanceled: root.adding = false
}
// The Custom group, pinned above everything the compositor reports.
Column {
width: parent.width
visible: root.customBinds.length > 0
spacing: 0
SectionLabel {
text: "Custom — yours"
count: root.filtering
? "· showing " + root.customBinds.length + " of " + Keybinds.customBinds.length
: "· " + Keybinds.customBinds.length
}
Repeater {
id: customRows
model: root.customBinds
CustomShortcutRow {
id: customRow
required property var modelData
required property int index
entry: customRow.modelData
capturing: root.rebindingChord === String(customRow.modelData.chord ?? "")
message: customRow.capturing ? root.customMessage() : ""
divider: customRow.index < customRows.count - 1
onRebindRequested: {
root.conflict = "";
root.conflictChord = "";
root.capturingChord = "";
root.rebindingChord = String(customRow.modelData.chord ?? "");
}
onRemoveRequested: Keybinds.removeCustomBind(String(customRow.modelData.chord ?? ""))
onCaptured: chord => {
const current = String(customRow.modelData.chord ?? "");
const taken = Keybinds.boundTo(chord, current);
if (taken !== "") {
root.conflict = taken;
root.conflictChord = chord;
return;
}
root.conflict = "";
Keybinds.rebindCustomBind(current, chord);
root.rebindingChord = "";
}
onCanceled: {
root.conflict = "";
root.rebindingChord = "";
}
}
}
} }
// One Column of Repeaters rather than a Loader per row: at a hundred and // One Column of Repeaters rather than a Loader per row: at a hundred and
@@ -363,6 +489,7 @@ SettingsPage {
onChangeRequested: { onChangeRequested: {
root.conflict = ""; root.conflict = "";
root.conflictChord = ""; root.conflictChord = "";
root.rebindingChord = "";
root.capturingChord = shortcutRow.modelData.luaChord; root.capturingChord = shortcutRow.modelData.luaChord;
} }
@@ -395,7 +522,7 @@ SettingsPage {
Text { Text {
width: parent.width width: parent.width
visible: root.groups.length === 0 && Keybinds.loaded visible: root.groups.length === 0 && root.customBinds.length === 0 && Keybinds.loaded
text: root.filtering text: root.filtering
? "Nothing matches — the filter searches shortcut names and group names." ? "Nothing matches — the filter searches shortcut names and group names."
: "The compositor reported no shortcuts." : "The compositor reported no shortcuts."
@@ -50,6 +50,19 @@ SettingsPage {
readonly property var heldKeys: SshKeys.keys.filter(key => key.loaded === true) readonly property var heldKeys: SshKeys.keys.filter(key => key.loaded === true)
// Known hosts, folded at the house cap. A machine that has been used for a
// year has dozens of these, and an unbounded list turns the card below the
// keys into most of the page -- the same reason the Wi-Fi list folds.
property bool showAllHosts: false
readonly property int hostCap: 6
readonly property var shownHosts: {
const list = SshKeys.hosts;
if (root.showAllHosts || list.length <= root.hostCap)
return list;
return list.slice(0, root.hostCap);
}
readonly property int hiddenHostCount: SshKeys.hosts.length - root.shownHosts.length
function resetForm(): void { function resetForm(): void {
root.showingGenerator = false; root.showingGenerator = false;
root.newName = ""; root.newName = "";
@@ -395,7 +408,7 @@ SettingsPage {
: "Machines this one has connected to. Forget an entry when a server legitimately changed — ssh-keygen keeps a .old copy." : "Machines this one has connected to. Forget an entry when a server legitimately changed — ssh-keygen keeps a .old copy."
Repeater { Repeater {
model: SshKeys.hosts model: root.shownHosts
delegate: SettingRow { delegate: SettingRow {
id: hostRow id: hostRow
@@ -445,6 +458,19 @@ SettingsPage {
} }
} }
SettingRow {
visible: root.hiddenHostCount > 0
|| (root.showAllHosts && SshKeys.hosts.length > root.hostCap)
label: root.showAllHosts
? "Show fewer hosts"
: root.hiddenHostCount + (root.hiddenHostCount === 1 ? " more host" : " more hosts")
detail: root.showAllHosts
? ""
: "Folded to keep the list short — every one of them is still in known_hosts"
activatable: true
onActivated: root.showAllHosts = !root.showAllHosts
}
ActionRow { ActionRow {
label: "Check again" label: "Check again"
detail: "Re-reads ~/.ssh and asks the agent what it is holding" detail: "Re-reads ~/.ssh and asks the agent what it is holding"
@@ -16,6 +16,109 @@ SettingsPage {
title: "Tiling" title: "Tiling"
lede: "How windows share the space, and where their edges are." lede: "How windows share the space, and where their edges are."
// The rule being edited, or null while the editor is closed. Held here
// rather than in the editor so that opening one row's Edit closes another's.
property var editingRule: null
property bool addingRule: false
readonly property bool ruleEditorOpen: root.addingRule || root.editingRule !== null
function closeRuleEditor(): void {
root.addingRule = false;
root.editingRule = null;
}
// Per-application behavior, above the general tiling settings: a rule for
// one application is what somebody came here to write, and the gaps and
// borders below are set once and left alone.
SettingsCard {
title: "App rules"
subtitle: "How specific applications behave when they open. Behaviors, not regexes: pick an application, tick what it should do. Panama's own surfaces cannot be matched."
Repeater {
id: ruleRows
model: WindowRules.rules
WindowRuleRow {
id: ruleRow
required property var modelData
required property int index
rule: ruleRow.modelData
divider: ruleRow.index < ruleRows.count - 1
onEditRequested: {
root.addingRule = false;
root.editingRule = ruleRow.modelData;
ruleEditor.load(ruleRow.modelData);
}
onRemoveRequested: {
if (root.editingRule === ruleRow.modelData)
root.closeRuleEditor();
WindowRules.removeRule(String(ruleRow.modelData.class ?? ""));
}
}
}
Text {
width: parent.width
visible: WindowRules.rules.length === 0 && !root.ruleEditorOpen
text: "No rules yet. Every application opens the way the compositor decides."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
topPadding: 10
bottomPadding: 10
}
WindowRuleEditor {
id: ruleEditor
width: parent.width
visible: root.ruleEditorOpen
onCommitted: rule => {
const saved = root.editingRule === null
? WindowRules.addRule(rule)
: WindowRules.updateRule(String(root.editingRule.class ?? ""), rule);
if (saved)
root.closeRuleEditor();
}
onCanceled: root.closeRuleEditor()
}
SettingRow {
visible: !root.ruleEditorOpen
label: "Add a rule"
detail: WindowRules.rules.length === 0
? "Applies on save, with a compositor reload"
: (WindowRules.applied
? "Applied on the last reload — Hyprland publishes no rule listing, so this is the reload's word, not a read-back of the rules themselves"
: "Saved, waiting on the reload that puts it in effect")
controlWidth: 130
divider: false
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
tone: "accent"
text: " Add a rule"
enabled: !WindowRules.reloading
onClicked: {
root.editingRule = null;
ruleEditor.reset();
root.addingRule = true;
}
}
}
ErrorRow { message: WindowRules.lastError }
}
SettingsCard { SettingsCard {
title: "Window layout" title: "Window layout"
subtitle: "Follows the Forge mental model, with native Hyprland tiling." subtitle: "Follows the Forge mental model, with native Hyprland tiling."
@@ -45,6 +45,24 @@ Column {
property string failedSsid: "" property string failedSsid: ""
property string failedText: "" property string failedText: ""
// The hidden-network form. Its passphrase lives here only while the form is
// open: the form is a Loader, so closing it destroys the field, and
// closeHidden empties this alongside it.
property bool hiddenOpen: false
property string hiddenSsid: ""
property string hiddenSecurity: "wpa-psk"
property string hiddenPassword: ""
readonly property bool hiddenReady: root.hiddenSsid.trim() !== ""
&& (root.hiddenSecurity === "none" || root.hiddenPassword !== "")
function closeHidden(): void {
root.hiddenOpen = false;
root.hiddenSsid = "";
root.hiddenSecurity = "wpa-psk";
root.hiddenPassword = "";
}
// The list shows what matters and folds the rest: connected and saved // The list shows what matters and folds the rest: connected and saved
// networks always render (they lead the sort, so a slice keeps them), and // networks always render (they lead the sort, so a slice keeps them), and
// strangers fill the remaining slots up to the cap. Everything else waits // strangers fill the remaining slots up to the cap. Everything else waits
@@ -72,6 +90,10 @@ Column {
root.confirmingForget = ""; root.confirmingForget = "";
} }
// closeAll is called from row activation, which the hidden form is not part
// of -- opening a network's drawer should not throw away a half-typed
// hidden SSID, but joining one should close the form.
// One click on a row means whatever that row's state makes it mean. The // One click on a row means whatever that row's state makes it mean. The
// connected network opens rather than reconnecting to itself. // connected network opens rather than reconnecting to itself.
function activate(network: var): void { function activate(network: var): void {
@@ -202,6 +224,7 @@ Column {
ConnectionDetails { ConnectionDetails {
width: parent.width width: parent.width
connection: entry.ssid
details: entry.details details: entry.details
} }
@@ -426,4 +449,116 @@ Column {
: "" : ""
divider: false divider: false
} }
// ── A network that does not say it is there ─────────────────────────────
//
// A hidden network cannot appear in the list above by definition, so the
// only way in is to name it. This was the last thing on the Wi-Fi card that
// sent people back to GNOME's panel.
ActionRow {
width: parent.width
visible: Connectivity.wifiDevice !== null && Connectivity.wifiEnabled
label: "Join a hidden network…"
detail: "A network that does not broadcast its name — you type the name and its security"
action: root.hiddenOpen ? "Cancel" : "Join…"
enabled: !NetworkTools.busy
divider: root.hiddenOpen
onTriggered: {
if (root.hiddenOpen) {
root.closeHidden();
return;
}
root.closeAll();
root.hiddenOpen = true;
}
}
// Loaded rather than hidden, for the same reason the enterprise form is:
// closing it destroys the field, and with it the passphrase that was typed.
Loader {
id: hiddenLoader
width: parent.width
active: root.hiddenOpen
visible: hiddenLoader.active
sourceComponent: Column {
width: hiddenLoader.width
TextFieldRow {
width: parent.width
label: "Network name"
detail: "Exactly as whoever runs the network wrote it — a hidden network is found by name, so a typo simply never connects"
placeholder: "office-private"
text: root.hiddenSsid
enabled: !NetworkTools.busy
onAccepted: value => root.hiddenSsid = value.trim()
}
OptionPickerRow {
width: parent.width
label: "Security"
detail: "What the network expects. The wrong one associates and then fails, with nothing to say why."
enabled: !NetworkTools.busy
options: [
{
value: "wpa-psk",
label: "WPA2 (password)",
detail: "What almost every home and office network uses"
},
{
value: "sae",
label: "WPA3 (password)",
detail: "Newer, and refused outright by anything older"
},
{
value: "none",
label: "Open",
detail: "No password at all"
}
]
current: root.hiddenSecurity
onPicked: value => {
root.hiddenSecurity = String(value);
// An open network has no passphrase, so a passphrase typed
// before the mode changed must not sit in memory waiting to
// be sent to a network that will not ask for one.
if (root.hiddenSecurity === "none")
root.hiddenPassword = "";
}
}
SecretFieldRow {
width: parent.width
visible: root.hiddenSecurity !== "none"
label: "Password"
detail: "Handed to NetworkManager down a pipe, never as a command argument"
enabled: !NetworkTools.busy
onChanged: value => root.hiddenPassword = value
}
ActionRow {
width: parent.width
label: "Join this network"
detail: root.hiddenSsid.trim() === ""
? "Give the network a name first."
: (root.hiddenSecurity !== "none" && root.hiddenPassword === ""
? "This network needs a password."
: "Saves a profile that probes for " + root.hiddenSsid.trim()
+ " by name, and connects to it.")
action: NetworkTools.busy ? "Joining…" : "Join"
enabled: !NetworkTools.busy && root.hiddenReady
divider: false
onTriggered: {
if (!root.hiddenReady)
return;
const ssid = root.hiddenSsid.trim();
NetworkTools.joinHidden(ssid, ssid, root.hiddenSecurity,
root.hiddenPassword);
root.closeHidden();
}
}
}
}
} }
@@ -0,0 +1,348 @@
// Writing a window rule as behaviors instead of as a regex.
//
// Two halves, in the order somebody actually thinks in: which application, then
// what it should do. The application half offers the windows open right now
// first -- that is the list where the class is a fact rather than a guess,
// because Hyprland is reporting it -- and falls back to a search over installed
// applications, whose class comes from their own StartupWMClass.
//
// The behavior half is ticks. Nothing here composes a rule string: the draft is
// booleans and two bounded numbers, WindowRules validates it, and hypr/rules.lua
// escapes the class before the compositor's matcher sees it.
//
// Panama's own surfaces are not offerable and not typeable: a rule that floated
// a Quickshell layer would break the desktop from inside Settings.
import QtQuick
import Quickshell
import Quickshell.Hyprland
import qs.config
import qs.services
import qs.modules.clipboard
Column {
id: root
// Set to an existing rule to edit it; null to add a new one.
property var editing: null
signal committed(var rule)
signal canceled
property string windowClass: ""
property string appLabel: ""
property bool floats: false
property bool center: false
property bool noAnim: false
property bool game: false
property bool noDim: false
property bool pin: false
property bool sizeOn: false
property int sizeWidth: 900
property int sizeHeight: 600
property bool workspaceOn: false
property int workspace: 1
readonly property bool chosen: root.windowClass !== ""
function load(rule: var): void {
root.editing = rule ?? null;
root.windowClass = String(rule?.class ?? "");
root.appLabel = String(rule?.label ?? rule?.class ?? "");
root.floats = rule?.float === true;
root.center = rule?.center === true;
root.noAnim = rule?.noAnim === true;
root.game = rule?.game === true;
root.noDim = rule?.noDim === true;
root.pin = rule?.pin === true;
root.sizeOn = Array.isArray(rule?.size) && rule.size.length === 2;
root.sizeWidth = root.sizeOn ? rule.size[0] : 900;
root.sizeHeight = root.sizeOn ? rule.size[1] : 600;
root.workspaceOn = Number.isFinite(rule?.workspace);
root.workspace = root.workspaceOn ? rule.workspace : 1;
appSearch.text = "";
}
function reset(): void {
root.load(null);
}
function draft(): var {
return {
"class": root.windowClass,
label: root.appLabel === "" ? root.windowClass : root.appLabel,
float: root.floats,
center: root.center,
size: root.sizeOn ? [root.sizeWidth, root.sizeHeight] : null,
workspace: root.workspaceOn ? root.workspace : null,
noAnim: root.noAnim,
game: root.game,
noDim: root.noDim,
pin: root.pin
};
}
readonly property bool complete: root.chosen
&& WindowRules.validRule(root.draft())
&& WindowRules.hasBehavior(root.draft())
width: parent ? parent.width : 620
spacing: 0
SectionLabel {
text: root.editing ? "Edit rule" : "New rule"
count: root.chosen ? root.windowClass : "pick an application"
}
// ── Which application ───────────────────────────────────────────────────
// Hidden while editing: the class is the rule's identity, and letting it be
// changed in place would be a second rule wearing the first one's history.
Column {
width: parent.width
visible: root.editing === null
spacing: 0
ChoiceGrid {
width: parent.width
visible: root.openWindows.length > 0
label: "Open right now"
detail: "The class comes from the compositor, so these are exact."
current: root.windowClass
options: root.openWindows
divider: false
onPicked: value => {
root.windowClass = String(value);
const found = root.openWindows.find(option => option.value === root.windowClass);
root.appLabel = found ? String(found.label) : root.windowClass;
}
}
SearchField {
id: appSearch
width: parent.width
placeholder: "…or search installed applications"
}
Repeater {
model: root.appMatches
SettingRow {
id: candidate
required property var modelData
required property int index
label: String(candidate.modelData.label ?? "")
detail: String(candidate.modelData.value ?? "")
value: candidate.modelData.value === root.windowClass ? "Chosen" : ""
controlWidth: 96
divider: candidate.index < root.appMatches.length - 1
activatable: true
onActivated: {
root.windowClass = String(candidate.modelData.value ?? "");
root.appLabel = String(candidate.modelData.label ?? "");
}
}
}
Text {
width: parent.width
visible: root.appMatches.length === 0 && appSearch.text.trim() !== ""
text: "Nothing installed matches that."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
topPadding: 10
bottomPadding: 10
}
}
// ── What it should do ───────────────────────────────────────────────────
Column {
width: parent.width
visible: root.chosen
spacing: 0
SectionLabel {
text: "Behavior"
count: root.appLabel
}
Flow {
width: parent.width
spacing: 7
bottomPadding: 12
SettingsChip {
text: "Float"
active: root.floats
onClicked: root.floats = !root.floats
}
SettingsChip {
text: "Center when opened"
active: root.center
onClicked: root.center = !root.center
}
SettingsChip {
text: "Fixed size"
active: root.sizeOn
onClicked: root.sizeOn = !root.sizeOn
}
SettingsChip {
text: "Open on a workspace"
active: root.workspaceOn
onClicked: root.workspaceOn = !root.workspaceOn
}
SettingsChip {
text: "No animations"
active: root.noAnim
onClicked: root.noAnim = !root.noAnim
}
SettingsChip {
text: "Treat as a game"
active: root.game
onClicked: root.game = !root.game
}
SettingsChip {
text: "Never dim"
active: root.noDim
onClicked: root.noDim = !root.noDim
}
SettingsChip {
text: "Pin on every workspace"
active: root.pin
onClicked: root.pin = !root.pin
}
}
TextFieldRow {
visible: root.sizeOn
label: "Width"
detail: "Pixels, between " + WindowRules.minSize + " and " + WindowRules.maxSize
text: String(root.sizeWidth)
onAccepted: value => {
const parsed = parseInt(value, 10);
if (Number.isFinite(parsed))
root.sizeWidth = parsed;
}
}
TextFieldRow {
visible: root.sizeOn
label: "Height"
text: String(root.sizeHeight)
onAccepted: value => {
const parsed = parseInt(value, 10);
if (Number.isFinite(parsed))
root.sizeHeight = parsed;
}
}
ChoiceGrid {
width: parent.width
visible: root.workspaceOn
label: "Workspace"
detail: "The ten the keymap reaches."
current: root.workspace
options: root.workspaceOptions
divider: false
onPicked: value => root.workspace = Number(value)
}
Item {
width: parent.width
height: 50
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
tone: "accent"
text: root.editing ? "Save the rule" : "Add the rule"
enabled: root.complete && !WindowRules.reloading
onClicked: root.committed(root.draft())
}
SettingsButton {
text: "Cancel"
onClicked: root.canceled()
}
}
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 240
horizontalAlignment: Text.AlignRight
text: root.complete
? WindowRules.ruleLineFor(root.draft())
: "Tick at least one behavior — a rule that does nothing is a row you will wonder about later."
color: Theme.fgMuted
font.family: root.complete ? Theme.fontMono : Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
// ── The candidate lists ─────────────────────────────────────────────────
readonly property var workspaceOptions: {
const out = [];
for (let n = 1; n <= WindowRules.maxWorkspace; n++)
out.push({ value: n, label: String(n) });
return out;
}
// Windows open now, one entry per class. `wayland.appId` is the class
// Hyprland matches rules against; there is no separate class property.
readonly property var openWindows: {
const seen = {};
const out = [];
for (const toplevel of (Hyprland.toplevels?.values ?? [])) {
const appId = String(toplevel?.wayland?.appId ?? "");
if (appId === "" || seen[appId])
continue;
if (!WindowRules.validClass(appId))
continue;
if (WindowRules.indexOfClass(appId) >= 0)
continue;
seen[appId] = true;
const entry = DesktopEntries.heuristicLookup(appId);
out.push({ value: appId, label: String(entry?.name ?? appId) });
}
return out;
}
readonly property var appMatches: {
const needle = appSearch.text.trim().toLowerCase();
if (needle === "")
return [];
const out = [];
for (const entry of DesktopEntries.applications.values) {
if (entry.noDisplay)
continue;
if (String(entry.name).toLowerCase().indexOf(needle) < 0)
continue;
const windowClass = String(entry.startupClass ?? "") !== ""
? String(entry.startupClass)
: String(entry.id ?? "").replace(/\.desktop$/, "");
if (!WindowRules.validClass(windowClass))
continue;
if (WindowRules.indexOfClass(windowClass) >= 0)
continue;
out.push({ value: windowClass, label: String(entry.name) });
if (out.length >= 8)
break;
}
return out;
}
}
@@ -0,0 +1,116 @@
// One per-application window rule.
//
// Three lines rather than SettingRow's two, and that third line is the point:
// the rule was chosen as behaviors -- float, center, workspace 4 -- and it is
// written to the compositor as a rule. Showing only the friendly sentence makes
// the card a black box; showing only the rule makes it a config file with a
// nicer font. Both, with the compositor line in mono underneath, is how
// somebody learns what the ticks actually did.
//
// The rule text and the sentence both come from WindowRules, which is the one
// place that knows what hypr/rules.lua emits.
import QtQuick
import qs.config
import qs.services
Item {
id: root
required property var rule
property bool divider: true
signal editRequested
signal removeRequested
readonly property string windowClass: String(root.rule?.class ?? "")
readonly property int openNow: WindowRules.matchesOpen(root.windowClass)
width: parent ? parent.width : 620
implicitHeight: Math.max(64, copy.implicitHeight + 22)
Column {
id: copy
anchors.left: parent.left
anchors.right: trailing.left
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: String(root.rule?.label ?? root.windowClass)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
text: WindowRules.summaryFor(root.rule)
+ (root.openNow === 0
? ""
: (root.openNow === 1
? " · 1 window open now"
: " · " + root.openNow + " windows open now"))
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Text {
width: parent.width
text: WindowRules.ruleLineFor(root.rule)
color: Theme.fgMuted
font.family: Theme.fontMono
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
elide: Text.ElideRight
}
}
Item {
id: trailing
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: removeConfirm.armed ? 250 : 170
height: 32
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: "Edit"
enabled: !WindowRules.reloading
onClicked: root.editRequested()
}
ConfirmAction {
id: removeConfirm
anchors.verticalCenter: parent.verticalCenter
actionId: "window-rule-remove:" + root.windowClass
armText: "Remove…"
confirmText: "Remove it"
enabled: !WindowRules.reloading
onConfirmed: root.removeRequested()
}
}
}
Rectangle {
anchors.left: copy.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
visible: root.divider
color: Theme.alpha(Theme.fg, 0.065)
}
}
@@ -139,3 +139,7 @@ IdleTimeline 1.0 IdleTimeline.qml
PowerProfileTiles 1.0 PowerProfileTiles.qml PowerProfileTiles 1.0 PowerProfileTiles.qml
FieldActionRow 1.0 FieldActionRow.qml FieldActionRow 1.0 FieldActionRow.qml
ManualChapters 1.0 ManualChapters.qml ManualChapters 1.0 ManualChapters.qml
CustomShortcutRow 1.0 CustomShortcutRow.qml
CustomShortcutEditor 1.0 CustomShortcutEditor.qml
WindowRuleRow 1.0 WindowRuleRow.qml
WindowRuleEditor 1.0 WindowRuleEditor.qml
+337 -3
View File
@@ -13,11 +13,16 @@ native service stays native.
panama-network details CONNECTION panama-network details CONNECTION
panama-network forget CONNECTION panama-network forget CONNECTION
panama-network saved
panama-network set-autoconnect CONNECTION true|false panama-network set-autoconnect CONNECTION true|false
panama-network set-mac-random CONNECTION true|false panama-network set-mac-random CONNECTION true|false
panama-network set-metered CONNECTION yes|no|auto
panama-network set-ip CONNECTION 4|6 auto
panama-network set-ip CONNECTION 4|6 manual ADDR/PREFIX GATEWAY DNS[,DNS...]
panama-network import-vpn FILE panama-network import-vpn FILE
panama-network hotspot start SSID | hotspot stop | hotspot status panama-network hotspot start SSID | hotspot stop | hotspot status
panama-network join-enterprise SSID PROFILE IDENTITY [CA_CERT] (password on stdin) panama-network join-enterprise SSID PROFILE IDENTITY [CA_CERT] (password on stdin)
panama-network join-hidden SSID PROFILE wpa-psk|sae|none (password on stdin)
panama-network proxy get panama-network proxy get
panama-network proxy set none panama-network proxy set none
panama-network proxy set manual [HOST PORT] panama-network proxy set manual [HOST PORT]
@@ -87,6 +92,48 @@ SSID = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9 _.:+()@'&#!-]{0,31}$")
HOSTNAME = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.-]{0,253}[A-Za-z0-9])?$") HOSTNAME = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.-]{0,253}[A-Za-z0-9])?$")
PAC_URL = re.compile(r"^(https?|file)://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]{1,500}$") PAC_URL = re.compile(r"^(https?|file)://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]{1,500}$")
# Static addressing. Written out per octet rather than as \d{1,3}, because
# 999.999.999.999 is four groups of three digits and is not an address -- and a
# static address that NetworkManager refuses is a connection that comes up with
# no address at all, which is a worse failure than being told to retype it.
IPV4 = re.compile(r"(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}")
# IPv6 in every form a person types one: full, compressed at either end, and
# "::" alone. One alternative per position the elision can take, which is long
# but is the only shape that accepts fd00::42 and refuses fd00:::42.
IPV6 = re.compile(
r"([0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}"
r"|([0-9A-Fa-f]{1,4}:){1,7}:"
r"|([0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}"
r"|([0-9A-Fa-f]{1,4}:){1,5}(:[0-9A-Fa-f]{1,4}){1,2}"
r"|([0-9A-Fa-f]{1,4}:){1,4}(:[0-9A-Fa-f]{1,4}){1,3}"
r"|([0-9A-Fa-f]{1,4}:){1,3}(:[0-9A-Fa-f]{1,4}){1,4}"
r"|([0-9A-Fa-f]{1,4}:){1,2}(:[0-9A-Fa-f]{1,4}){1,5}"
r"|[0-9A-Fa-f]{1,4}:(:[0-9A-Fa-f]{1,4}){1,6}"
r"|:((:[0-9A-Fa-f]{1,4}){1,7}|:)"
)
# The prefix lengths, which are different numbers on the two stacks: /24 means
# something on both, /64 only on one.
IPV4_PREFIX = re.compile(r"3[0-2]|[12]?[0-9]")
IPV6_PREFIX = re.compile(r"12[0-8]|1[01][0-9]|[1-9]?[0-9]")
# How many nameservers a person may list. Not a NetworkManager limit -- a
# resolver stops trying long before this, and a field with twenty addresses in
# it is a typo rather than a configuration.
MAX_DNS = 6
# What a connection's metered flag is called, in each direction. NetworkManager
# spells "decide for yourself" as unknown; a settings page says "automatic",
# and the two must never be confused with "no", which is a claim.
METERED_NAMES = {"yes": "yes", "no": "no", "unknown": "auto", "": "auto"}
METERED_VALUES = {"yes": "yes", "no": "no", "auto": "unknown"}
# Key management for a hidden network, by the name the page offers. A closed
# set: an arbitrary key-mgmt string produces a profile that never associates,
# with nothing to say why.
WIFI_SECURITY = {"wpa-psk": "wpa-psk", "sae": "sae", "none": ""}
# The EAP profiles offered. A closed set, because "type your own EAP string" # The EAP profiles offered. A closed set, because "type your own EAP string"
# produces a profile that fails to authenticate with no way to tell why. # produces a profile that fails to authenticate with no way to tell why.
EAP_PROFILES = { EAP_PROFILES = {
@@ -199,6 +246,69 @@ def require_connection(value: str) -> str:
return require(NAME, value, "That is not a connection name.") return require(NAME, value, "That is not a connection name.")
def require_family(value: str) -> str:
if value not in ("4", "6"):
raise BoundaryError("An address is either IPv4 or IPv6.")
return value
def address_pattern(family: str) -> re.Pattern:
return IPV4 if family == "4" else IPV6
def require_cidr(family: str, value: str) -> str:
"""An address with its prefix, which NetworkManager will not take without.
Split from the right, because an IPv6 address is mostly colons and one
slash: rpartition finds the prefix wherever the address ends.
"""
address, separator, prefix = (value or "").rpartition("/")
if not separator:
raise BoundaryError("A static address needs a prefix, like 192.168.1.50/24."
if family == "4"
else "A static address needs a prefix, like fd00::42/64.")
require(address_pattern(family), address,
"That is not an IPv4 address." if family == "4" else "That is not an IPv6 address.")
require(IPV4_PREFIX if family == "4" else IPV6_PREFIX, prefix,
"An IPv4 prefix is a number from 0 to 32." if family == "4"
else "An IPv6 prefix is a number from 0 to 128.")
return f"{address}/{prefix}"
def require_gateway(family: str, value: str) -> str:
"""A gateway, or nothing. Empty is a real answer: a network segment with no
router is not a broken configuration, it is a network with no way out."""
text = (value or "").strip()
if text == "":
return ""
return require(address_pattern(family), text, "That is not a gateway address.")
def require_dns(family: str, value: str) -> list[str]:
"""The nameserver list, comma-separated as it is typed and as nmcli takes it.
Validated per stack rather than per address: NetworkManager stores ipv6.dns
as IPv6 addresses, and an IPv4 nameserver typed into the IPv6 box is a
profile it refuses to save with an error nobody can act on.
"""
servers = [part.strip() for part in (value or "").split(",") if part.strip()]
if len(servers) > MAX_DNS:
raise BoundaryError(f"That is more than {MAX_DNS} nameservers.")
for server in servers:
require(address_pattern(family), server, f"{server} is not a nameserver address.")
return servers
def plain(value: str) -> str:
"""nmcli's way of saying a property is unset, in either of its spellings."""
text = (value or "").strip()
return "" if text in ("--", "(none)") else text
def comma_list(value: str) -> list[str]:
return [part.strip() for part in plain(value).split(",") if part.strip()]
# ---------------------------------------------------------------- reading # ---------------------------------------------------------------- reading
@@ -284,6 +394,19 @@ def connection_state(name: str, note: str = "") -> dict:
"mac": "", "mac": "",
"macRandomized": False, "macRandomized": False,
"autoconnect": False, "autoconnect": False,
"metered": "auto",
# What the PROFILE says, which is not what the four fields above say.
# Those are the addresses on the wire; these are the addresses the
# profile asks for -- and the editor has to show the second, or a static
# address that has not been applied yet looks like it was never typed.
"ip4Method": "",
"ip4Addresses": [],
"ip4Gateway": "",
"ip4Dns": [],
"ip6Method": "",
"ip6Addresses": [],
"ip6Gateway": "",
"ip6Dns": [],
"note": note, "note": note,
"error": "", "error": "",
} }
@@ -296,6 +419,13 @@ def connection_state(name: str, note: str = "") -> dict:
state["type"] = found.get("connection.type", "") state["type"] = found.get("connection.type", "")
state["autoconnect"] = found.get("connection.autoconnect", "") in ("yes", "true") state["autoconnect"] = found.get("connection.autoconnect", "") in ("yes", "true")
state["macRandomized"] = first(found, *CLONED_MAC_KEYS).lower() == "random" state["macRandomized"] = first(found, *CLONED_MAC_KEYS).lower() == "random"
state["metered"] = METERED_NAMES.get(plain(found.get("connection.metered", "")), "auto")
for family, stack in (("4", "ipv4"), ("6", "ipv6")):
state[f"ip{family}Method"] = plain(found.get(f"{stack}.method", ""))
state[f"ip{family}Addresses"] = comma_list(found.get(f"{stack}.addresses", ""))
state[f"ip{family}Gateway"] = plain(found.get(f"{stack}.gateway", ""))
state[f"ip{family}Dns"] = comma_list(found.get(f"{stack}.dns", ""))
state["active"] = found.get("GENERAL.STATE", "") == "activated" state["active"] = found.get("GENERAL.STATE", "") == "activated"
state["ip4"] = (indexed(found, "IP4.ADDRESS") or [""])[0] state["ip4"] = (indexed(found, "IP4.ADDRESS") or [""])[0]
@@ -348,6 +478,143 @@ def set_mac_random(name: str, enabled: bool) -> dict:
return connection_state(name, "Reconnect for this to take effect.") return connection_state(name, "Reconnect for this to take effect.")
def set_metered(name: str, mode: str) -> dict:
"""Whether this connection costs money by the byte.
Three states, not two. "Automatic" is NetworkManager deciding from what the
network told it, and it is not the same claim as "no" -- a page that folded
the two together would report a guess as a fact.
"""
require_connection(name)
value = METERED_VALUES.get(mode)
if value is None:
raise BoundaryError("A connection is metered, not metered, or left to NetworkManager.")
nmcli("connection", "modify", name, "connection.metered", value, timeout=60)
return connection_state(name)
def set_ip(name: str, family: str, method: str, address: str = "",
gateway: str = "", dns_text: str = "") -> dict:
"""One stack's addressing, written in a single nmcli call.
Manual and automatic are one setting rather than two: switching back to
automatic has to clear the addresses manual mode left behind, or
NetworkManager keeps them and the connection comes up holding both. So every
property this verb can write is written on every call, with the empty string
where one should go back to unset.
"""
require_connection(name)
require_family(family)
stack = "ipv4" if family == "4" else "ipv6"
# Every argument is checked before NetworkManager is asked anything at all.
# Reading the connection first would mean a typo in an address costs an
# nmcli invocation before it is refused -- and, worse, would make "did
# something error" pass with the validation deleted.
if method == "auto":
settings = [f"{stack}.method", "auto",
f"{stack}.addresses", "",
f"{stack}.gateway", "",
f"{stack}.dns", "",
f"{stack}.ignore-auto-dns", "no"]
elif method == "manual":
servers = require_dns(family, dns_text)
settings = [f"{stack}.method", "manual",
f"{stack}.addresses", require_cidr(family, address),
f"{stack}.gateway", require_gateway(family, gateway),
f"{stack}.dns", ",".join(servers),
# Without this NetworkManager appends the ones DHCP handed
# out to the ones just typed, which is not what a person
# choosing "manual" is asking for.
f"{stack}.ignore-auto-dns", "yes" if servers else "no"]
else:
raise BoundaryError("An address is either automatic or manual.")
before = connection_state(name)
if not before["exists"]:
raise BoundaryError("That connection no longer exists.")
nmcli("connection", "modify", name, *settings, timeout=60)
# Brought back up only if it was up. Activating a connection that was down
# is a different action, and doing it here would join a network on the
# strength of somebody editing its addresses.
if not before["active"]:
return connection_state(name, "Applies the next time this connection comes up.")
activation = run(["nmcli", "-w", "45", "connection", "up", name], timeout=60)
if activation.returncode != 0:
raise BoundaryError(refusal(
activation, "The addresses were saved, but the connection would not come back up."))
return connection_state(name, "Applied.")
# ---------------------------------------------------------------- saved profiles
# NAME is read last and split with a limit, because a network legitimately
# called "Cafe: Guest" would otherwise be cut in half by the field separator.
# Every field before it is a UUID, a keyword or a number, none of which can
# contain a colon.
SAVED_FIELDS = "UUID,TYPE,AUTOCONNECT,ACTIVE,TIMESTAMP,NAME"
def visible_ssids() -> set[str]:
"""What the last scan saw, for the in-range flag.
`--rescan no`, deliberately: listing the profiles this machine remembers
must not make the radio go looking, or opening a settings page would cost
airtime and drop throughput on the connection being looked at.
"""
result = run(["nmcli", "-t", "-e", "no", "-f", "SSID",
"device", "wifi", "list", "--rescan", "no"])
if result.returncode != 0:
return set()
return {line.strip() for line in result.stdout.splitlines()
if line.strip() and line.strip() != "--"}
def saved_connections() -> dict:
"""Every profile NetworkManager holds, including the ones nowhere near here.
The scan is what makes this more than `nmcli connection show`: a saved
network is otherwise invisible until you are standing next to it, which is
exactly when you are least able to go and tidy it up.
"""
tool("nmcli", "NetworkManager is not available.")
result = run(["nmcli", "-t", "-e", "no", "-f", SAVED_FIELDS, "connection", "show"])
if result.returncode != 0:
raise BoundaryError(refusal(result, "NetworkManager would not list the saved networks."))
in_range = visible_ssids()
entries = []
for line in result.stdout.splitlines():
parts = line.split(":", 5)
if len(parts) != 6:
continue
uuid, kind, autoconnect, active, timestamp, name = (part.strip() for part in parts)
if not name:
continue
wireless = "wireless" in kind or kind == "wifi"
entries.append({
"name": name,
"uuid": uuid,
"type": kind,
"wifi": wireless,
"autoconnect": autoconnect in ("yes", "true"),
"active": active in ("yes", "true"),
"lastUsed": int(timestamp) if timestamp.isdigit() else 0,
# Only a Wi-Fi profile can be out of range. A wired profile is not
# somewhere else; it is a cable, and saying "out of range" about one
# would be inventing a fact. None means the question does not apply.
"inRange": (name in in_range) if wireless else None,
})
entries.sort(key=lambda entry: (not entry["active"], not entry["autoconnect"],
entry["name"].lower()))
return {"connections": entries, "error": ""}
# ---------------------------------------------------------------- VPN import # ---------------------------------------------------------------- VPN import
@@ -631,6 +898,53 @@ def join_enterprise_nmcli(ssid: str, profile_name: str, identity: str,
raise BoundaryError(refusal(activation, "That network refused the sign-in.")) raise BoundaryError(refusal(activation, "That network refused the sign-in."))
# ---------------------------------------------------------------- hidden Wi-Fi
def join_hidden(ssid: str, profile_name: str, security: str) -> dict:
"""A network that does not broadcast its name.
The only thing that makes this different from an ordinary join is
`802-11-wireless.hidden yes`: without it NetworkManager waits to be told the
network exists and never probes for it, so the profile saves and never
connects.
Validated before stdin is read, for the same reason join-enterprise is: a
request that was always going to be refused must not sit waiting for a
password first. The passphrase goes down the editor's stdin, never argv.
"""
require(SSID, ssid, "That is not a network name.")
require(NAME, profile_name, "That is not a connection name.")
if security not in WIFI_SECURITY:
raise BoundaryError("A hidden network is WPA2, WPA3, or open.")
secured = security != "none"
password = read_password() if secured else ""
if secured and not password:
raise BoundaryError("That network needs a password.")
script = [
f"set connection.id {profile_name}",
f"set 802-11-wireless.ssid {ssid}",
"set 802-11-wireless.hidden yes",
]
if secured:
script.append(f"set 802-11-wireless-security.key-mgmt {WIFI_SECURITY[security]}")
script.append(f"set 802-11-wireless-security.psk {password}")
script += ["save", "quit", ""]
nmcli("connection", "edit", "type", "wifi", "con-name", profile_name,
timeout=60, stdin_text="\n".join(script))
activation = run(["nmcli", "-w", "45", "connection", "up", profile_name], timeout=60)
if activation.returncode != 0:
raise BoundaryError(refusal(activation, "That network did not answer."))
state = connection_state(profile_name, "Joined.")
state["joined"] = state["exists"]
return state
# ---------------------------------------------------------------- proxy # ---------------------------------------------------------------- proxy
@@ -791,7 +1105,11 @@ FALLBACKS = {
"connection": {"connection": "", "exists": False, "uuid": "", "type": "", "connection": {"connection": "", "exists": False, "uuid": "", "type": "",
"interface": "", "active": False, "ip4": "", "ip6": "", "interface": "", "active": False, "ip4": "", "ip6": "",
"gateway": "", "dns": [], "mac": "", "macRandomized": False, "gateway": "", "dns": [], "mac": "", "macRandomized": False,
"autoconnect": False, "note": ""}, "autoconnect": False, "metered": "auto",
"ip4Method": "", "ip4Addresses": [], "ip4Gateway": "", "ip4Dns": [],
"ip6Method": "", "ip6Addresses": [], "ip6Gateway": "", "ip6Dns": [],
"note": ""},
"saved": {"connections": []},
"import": {"name": "", "uuid": "", "kind": ""}, "import": {"name": "", "uuid": "", "kind": ""},
"hotspot": {"active": False, "ssid": "", "password": "", "hotspot": {"active": False, "ssid": "", "password": "",
"connection": HOTSPOT_CONNECTION, "band": "", "interface": ""}, "connection": HOTSPOT_CONNECTION, "band": "", "interface": ""},
@@ -800,10 +1118,14 @@ FALLBACKS = {
"hardBlocked": False, "radios": 0}, "hardBlocked": False, "radios": 0},
} }
USAGE = ("Usage: panama-network details CONNECTION | forget CONNECTION | " USAGE = ("Usage: panama-network details CONNECTION | forget CONNECTION | saved | "
"set-autoconnect CONNECTION true|false | set-mac-random CONNECTION true|false | " "set-autoconnect CONNECTION true|false | set-mac-random CONNECTION true|false | "
"set-metered CONNECTION yes|no|auto | "
"set-ip CONNECTION 4|6 auto | "
"set-ip CONNECTION 4|6 manual ADDR/PREFIX GATEWAY DNS[,DNS...] | "
"import-vpn FILE | hotspot start SSID|stop|status | " "import-vpn FILE | hotspot start SSID|stop|status | "
"join-enterprise SSID PROFILE IDENTITY [CA_CERT] | " "join-enterprise SSID PROFILE IDENTITY [CA_CERT] | "
"join-hidden SSID PROFILE wpa-psk|sae|none | "
"proxy get | proxy set none|manual [HOST PORT]|auto [PAC_URL] | " "proxy get | proxy set none|manual [HOST PORT]|auto [PAC_URL] | "
"airplane status | airplane set true|false") "airplane status | airplane set true|false")
@@ -816,6 +1138,14 @@ def dispatch(arguments: list[str]) -> tuple[str, dict]:
return "connection", connection_state(require_connection(rest[0])) return "connection", connection_state(require_connection(rest[0]))
if verb == "forget" and len(rest) == 1: if verb == "forget" and len(rest) == 1:
return "connection", forget(rest[0]) return "connection", forget(rest[0])
if verb == "saved" and not rest:
return "saved", saved_connections()
if verb == "set-metered" and len(rest) == 2:
return "connection", set_metered(rest[0], rest[1])
if verb == "set-ip" and len(rest) == 3 and rest[2] == "auto":
return "connection", set_ip(rest[0], rest[1], "auto")
if verb == "set-ip" and len(rest) == 6 and rest[2] == "manual":
return "connection", set_ip(rest[0], rest[1], "manual", rest[3], rest[4], rest[5])
if verb == "set-autoconnect" and len(rest) == 2: if verb == "set-autoconnect" and len(rest) == 2:
return "connection", set_autoconnect(rest[0], require_bool(rest[1])) return "connection", set_autoconnect(rest[0], require_bool(rest[1]))
if verb == "set-mac-random" and len(rest) == 2: if verb == "set-mac-random" and len(rest) == 2:
@@ -831,6 +1161,8 @@ def dispatch(arguments: list[str]) -> tuple[str, dict]:
if verb == "join-enterprise" and len(rest) in (3, 4): if verb == "join-enterprise" and len(rest) in (3, 4):
return "connection", join_enterprise( return "connection", join_enterprise(
rest[0], rest[1], rest[2], rest[3] if len(rest) == 4 else "") rest[0], rest[1], rest[2], rest[3] if len(rest) == 4 else "")
if verb == "join-hidden" and len(rest) == 3:
return "connection", join_hidden(rest[0], rest[1], rest[2])
if verb == "proxy" and rest == ["get"]: if verb == "proxy" and rest == ["get"]:
return "proxy", proxy_get() return "proxy", proxy_get()
if verb == "proxy" and len(rest) >= 2 and rest[0] == "set": if verb == "proxy" and len(rest) >= 2 and rest[0] == "set":
@@ -847,7 +1179,9 @@ def shape_for(arguments: list[str]) -> str:
verb = arguments[0] if arguments else "" verb = arguments[0] if arguments else ""
return {"details": "connection", "forget": "connection", return {"details": "connection", "forget": "connection",
"set-autoconnect": "connection", "set-mac-random": "connection", "set-autoconnect": "connection", "set-mac-random": "connection",
"join-enterprise": "connection", "import-vpn": "import", "set-metered": "connection", "set-ip": "connection",
"join-enterprise": "connection", "join-hidden": "connection",
"saved": "saved", "import-vpn": "import",
"hotspot": "hotspot", "proxy": "proxy", "hotspot": "hotspot", "proxy": "proxy",
"airplane": "airplane"}.get(verb, "connection") "airplane": "airplane"}.get(verb, "connection")
+220 -1
View File
@@ -98,6 +98,13 @@ Singleton {
// Displayed binds come from the compositor and so already reflect any // Displayed binds come from the compositor and so already reflect any
// override; the override map is what tells us where they started. // override; the override map is what tells us where they started.
function shippedChordFor(currentChord: string): string { function shippedChordFor(currentChord: string): string {
// Custom chords are outside the override map's domain: a custom bind
// has no shipped chord to have been moved from, and answering one from
// the map would let a shipped bind's override claim a user's own
// shortcut. See the customBinds section below.
if (root.isCustomChord(currentChord))
return currentChord;
for (const shipped in root.overrides) { for (const shipped in root.overrides) {
if (root.overrides[shipped] === currentChord) if (root.overrides[shipped] === currentChord)
return shipped; return shipped;
@@ -131,6 +138,12 @@ Singleton {
} }
function rebind(currentChord: string, newChord: string): bool { function rebind(currentChord: string, newChord: string): bool {
// A custom bind is edited in place in `customBinds`; it never enters
// the override map. Routing here rather than refusing keeps the two
// mechanisms from ever meeting even if a caller does not check first.
if (root.isCustomChord(currentChord))
return root.rebindCustomBind(currentChord, newChord);
if (newChord === "" || newChord === currentChord) if (newChord === "" || newChord === currentChord)
return false; return false;
@@ -183,6 +196,209 @@ Singleton {
root.applyReload(); root.applyReload();
} }
// ── Named actions ───────────────────────────────────────────────────────
// A custom shortcut and an assigned four-finger gesture both store DATA,
// never a command: an enum `kind`, a validated `target`, and the `label`
// to show. hypr/actions.lua turns that data into something the compositor
// runs, through whitelist tables only -- so nothing a person can type into
// settings.json becomes executable, and an unknown kind or an invalid
// target means the bind is silently not emitted rather than guessed at.
//
// This is the same vocabulary on the QML side. `describeAction()` is the
// single authority here on whether an entry is one the Lua would emit;
// every page asks it rather than re-deriving the rules.
readonly property var actionKinds: ["app", "shell", "window"]
// An application id is an ARGUMENT to the launch-or-focus path, never text
// interpolated into a command, and this is the shape that path accepts.
readonly property var safeTargetPattern: /^[A-Za-z0-9@._-]{1,128}$/
// Shell verbs, each one a surface `shell.qml` already exposes over IPC (or,
// for the last two, a command hypr/keybinds.lua already binds). The target
// strings are keys of the whitelist table in hypr/actions.lua -- adding one
// here without adding it there means the entry simply never emits.
readonly property var shellActions: [
{ target: "dnd-toggle", label: "Toggle Do Not Disturb" },
{ target: "screenshot", label: "Screenshot or record" },
{ target: "screenshot-screen", label: "Screenshot the whole screen" },
{ target: "screenshot-window", label: "Screenshot the focused window" },
{ target: "screen-intelligence", label: "Read text on screen" },
{ target: "color-picker", label: "Pick a color" },
{ target: "clipboard", label: "Clipboard history" },
{ target: "launcher", label: "Open the launcher" },
{ target: "overview", label: "Open Mission Control" },
{ target: "quick-settings", label: "Open Quick Settings" },
{ target: "notifications", label: "Open notifications" },
{ target: "activity", label: "Open Activity" },
{ target: "cheatsheet", label: "Keyboard shortcuts" },
{ target: "settings", label: "Open Settings" },
{ target: "focus-session", label: "Focus session" },
{ target: "caffeine", label: "Keep the screen awake" },
{ target: "night-light", label: "Toggle Night Light" },
{ target: "power-menu", label: "Power menu" },
{ target: "lock", label: "Lock the screen" }
]
// Compositor verbs. The three window-state ones, then the ten workspaces
// the keymap already reaches -- generated rather than typed so the range
// and hypr/actions.lua's 1..10 check can never disagree.
//
// `workspace:N` goes TO that workspace; it does not carry the focused
// window there. Said in the label because "workspace 4" on its own reads
// like either one.
readonly property var windowActions: {
const out = [
{ target: "float-toggle", label: "Toggle floating" },
{ target: "fullscreen", label: "Fullscreen" },
{ target: "pin", label: "Pin on every workspace" }
];
for (let n = 1; n <= 10; n++)
out.push({ target: "workspace:" + n, label: "Go to workspace " + n });
return out;
}
function shellActionLabel(target: string): string {
const found = root.shellActions.find(action => action.target === target);
return found ? found.label : "";
}
function windowActionLabel(target: string): string {
const found = root.windowActions.find(action => action.target === target);
return found ? found.label : "";
}
// What an entry does, in a sentence -- or "" when it is not an action the
// Lua would emit, which is what every caller checks rather than validating
// kind and target for itself.
function describeAction(entry: var): string {
if (!entry || typeof entry !== "object")
return "";
const kind = String(entry.kind ?? "");
const target = String(entry.target ?? "");
if (target === "")
return "";
if (kind === "app")
return root.safeTargetPattern.test(target) ? "Application · launch-or-focus" : "";
if (kind === "shell") {
const shell = root.shellActionLabel(target);
return shell === "" ? "" : "Shell action · " + shell;
}
if (kind === "window") {
const window = root.windowActionLabel(target);
return window === "" ? "" : "Window · " + window;
}
return "";
}
// ── Custom shortcuts ────────────────────────────────────────────────────
// [{ chord, kind, target, label }]. hypr/keybinds.lua emits these after the
// shipped binds under a "Custom" category, skipping any entry whose chord
// is invalid, whose label is empty, whose action does not resolve, or whose
// chord a shipped bind already holds. This end refuses all four upstream so
// that a saved shortcut is a working one.
readonly property var customBinds: {
const stored = DesktopPreferences.get("customBinds");
return Array.isArray(stored) ? stored : [];
}
function normalizedChord(chord: string): string {
return String(chord).replace(/\s+/g, "").toLowerCase();
}
function isCustomChord(chord: string): bool {
const wanted = root.normalizedChord(chord);
if (wanted === "")
return false;
return root.customBinds.some(entry => root.normalizedChord(entry?.chord ?? "") === wanted);
}
function customBindFor(chord: string): var {
const wanted = root.normalizedChord(chord);
return root.customBinds.find(entry => root.normalizedChord(entry?.chord ?? "") === wanted) ?? null;
}
// Is the compositor actually answering this chord with this action? A
// stored entry is an intention; the keymap is the fact. Reported as true
// before the first read so a fresh page does not flash a warning it has no
// basis for.
function customBindApplied(entry: var): bool {
if (!root.loaded || !entry)
return true;
return root.boundTo(String(entry.chord ?? ""), "") === String(entry.label ?? "");
}
function writeCustomBinds(next: var, failure: string): bool {
if (!DesktopPreferences.set("customBinds", next)) {
root.lastError = failure;
return false;
}
root.applyReload();
return true;
}
function addCustomBind(chord: string, kind: string, target: string, label: string): bool {
const trimmed = String(label).trim();
if (chord === "" || trimmed === "") {
root.lastError = "A shortcut needs a chord and a name.";
return false;
}
if (root.describeAction({ kind: kind, target: target }) === "") {
root.lastError = "That is not an action Panama can bind.";
return false;
}
const taken = root.boundTo(chord, "");
if (taken !== "") {
root.lastError = `${chord} is already ${taken}.`;
return false;
}
if (root.isCustomChord(chord)) {
root.lastError = `${chord} is already one of your shortcuts.`;
return false;
}
const next = root.customBinds.slice();
next.push({ chord: chord, kind: String(kind), target: String(target), label: trimmed });
return root.writeCustomBinds(next, "That shortcut could not be saved.");
}
function rebindCustomBind(currentChord: string, newChord: string): bool {
if (newChord === "" || newChord === currentChord)
return false;
const at = root.customBinds.findIndex(entry =>
root.normalizedChord(entry?.chord ?? "") === root.normalizedChord(currentChord));
if (at < 0)
return false;
// `exceptCurrent` is the chord being vacated, so a shortcut can be
// re-recorded onto the chord it already holds without refusing itself.
const taken = root.boundTo(newChord, currentChord);
if (taken !== "") {
root.lastError = `${newChord} is already ${taken}.`;
return false;
}
if (root.isCustomChord(newChord)) {
root.lastError = `${newChord} is already one of your shortcuts.`;
return false;
}
const next = root.customBinds.slice();
next[at] = Object.assign({}, next[at], { chord: newChord });
return root.writeCustomBinds(next, "That shortcut could not be saved.");
}
function removeCustomBind(chord: string): bool {
const wanted = root.normalizedChord(chord);
const next = root.customBinds.filter(entry =>
root.normalizedChord(entry?.chord ?? "") !== wanted);
if (next.length === root.customBinds.length)
return false;
return root.writeCustomBinds(next, "That shortcut could not be removed.");
}
Process { Process {
id: reloadRun id: reloadRun
command: ["hyprctl", "reload"] command: ["hyprctl", "reload"]
@@ -388,7 +604,10 @@ Singleton {
// after them are the ones the substring derivation produces, kept so a // after them are the ones the substring derivation produces, kept so a
// machine whose compositor has not reloaded since the manifest was added // machine whose compositor has not reloaded since the manifest was added
// still sorts into a sensible order rather than alphabetically. // still sorts into a sensible order rather than alphabetically.
readonly property var groupOrder: ["Windows", "Workspaces", "Applications", "Shell", // Custom leads: a list of a hundred and thirty shipped binds is somewhere
// to look things up, and the two you invented are the two you came for.
readonly property var groupOrder: ["Custom",
"Windows", "Workspaces", "Applications", "Shell",
"Session", "Media & hardware", "Other", "Session", "Media & hardware", "Other",
"Focus", "Move & split", "Size", "Window state", "Focus", "Move & split", "Size", "Window state",
"Applications & shell", "Media & hardware keys"] "Applications & shell", "Media & hardware keys"]
+100 -11
View File
@@ -35,6 +35,12 @@ Singleton {
// connection name -> the helper's connection shape. See detailsFor(). // connection name -> the helper's connection shape. See detailsFor().
property var details: ({}) property var details: ({})
// Every profile NetworkManager holds, in range or not. See the `saved`
// verb: this is the list that is otherwise invisible until you are standing
// next to the network you wanted to tidy up.
property var savedConnections: []
property bool savedScanned: false
property var hotspot: ({}) property var hotspot: ({})
property string proxyMode: "none" property string proxyMode: "none"
property string proxyHost: "" property string proxyHost: ""
@@ -54,7 +60,7 @@ Singleton {
// Guards read the Process objects directly; a derived binding is stale // Guards read the Process objects directly; a derived binding is stale
// inside the handler that changes it. See DefaultApps.qml. // inside the handler that changes it. See DefaultApps.qml.
readonly property bool busy: mutation.running || enterprise.running || detailsQuery.running readonly property bool busy: mutation.running || passwordJoin.running || detailsQuery.running
// Connections whose details have been asked for, in order, so a burst of // Connections whose details have been asked for, in order, so a burst of
// requests becomes one query at a time rather than one Process each. // requests becomes one query at a time rather than one Process each.
@@ -131,6 +137,18 @@ Singleton {
} }
} }
function absorbSaved(text: string): void {
try {
const parsed = JSON.parse(text);
root.savedConnections = Array.isArray(parsed.connections) ? parsed.connections : [];
root.savedScanned = true;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read the saved networks.";
}
}
function absorbHotspot(text: string): void { function absorbHotspot(text: string): void {
try { try {
const parsed = JSON.parse(text); const parsed = JSON.parse(text);
@@ -185,6 +203,7 @@ Singleton {
function absorb(shape: string, subject: string, text: string): void { function absorb(shape: string, subject: string, text: string): void {
switch (shape) { switch (shape) {
case "connection": root.absorbDetails(subject, text); break; case "connection": root.absorbDetails(subject, text); break;
case "saved": root.absorbSaved(text); break;
case "hotspot": root.absorbHotspot(text); break; case "hotspot": root.absorbHotspot(text); break;
case "proxy": root.absorbProxy(text); break; case "proxy": root.absorbProxy(text); break;
case "airplane": root.absorbAirplane(text); break; case "airplane": root.absorbAirplane(text); break;
@@ -206,6 +225,10 @@ Singleton {
function forget(connection: string): void { function forget(connection: string): void {
root.run("connection", connection, ["forget", connection]); root.run("connection", connection, ["forget", connection]);
// The saved list is the one surface that shows profiles nobody is
// standing next to, so a forget nobody re-reads leaves a row for a
// profile that no longer exists. The timer waits for the mutation.
root.refreshSavedSoon();
} }
function setAutoconnect(connection: string, enabled: bool): void { function setAutoconnect(connection: string, enabled: bool): void {
@@ -218,6 +241,36 @@ Singleton {
["set-mac-random", connection, enabled ? "true" : "false"]); ["set-mac-random", connection, enabled ? "true" : "false"]);
} }
// "yes", "no" or "auto". Three states rather than two, because "automatic"
// is NetworkManager guessing from what the network said and "no" is a claim
// — see the helper's set_metered.
function setMetered(connection: string, mode: string): void {
root.run("connection", connection, ["set-metered", connection, String(mode)]);
}
// ---- static addressing
//
// family is "4" or "6" — the helper's own spelling, so nothing has to
// translate between two vocabularies on the way down.
function setIpAuto(connection: string, family: string): void {
root.run("connection", connection, ["set-ip", connection, String(family), "auto"]);
}
// Every field on every call, including the empty ones: the helper writes
// the whole stack at once so that switching modes cannot leave half the old
// configuration behind. `dns` is the comma-separated list as typed.
function setIpManual(connection: string, family: string, address: string,
gateway: string, dns: string): void {
root.run("connection", connection,
["set-ip", connection, String(family), "manual",
String(address), String(gateway), String(dns)]);
}
// ---- saved profiles
function refreshSaved(): void { root.run("saved", "", ["saved"]); }
// ---- VPN // ---- VPN
function importVpn(path: string): void { function importVpn(path: string): void {
@@ -273,16 +326,34 @@ Singleton {
// above: only this one ever opens stdin. // above: only this one ever opens stdin.
function joinEnterprise(ssid: string, profile: string, identity: string, function joinEnterprise(ssid: string, profile: string, identity: string,
password: string, caCert: string): void { password: string, caCert: string): void {
if (enterprise.running) if (passwordJoin.running)
return; return;
root.lastError = ""; root.lastError = "";
root.pendingPassword = password; root.pendingPassword = password;
enterprise.subject = ssid; passwordJoin.subject = ssid;
enterprise.command = String(caCert ?? "") !== "" passwordJoin.command = String(caCert ?? "") !== ""
? [root.helperPath, "join-enterprise", ssid, profile, identity, caCert] ? [root.helperPath, "join-enterprise", ssid, profile, identity, caCert]
: [root.helperPath, "join-enterprise", ssid, profile, identity]; : [root.helperPath, "join-enterprise", ssid, profile, identity];
enterprise.stdinEnabled = true; passwordJoin.stdinEnabled = true;
enterprise.running = true; passwordJoin.running = true;
}
// ---- hidden Wi-Fi
//
// Same passphrase path as the enterprise join, for the same reason: a
// passphrase in argv is published to every process on this machine through
// /proc. An open hidden network still goes down this path and writes an
// empty line, so the helper's read returns rather than waiting forever.
function joinHidden(ssid: string, profile: string, security: string,
password: string): void {
if (passwordJoin.running)
return;
root.lastError = "";
root.pendingPassword = password;
passwordJoin.subject = profile;
passwordJoin.command = [root.helperPath, "join-hidden", ssid, profile, security];
passwordJoin.stdinEnabled = true;
passwordJoin.running = true;
} }
// Everything that is not per-connection, in one call: what a page asks for // Everything that is not per-connection, in one call: what a page asks for
@@ -291,6 +362,7 @@ Singleton {
root.refreshProxy(); root.refreshProxy();
root.refreshAirplaneSoon(); root.refreshAirplaneSoon();
root.refreshHotspotSoon(); root.refreshHotspotSoon();
root.refreshSavedSoon();
for (const connection in root.details) for (const connection in root.details)
root.requestDetails(connection); root.requestDetails(connection);
} }
@@ -299,6 +371,7 @@ Singleton {
// over it rather than dropped by its running guard. // over it rather than dropped by its running guard.
function refreshAirplaneSoon(): void { airplaneSoon.restart(); } function refreshAirplaneSoon(): void { airplaneSoon.restart(); }
function refreshHotspotSoon(): void { hotspotSoon.restart(); } function refreshHotspotSoon(): void { hotspotSoon.restart(); }
function refreshSavedSoon(): void { savedSoon.restart(); }
onActiveChanged: if (root.active) root.refresh() onActiveChanged: if (root.active) root.refresh()
@@ -324,6 +397,17 @@ Singleton {
} }
} }
Timer {
id: savedSoon
interval: 400
onTriggered: {
if (mutation.running)
savedSoon.restart();
else
root.refreshSaved();
}
}
// The next queued details read, one tick after the last one exits. Draining // The next queued details read, one tick after the last one exits. Draining
// from inside onExited would look at a `running` that has not gone false // from inside onExited would look at a `running` that has not gone false
// yet, and the queue would stall on its own guard. // yet, and the queue would stall on its own guard.
@@ -369,24 +453,29 @@ Singleton {
} }
Process { Process {
id: enterprise id: passwordJoin
property string subject: "" property string subject: ""
onStarted: { onStarted: {
enterprise.write(root.pendingPassword + "\n"); passwordJoin.write(root.pendingPassword + "\n");
// Held for as long as it takes to hand over, and no longer. // Held for as long as it takes to hand over, and no longer.
root.pendingPassword = ""; root.pendingPassword = "";
// Closing stdin is what lets the helper's read return; without it // Closing stdin is what lets the helper's read return; without it
// the join waits forever for a line that is already sent. // the join waits forever for a line that is already sent.
enterprise.stdinEnabled = false; passwordJoin.stdinEnabled = false;
} }
stdout: StdioCollector { stdout: StdioCollector {
onStreamFinished: root.absorbDetails(enterprise.subject, this.text) onStreamFinished: root.absorbDetails(passwordJoin.subject, this.text)
} }
stderr: StdioCollector { stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
} }
onExited: root.pendingPassword = "" onExited: {
root.pendingPassword = "";
// A join makes a profile, so the saved list is out of date the
// moment this returns.
root.refreshSavedSoon();
}
} }
} }
+121 -31
View File
@@ -69,6 +69,12 @@ Singleton {
// Settings that are real but have no schema entry, because the system owns // Settings that are real but have no schema entry, because the system owns
// them rather than Panama. Without these, searching "timezone" would fail // them rather than Panama. Without these, searching "timezone" would fail
// on a settings app that plainly has one. // on a settings app that plainly has one.
//
// An entry may name a `section` as well as a page. A page with tabs opens
// on the tab holding the thing that was searched for, rather than on
// whichever tab it opens on by default -- see SettingsSidebar. Sections are
// only worth naming for a page that consumes one (ShellState.takeSettings-
// Section); everything else leaves it off and routes as before.
readonly property var extraEntries: [ readonly property var extraEntries: [
{ label: "Manual", detail: "How this desktop works, in chapters", page: "manual" }, { label: "Manual", detail: "How this desktop works, in chapters", page: "manual" },
{ label: "Getting started", detail: "Coming from GNOME, macOS or Windows", page: "manual" }, { label: "Getting started", detail: "Coming from GNOME, macOS or Windows", page: "manual" },
@@ -92,6 +98,22 @@ Singleton {
{ label: "IP address", detail: "The address, gateway, DNS servers, and hardware address of a connection", page: "connectivity" }, { label: "IP address", detail: "The address, gateway, DNS servers, and hardware address of a connection", page: "connectivity" },
{ label: "Forget a Wi-Fi network", detail: "Remove a saved network so it stops connecting on its own", page: "connectivity" }, { label: "Forget a Wi-Fi network", detail: "Remove a saved network so it stops connecting on its own", page: "connectivity" },
{ label: "Enterprise Wi-Fi", detail: "Join a network that asks for an identity and a password", page: "connectivity" }, { label: "Enterprise Wi-Fi", detail: "Join a network that asks for an identity and a password", page: "connectivity" },
// Tier 2 network truths. Each of these was a reason to open a terminal
// or GNOME's panel, and none of them is the label of a preference: a
// static address is a profile property, a saved network is a file
// NetworkManager keeps, and metered is a flag on both.
{ label: "Saved networks", detail: "Every network this machine remembers, including the ones nowhere near you, and forgetting one", page: "connectivity" },
{ label: "Join a hidden network", detail: "A network that does not broadcast its name — type the name and its security", page: "connectivity" },
{ label: "Hidden network", detail: "Join a Wi-Fi network that does not announce itself", page: "connectivity" },
{ label: "Metered connection", detail: "Mark a connection as costing money by the byte, so updates and large downloads wait", page: "connectivity" },
{ label: "Static IP address", detail: "Set a manual IPv4 or IPv6 address, gateway and DNS for one connection", page: "connectivity" },
{ label: "Manual IP address", detail: "Turn off DHCP for a connection and enter the address yourself", page: "connectivity" },
{ label: "DNS servers", detail: "The nameservers a connection uses, and replacing the ones it is handed", page: "connectivity" },
{ label: "Show the Wi-Fi password", detail: "A QR code a phone can scan, so nobody has to read the password out", page: "connectivity" },
// The two words people type for the same socket. Neither appears in the
// page's own labels, which say "Wired" once and "Ethernet" once.
{ label: "Wired network", detail: "The Ethernet connection, its link speed, and its addresses", page: "connectivity" },
{ label: "Ethernet", detail: "The wired connection: turn it off, or open its addresses", page: "connectivity" },
{ label: "Default applications", detail: "Browser, mail, files", page: "applications" }, { label: "Default applications", detail: "Browser, mail, files", page: "applications" },
// The Applications tab manages applications now, rather than only // The Applications tab manages applications now, rather than only
// pointing file types at them, so the things people come looking for — // pointing file types at them, so the things people come looking for —
@@ -264,6 +286,11 @@ Singleton {
{ label: "Pinned applications", detail: "Reorder the dock by dragging, here or on the dock itself", page: "dock" }, { label: "Pinned applications", detail: "Reorder the dock by dragging, here or on the dock itself", page: "dock" },
{ label: "Bar text", detail: "Keep the bar legible on any wallpaper", page: "bar" }, { label: "Bar text", detail: "Keep the bar legible on any wallpaper", page: "bar" },
{ label: "Control Center sections", detail: "Choose what the panel offers", page: "control-center" }, { label: "Control Center sections", detail: "Choose what the panel offers", page: "control-center" },
{ label: "Do Not Disturb tile", detail: "The Control Center switch that holds banners back, beside Presentation", page: "control-center" },
// Signing out has no preference anywhere: it is a power-menu verb, and
// "log out" returned nothing at all on a desktop that plainly does it.
{ label: "Log out", detail: "Sign out of this session from the power menu — the same menu that restarts and powers off", page: "power" },
{ label: "Sign out", detail: "End this session and return to the login screen", page: "power" },
{ label: "Do Not Disturb", detail: "Hold banners back until you turn it off", page: "notifications" }, { label: "Do Not Disturb", detail: "Hold banners back until you turn it off", page: "notifications" },
{ label: "Quiet hours", detail: "The schedule the Sleep focus mode keeps", page: "notifications" }, { label: "Quiet hours", detail: "The schedule the Sleep focus mode keeps", page: "notifications" },
{ label: "Critical alerts break through", detail: "Let urgent notifications past Do Not Disturb", page: "notifications" }, { label: "Critical alerts break through", detail: "Let urgent notifications past Do Not Disturb", page: "notifications" },
@@ -277,6 +304,18 @@ Singleton {
// a row — so searching for the thing people actually want to do would // a row — so searching for the thing people actually want to do would
// otherwise find only the shortcut it is being done to. // otherwise find only the shortcut it is being done to.
{ label: "Rebind a shortcut", detail: "Change the keys an action answers to, or put them back", page: "shortcuts" }, { label: "Rebind a shortcut", detail: "Change the keys an action answers to, or put them back", page: "shortcuts" },
// Tier 2. A custom shortcut, an app rule and a gesture assignment are
// all the same thing wearing three hats -- a named action -- and none
// of the three is a preference with a label to find it by.
{ label: "Custom shortcut", detail: "Bind your own keys to an application, a shell action, or a window action", page: "shortcuts" },
{ label: "Add a shortcut", detail: "Press the chord you want, then pick what it should do", page: "shortcuts" },
{ label: "Launch an app with a shortcut", detail: "Give an application its own key combination", page: "shortcuts" },
{ label: "App rules", detail: "Per-application window rules: float, centre, size, workspace, and no dimming", page: "tiling" },
{ label: "Window rules", detail: "Make one application always float, open on a workspace, or skip the animations", page: "tiling" },
{ label: "Always float a window", detail: "A per-application rule, so one application stops being tiled", page: "tiling" },
{ label: "Gestures", detail: "Three-finger swipes as shipped, and four-finger swipes you assign yourself", page: "mouse" },
{ label: "Touchpad gestures", detail: "What swiping with three or four fingers does", page: "mouse" },
{ label: "Four-finger swipe", detail: "Assign an application or a shell action to each direction", page: "mouse" },
{ label: "Pointer test area", detail: "Scribble and scroll to feel a pointer change before keeping it", page: "mouse" }, { label: "Pointer test area", detail: "Scribble and scroll to feel a pointer change before keeping it", page: "mouse" },
{ label: "Connected input devices", detail: "The keyboards, mice, and touchpad this machine can see", page: "mouse" }, { label: "Connected input devices", detail: "The keyboards, mice, and touchpad this machine can see", page: "mouse" },
// Accessibility. The schema covers the switches by their own labels, so // Accessibility. The schema covers the switches by their own labels, so
@@ -294,13 +333,18 @@ Singleton {
{ label: "Screen reader", detail: "Start Orca and see whether the accessibility bus is up", page: "accessibility" }, { label: "Screen reader", detail: "Start Orca and see whether the accessibility bus is up", page: "accessibility" },
{ label: "Orca", detail: "The screen reader: whether it is running, and starting or stopping it", page: "accessibility" }, { label: "Orca", detail: "The screen reader: whether it is running, and starting or stopping it", page: "accessibility" },
{ label: "Sticky keys", detail: "Why sticky, slow and bounce keys are not offered in this session", page: "accessibility" }, { label: "Sticky keys", detail: "Why sticky, slow and bounce keys are not offered in this session", page: "accessibility" },
// The colour filter is a compositor shader rather than a switch, so it
// is findable by the condition rather than only by the word "filter".
{ label: "Color filter", detail: "A whole-screen filter the compositor renders: grayscale, or one for each kind of colour blindness", page: "accessibility" },
{ label: "Grayscale", detail: "Drain the colour out of the whole screen", page: "accessibility" },
{ label: "Color blindness", detail: "Protanopia, deuteranopia and tritanopia filters applied to the whole screen", page: "accessibility" },
{ label: "Dictation", detail: "Speech to text with Super+D, typed where the cursor is", page: "dictation" }, { label: "Dictation", detail: "Speech to text with Super+D, typed where the cursor is", page: "dictation" },
{ label: "Speech to text", detail: "Set up the local speech server and model", page: "dictation" }, { label: "Speech to text", detail: "Set up the local speech server and model", page: "dictation" },
{ label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" }, { label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" },
{ label: "Copy health report", detail: "Copy a redacted Panama doctor report", page: "services" }, { label: "Copy health report", detail: "Copy a redacted Panama doctor report", page: "services" },
{ label: "Lock screen background", detail: "Choose a blurred desktop, wallpaper, or solid color", page: "appearance" }, { label: "Lock screen background", detail: "Choose a blurred desktop, wallpaper, or solid color", page: "appearance", section: "background" },
{ label: "Password field", detail: "Choose whether the empty lock-screen field stays visible", page: "appearance" }, { label: "Password field", detail: "Choose whether the empty lock-screen field stays visible", page: "appearance", section: "background" },
{ label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance" }, { label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance", section: "background" },
{ label: "Arrange displays", detail: "Drag connected displays into their physical positions", page: "displays" }, { label: "Arrange displays", detail: "Drag connected displays into their physical positions", page: "displays" },
{ label: "Monitor position", detail: "Set where each display sits in the desktop", page: "displays" }, { label: "Monitor position", detail: "Set where each display sits in the desktop", page: "displays" },
{ label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" }, { label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" },
@@ -316,18 +360,18 @@ Singleton {
{ label: "Mirror displays", detail: "Show the same picture on a second display", page: "displays" }, { label: "Mirror displays", detail: "Show the same picture on a second display", page: "displays" },
{ label: "Variable refresh rate", detail: "Override the gaming policy for one display", page: "displays" }, { label: "Variable refresh rate", detail: "Override the gaming policy for one display", page: "displays" },
{ label: "Monitor brightness", detail: "The monitor's own backlight, over DDC", page: "displays" }, { label: "Monitor brightness", detail: "The monitor's own backlight, over DDC", page: "displays" },
{ label: "Theme profiles", detail: "Switch between Moon, Moon Rose, Day, and saved themes", page: "appearance" }, { label: "Theme profiles", detail: "Switch between Moon, Moon Rose, Day, and saved themes", page: "appearance", section: "themes" },
{ label: "Themes", detail: "Light and dark mode, and the theme for each", page: "appearance" }, { label: "Themes", detail: "Light and dark mode, and the theme for each", page: "appearance", section: "themes" },
{ label: "Dark mode", detail: "Flip the desktop to your dark theme", page: "appearance" }, { label: "Dark mode", detail: "Flip the desktop to your dark theme", page: "appearance", section: "themes" },
{ label: "Light mode", detail: "Flip the desktop to your light theme", page: "appearance" }, { label: "Light mode", detail: "Flip the desktop to your light theme", page: "appearance", section: "themes" },
{ label: "Theme editor", detail: "Build your own theme — colors, saturation, and effects", page: "appearance" }, { label: "Theme editor", detail: "Build your own theme — colors, saturation, and effects", page: "appearance", section: "editor" },
{ label: "Catppuccin", detail: "Mocha and Latte, in the theme galleries", page: "appearance" }, { label: "Catppuccin", detail: "Mocha and Latte, in the theme galleries", page: "appearance", section: "themes" },
{ label: "Nord", detail: "The arctic dark theme, in the gallery", page: "appearance" }, { label: "Nord", detail: "The arctic dark theme, in the gallery", page: "appearance", section: "themes" },
{ label: "Gruvbox", detail: "Dark and light, in the theme galleries", page: "appearance" }, { label: "Gruvbox", detail: "Dark and light, in the theme galleries", page: "appearance", section: "themes" },
{ label: "Everforest", detail: "Dark and light, in the theme galleries", page: "appearance" }, { label: "Everforest", detail: "Dark and light, in the theme galleries", page: "appearance", section: "themes" },
{ label: "Tokyo Night", detail: "Moon and Day, the shipped defaults", page: "appearance" }, { label: "Tokyo Night", detail: "Moon and Day, the shipped defaults", page: "appearance", section: "themes" },
{ label: "Video wallpaper", detail: "A looping video as the desktop background", page: "appearance" }, { label: "Video wallpaper", detail: "A looping video as the desktop background", page: "appearance", section: "background" },
{ label: "Advanced accent", detail: "Adjust primary and secondary hue, saturation, and value", page: "appearance" }, { label: "Advanced accent", detail: "Adjust primary and secondary hue, saturation, and value", page: "appearance", section: "editor" },
{ label: "Pick colour from screen", detail: "Sample an accent colour with hyprpicker", page: "appearance" } { label: "Pick colour from screen", detail: "Sample an accent colour with hyprpicker", page: "appearance" }
] ]
@@ -335,41 +379,77 @@ Singleton {
return root.groupPages[group] ?? "home"; return root.groupPages[group] ?? "home";
} }
// [{ label, detail, page, kind }] for a query. Empty query yields nothing: // Both sides of a comparison, in the one spelling.
// the sidebar shows its normal navigation in that case. //
// The hyphens go because they are a typographic choice rather than a word
// boundary, and the desktop's most-searched noun is the worst case:
// everything here spells it "Wi-Fi" and nobody types it that way, so "wifi"
// matched nothing at all. Applied to the query and the index alike, so the
// rule is a spelling equivalence rather than a special case for one word.
function flatten(text: string): string {
return String(text).trim().toLowerCase().replace(/-/g, "");
}
// [{ label, detail, page, kind, section }] for a query. Empty query yields
// nothing: the sidebar shows its normal navigation in that case.
//
// The needle is split on whitespace and every token has to appear somewhere
// in the haystack -- an AND over words rather than one contiguous
// substring. The old shape matched the whole query as typed, so "wifi
// password", "log out" and "metered" returned nothing on a settings app
// that has all three: the words are all present, just not adjacent and not
// in that order. Order was the accidental part, and it was doing the most
// damage.
function search(query: string): var { function search(query: string): var {
const needle = String(query).trim().toLowerCase(); const needle = root.flatten(query);
if (needle === "") if (needle === "")
return []; return [];
const tokens = needle.split(/\s+/).filter(token => token !== "");
if (tokens.length === 0)
return [];
function hit(haystack) {
const text = root.flatten(haystack);
return tokens.every(token => text.indexOf(token) >= 0);
}
const results = []; const results = [];
const seen = {}; const seen = {};
function add(label, detail, page, kind) { function add(label, detail, page, kind, section) {
const dedupe = `${kind}:${label}:${page}`; const dedupe = `${kind}:${label}:${page}`;
if (seen[dedupe]) if (seen[dedupe])
return; return;
seen[dedupe] = true; seen[dedupe] = true;
results.push({ label: label, detail: detail, page: page, kind: kind }); results.push({
label: label,
detail: detail,
page: page,
kind: kind,
// "" means "the page's own first tab", which is every result
// that does not name one. See SettingsSidebar.
section: String(section ?? "")
});
} }
for (const entry of PreferenceSchema.entries) { for (const entry of PreferenceSchema.entries) {
if (entry.internal) if (entry.internal)
continue; continue;
const optionLabels = (entry.options ?? []).map(option => option.label).join(" "); const optionLabels = (entry.options ?? []).map(option => option.label).join(" ");
const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group} ${optionLabels}`.toLowerCase(); const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group} ${optionLabels}`;
if (haystack.indexOf(needle) >= 0) if (hit(haystack))
add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting"); add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting", "");
} }
for (const entry of root.extraEntries) { for (const entry of root.extraEntries) {
if (`${entry.label} ${entry.detail}`.toLowerCase().indexOf(needle) >= 0) if (hit(`${entry.label} ${entry.detail}`))
add(entry.label, entry.detail, entry.page, "setting"); add(entry.label, entry.detail, entry.page, "setting", entry.section ?? "");
} }
for (const bind of Keybinds.binds) { for (const bind of Keybinds.binds) {
if (bind.description.toLowerCase().indexOf(needle) >= 0) if (hit(bind.description))
add(bind.description, bind.chord, "shortcuts", "shortcut"); add(bind.description, bind.chord, "shortcuts", "shortcut", "");
} }
// Exact prefix matches first: typing "blur" should put "Blur" above // Exact prefix matches first: typing "blur" should put "Blur" above
@@ -377,15 +457,25 @@ Singleton {
// its explanation. An exact enum option also leads: "slideshow" is a // its explanation. An exact enum option also leads: "slideshow" is a
// mode choice, so Wallpaper mode belongs above the interval row that // mode choice, so Wallpaper mode belongs above the interval row that
// merely explains it. // merely explains it.
//
// Between those two comes the tokenized rule: a row whose LABEL holds
// every word of the query beats one that only holds some of them, or
// holds them in its explanation. Without it, "wifi password" would rank
// every row whose detail happens to say "password" alongside the rows
// that are actually about the Wi-Fi password.
return results.sort((a, b) => { return results.sort((a, b) => {
const aSpec = PreferenceSchema.entries.find(entry => entry.label === a.label); const aSpec = PreferenceSchema.entries.find(entry => entry.label === a.label);
const bSpec = PreferenceSchema.entries.find(entry => entry.label === b.label); const bSpec = PreferenceSchema.entries.find(entry => entry.label === b.label);
const ao = (aSpec?.options ?? []).some(option => option.label.toLowerCase() === needle); const ao = (aSpec?.options ?? []).some(option => root.flatten(option.label) === needle);
const bo = (bSpec?.options ?? []).some(option => option.label.toLowerCase() === needle); const bo = (bSpec?.options ?? []).some(option => root.flatten(option.label) === needle);
if (ao !== bo) if (ao !== bo)
return ao ? -1 : 1; return ao ? -1 : 1;
const al = a.label.toLowerCase(); const al = root.flatten(a.label);
const bl = b.label.toLowerCase(); const bl = root.flatten(b.label);
const at = tokens.every(token => al.indexOf(token) >= 0) ? 0 : 1;
const bt = tokens.every(token => bl.indexOf(token) >= 0) ? 0 : 1;
if (at !== bt)
return at - bt;
const ap = al === needle ? 0 : (al.indexOf(needle) === 0 ? 1 : 2); const ap = al === needle ? 0 : (al.indexOf(needle) === 0 ? 1 : 2);
const bp = bl === needle ? 0 : (bl.indexOf(needle) === 0 ? 1 : 2); const bp = bl === needle ? 0 : (bl.indexOf(needle) === 0 ? 1 : 2);
return ap !== bp ? ap - bp : al.localeCompare(bl); return ap !== bp ? ap - bp : al.localeCompare(bl);
@@ -652,6 +652,81 @@ Singleton {
root.applyOptions({ directScanoutPolicy: policy }); root.applyOptions({ directScanoutPolicy: policy });
} }
// ── Color filters ───────────────────────────────────────────────────────
// The stored preference is an enum; what the compositor wants is a shader
// path. That mapping cannot be a `hypr:` block on the schema entry -- the
// read-back would compare "grayscale" against a filename and fail every
// shape and sweep contract -- so it lives here, and hypr/looks.lua does the
// same lookup for the value the config carries at launch.
//
// The shaders are installed by the hypr directory symlink, so the path is
// the deployed one rather than the repository's.
readonly property string shaderDir:
(Quickshell.env("XDG_CONFIG_HOME") || `${Quickshell.env("HOME")}/.config`) + "/hypr/shaders"
readonly property var colorFilterShaders: ({
"grayscale": "grayscale.frag",
"protanopia": "protanopia.frag",
"deuteranopia": "deuteranopia.frag",
"tritanopia": "tritanopia.frag"
})
// "" for none, and for any value this build does not ship a shader for --
// an unknown filter turns the filter off rather than leaving the previous
// one on under a new name.
function colorFilterPath(name: string): string {
const file = root.colorFilterShaders[String(name)];
return file === undefined ? "" : `${root.shaderDir}/${file}`;
}
property string colorFilterPending: ""
Process {
id: colorFilterWrite
stdout: StdioCollector {
onStreamFinished: {
// `hyprctl eval` exits 0 on a Lua error and reports it on
// stdout, so the exit status proves nothing. Read it back.
if (this.text.indexOf("error:") >= 0) {
root.lastError = "The color filter could not be applied.";
return;
}
colorFilterVerify.exec(["hyprctl", "-j", "getoption", "decoration:screen_shader"]);
}
}
}
Process {
id: colorFilterVerify
stdout: StdioCollector {
onStreamFinished: {
try {
const observed = String(JSON.parse(this.text).str ?? "");
if (observed !== root.colorFilterPending) {
root.lastError = "The compositor did not take the color filter.";
return;
}
root.lastError = "";
} catch (error) {
root.lastError = "The compositor did not say whether the color filter applied.";
}
}
}
}
// Applies the filter to the running compositor. The preference is written
// by the row that calls this; nothing here stores anything.
function applyColorFilter(name: string): void {
if (colorFilterWrite.running)
return;
const path = root.colorFilterPath(name);
root.colorFilterPending = path;
colorFilterWrite.exec(["hyprctl", "eval",
`hl.config({ decoration = { screen_shader = "${path.replace(/["\\]/g, "")}" } })`]);
}
// Replays every compositor-owned preference in one batch at shell start, so // Replays every compositor-owned preference in one batch at shell start, so
// a value the user changed in Settings survives a reboot even though the // a value the user changed in Settings survives a reboot even though the
// Lua config only reads the file once, at launch. // Lua config only reads the file once, at launch.
@@ -0,0 +1,291 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// Per-application window rules: how a named application behaves when it opens.
//
// Modelled on Workspaces.qml, and for the same reason. A window rule is read by
// Hyprland at config time and cannot be taken back at runtime, so the only way
// to change the set is `hyprctl reload`, which re-runs the config and lets
// hypr/rules.lua emit exactly the rules the preference asks for.
//
// That makes this service two things: the reload, and an honest answer to "has
// it taken effect yet". The second is the harder half here, because Hyprland
// publishes no window-rule listing -- `hyprctl` offers `workspacerules` and
// nothing equivalent for these. So `applied` is not a read-back of the rules
// themselves: it is the exit status of the reload that last ran, against the
// rule set that was stored when it ran. The page says applied-on-reload in
// those words rather than dressing that up as a confirmation it is not.
//
// What CAN be read back is the effect: `matchesOpen()` counts the windows open
// right now whose class a rule names, which is the difference between "we wrote
// a rule for org.gnome.Calculator" and "the calculator on your screen is the
// thing this rule is about".
//
// Nothing stored here is a command. A rule is a literal class string plus
// booleans and two bounded numbers; hypr/rules.lua regex-escapes the class
// before Hyprland's matcher sees it and skips any entry that fails validation,
// so an entry that arrived by hand-editing settings.json is inert rather than
// dangerous.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import QtQuick
import qs.config
Singleton {
id: root
// [{ class, label, float, center, size, workspace, noAnim, game, noDim, pin }]
readonly property var rules: {
const stored = DesktopPreferences.get("windowRules");
return Array.isArray(stored) ? stored : [];
}
property bool reloading: false
property string lastError: ""
// The rule set the last successful reload actually carried. Compared by
// value because the array is rewritten wholesale on every edit.
property string appliedSignature: ""
readonly property string signature: JSON.stringify(root.rules)
// Has the compositor been through a reload since the rules last changed?
// An empty rule set needs no reload to be true of the compositor, so it is
// applied by definition.
readonly property bool applied: root.rules.length === 0
|| root.appliedSignature === root.signature
// ── Validation ──────────────────────────────────────────────────────────
// The same rules hypr/rules.lua applies, so the page can refuse an entry
// rather than saving one the compositor will silently drop.
readonly property int maxClassLength: 128
readonly property int minSize: 50
readonly property int maxSize: 10000
readonly property int maxWorkspace: 10
// The shell's own surfaces are not addressable. A rule that floated or
// moved a Quickshell layer would break the desktop from inside Settings,
// and there is no legitimate reason to write one.
readonly property var shellClassPattern: /^(quickshell|qs-)/i
function isShellClass(windowClass: string): bool {
return root.shellClassPattern.test(String(windowClass).trim());
}
// A class is matched literally, so anything printable is allowed -- but a
// control character or a newline could not have come from a real window and
// would end up inside a compositor rule.
function validClass(windowClass: string): bool {
const text = String(windowClass ?? "").trim();
if (text === "" || text.length > root.maxClassLength)
return false;
if (/[\x00-\x1f\x7f]/.test(text))
return false;
return !root.isShellClass(text);
}
function validSize(size: var): bool {
if (size === null || size === undefined)
return true;
if (!Array.isArray(size) || size.length !== 2)
return false;
return size.every(value => Number.isFinite(value)
&& value >= root.minSize && value <= root.maxSize);
}
function validWorkspace(workspace: var): bool {
if (workspace === null || workspace === undefined)
return true;
return Number.isFinite(workspace) && workspace >= 1 && workspace <= root.maxWorkspace;
}
function validRule(rule: var): bool {
if (!rule || typeof rule !== "object")
return false;
return root.validClass(rule.class)
&& root.validSize(rule.size ?? null)
&& root.validWorkspace(rule.workspace ?? null);
}
// A rule that ticks nothing is a rule that does nothing, which is a row
// somebody would later wonder about.
function hasBehavior(rule: var): bool {
if (!rule)
return false;
return rule.float === true || rule.center === true || rule.noAnim === true
|| rule.game === true || rule.noDim === true || rule.pin === true
|| (Array.isArray(rule.size) && rule.size.length === 2)
|| Number.isFinite(rule.workspace);
}
// ── How a rule reads ────────────────────────────────────────────────────
// Two spellings, both from here so the page never invents a third: the
// plain sentence somebody chose these ticks by, and the compositor line
// underneath it for anyone who wants to see what was actually written.
function summaryFor(rule: var): string {
const parts = [];
if (rule?.float === true)
parts.push("Floats");
if (Array.isArray(rule?.size) && rule.size.length === 2)
parts.push(`fixed size (${rule.size[0]} × ${rule.size[1]})`);
if (rule?.center === true)
parts.push("centered");
if (Number.isFinite(rule?.workspace))
parts.push("opens on workspace " + rule.workspace);
if (rule?.game === true)
parts.push("treated as a game");
if (rule?.noAnim === true)
parts.push("no animations");
if (rule?.noDim === true)
parts.push("never dimmed");
if (rule?.pin === true)
parts.push("pinned to every workspace");
if (parts.length === 0)
return "No behavior chosen — this rule does nothing";
return parts.join(" · ");
}
function ruleLineFor(rule: var): string {
const verbs = [];
if (rule?.float === true)
verbs.push("float");
if (Array.isArray(rule?.size) && rule.size.length === 2)
verbs.push(`size ${rule.size[0]} ${rule.size[1]}`);
if (rule?.center === true)
verbs.push("center");
if (Number.isFinite(rule?.workspace))
verbs.push("workspace " + rule.workspace);
if (rule?.game === true)
verbs.push("content:game");
if (rule?.noAnim === true)
verbs.push("no_anim");
if (rule?.noDim === true)
verbs.push("no_dim");
if (rule?.pin === true)
verbs.push("pin");
return `match class ${String(rule?.class ?? "")} ${verbs.length === 0 ? "nothing" : verbs.join(", ")}`;
}
// ── The effect, read from the live desktop ──────────────────────────────
function indexOfClass(windowClass: string): int {
const wanted = String(windowClass).trim();
return root.rules.findIndex(rule => String(rule?.class ?? "").trim() === wanted);
}
// Windows open right now that this rule's class names. Hyprland reports a
// Wayland client's app id, which is the class its rules match on.
function matchesOpen(windowClass: string): int {
const wanted = String(windowClass).trim();
if (wanted === "")
return 0;
let count = 0;
for (const toplevel of (Hyprland.toplevels?.values ?? [])) {
if (toplevel?.wayland?.appId === wanted)
count++;
}
return count;
}
// ── Editing ─────────────────────────────────────────────────────────────
// Each write replaces the whole array and then reloads, because that is the
// only way a removed rule stops applying.
function write(next: var, failure: string): bool {
if (!DesktopPreferences.set("windowRules", next)) {
root.lastError = failure;
return false;
}
root.lastError = "";
root.apply();
return true;
}
function addRule(rule: var): bool {
if (!root.validRule(rule)) {
root.lastError = root.isShellClass(rule?.class ?? "")
? "Panama's own surfaces cannot be given window rules."
: "That rule is not one the compositor would accept.";
return false;
}
if (root.indexOfClass(rule.class) >= 0) {
root.lastError = `There is already a rule for ${rule.class}.`;
return false;
}
const next = root.rules.slice();
next.push(rule);
return root.write(next, "That rule could not be saved.");
}
function updateRule(windowClass: string, rule: var): bool {
const at = root.indexOfClass(windowClass);
if (at < 0)
return false;
if (!root.validRule(rule)) {
root.lastError = "That rule is not one the compositor would accept.";
return false;
}
const next = root.rules.slice();
next[at] = rule;
return root.write(next, "That rule could not be saved.");
}
function removeRule(windowClass: string): bool {
const at = root.indexOfClass(windowClass);
if (at < 0)
return false;
const next = root.rules.slice();
next.splice(at, 1);
return root.write(next, "That rule could not be removed.");
}
// ── Applying ────────────────────────────────────────────────────────────
Process {
id: reloadRun
command: ["hyprctl", "reload"]
onExited: (exitCode, exitStatus) => {
root.reloading = false;
if (exitCode !== 0) {
root.lastError = "The compositor did not reload, so the rules above are not in effect yet.";
return;
}
root.lastError = "";
settle.restart();
}
}
Timer {
id: settle
interval: 350
// The rules the reload just read are the ones stored at that moment.
// Recorded after the settle rather than before the reload so a write
// that lands late is not credited to a reload that ran before it.
onTriggered: root.appliedSignature = root.signature
}
Timer {
id: reloadDelay
interval: 120
onTriggered: reloadRun.running = true
}
function apply(): void {
if (reloadRun.running)
return;
root.reloading = true;
// DesktopPreferences coalesces its write on a timer; the reload has to
// land after it or it re-reads the previous file.
reloadDelay.restart();
}
// A rule set that was already in the config when the shell started is in
// effect: the compositor read it at login. Recorded once so an untouched
// list does not read as pending forever.
Component.onCompleted: root.appliedSignature = root.signature
}
@@ -14,7 +14,11 @@ ShellRoot {
count: hits.length, count: hits.length,
top: hits.length > 0 ? hits[0].label : "", top: hits.length > 0 ? hits[0].label : "",
topPage: hits.length > 0 ? hits[0].page : "", topPage: hits.length > 0 ? hits[0].page : "",
labels: hits.slice(0, 6).map(hit => hit.label) // "" for a result that names no tab, which is most of them.
// The sidebar routes those exactly as it always did.
topSection: hits.length > 0 ? String(hits[0].section ?? "") : "",
labels: hits.slice(0, 6).map(hit => hit.label),
pages: hits.slice(0, 6).map(hit => hit.page)
}); });
} }
} }
@@ -5,6 +5,6 @@
# @vicinae.mode silent # @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Accessibility in Settings. # @vicinae.description Open Accessibility in Settings.
# @vicinae.keywords ["settings", "magnifier", "magnifier follows in steps", "high contrast", "dim inactive windows", "dim amount", "flash the screen for notifications", "pointer size", "text size", "magnifier zoom", "zoom in and out", "reduce motion", "visual alerts"] # @vicinae.keywords ["settings", "magnifier", "magnifier follows in steps", "high contrast", "dim inactive windows", "dim amount", "color filter", "flash the screen for notifications", "pointer size", "text size", "magnifier zoom", "zoom in and out", "reduce motion"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page accessibility exec "$HOME/.config/quickshell/scripts/panama-action" settings-page accessibility
+2 -1
View File
@@ -4,7 +4,7 @@
Do not edit this file. Run `quickshell/scripts/panama-settings-docs` Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
after changing the schema; a contract fails when this copy is stale. after changing the schema; a contract fails when this copy is stale.
174 settings across 36 groups. 77 of them are applied to the compositor and confirmed by reading the value back. 175 settings across 36 groups. 77 of them are applied to the compositor and confirmed by reading the value back.
## accessibility ## accessibility
@@ -17,6 +17,7 @@ Found on **Accessibility**.
| **High contrast**<br>`highContrast` | false | Increases contrast in applications that support it. Modern GTK applications read this from the desktop portal and restyle themselves; older ones need a high-contrast theme, which is not installed here. | | **High contrast**<br>`highContrast` | false | Increases contrast in applications that support it. Modern GTK applications read this from the desktop portal and restyle themselves; older ones need a high-contrast theme, which is not installed here. |
| **Dim inactive windows**<br>`dimInactive` `decoration:dim_inactive` | false | Darkens every window except the focused one, so the active window is unmistakable | | **Dim inactive windows**<br>`dimInactive` `decoration:dim_inactive` | false | Darkens every window except the focused one, so the active window is unmistakable |
| **Dim amount**<br>`dimStrength` `decoration:dim_strength` | 0.5 | How much darker unfocused windows are. Range 0.050.9. | | **Dim amount**<br>`dimStrength` `decoration:dim_strength` | 0.5 | How much darker unfocused windows are. Range 0.050.9. |
| **Color filter**<br>`colorFilter` | none | A whole-screen filter rendered by the compositor — grayscale, or a correction for one kind of color blindness. Costs nothing when off. Choices: None, Grayscale, Protanopia, Deuteranopia, Tritanopia. |
| **Flash the screen for notifications**<br>`visualAlerts` | false | A single flash at the edges of every screen when a notification arrives that would ring the bell | | **Flash the screen for notifications**<br>`visualAlerts` | false | A single flash at the edges of every screen when a notification arrives that would ring the bell |
| **Pointer size**<br>`cursorSize` | 24 px | Applies to the compositor and to applications. Range 1664. | | **Pointer size**<br>`cursorSize` | 24 px | Applies to the compositor and to applications. Range 1664. |
| **Text size**<br>`textScale` | 1.0 | Scales interface text everywhere; 1.00 is the design size. Range 0.752.0. | | **Text size**<br>`textScale` | 1.0 | Scales interface text everywhere; 1.00 is the design size. Range 0.752.0. |
@@ -0,0 +1,201 @@
# Tier 2 — the identity block
Approved from mock `tier2-identity.html` (2026-08-25). Custom shortcuts, per-app window rules,
assignable four-finger gestures — all fed by one named-action system — plus network truths
(static IP both stacks, metered, saved networks, hidden SSID), color filters, the DND tile,
tokenized search, the known-hosts cap, and the GNOME umbrella button's removal.
## The one safety property
`settings.json` stays non-executable. No stored value is ever a command. A custom shortcut, a
gesture assignment, and a window rule are all **data**: an enum kind, a validated target id, and
booleans. The Lua resolves data → action at config time through whitelist tables only; an unknown
kind or an invalid target means the bind/gesture/rule is silently not emitted (the `prefs.get`
philosophy: never raise, never guess).
## Named actions (shared vocabulary)
Stored shape, used by `customBinds` and the four gesture keys:
```json
{ "kind": "app" | "shell" | "window", "target": "<id>", "label": "<display text>" }
```
- `app` — target is an application/desktop id matching `^[A-Za-z0-9@._-]{1,128}$`, launched via
the existing launch-or-focus path. The id is an *argument*, never interpolated into a shell string.
- `shell` — target is a key of a fixed Lua table mapping to existing shell IPC verbs
(`dnd-toggle`, `screenshot-area`, `color-picker`, `clipboard`, `overview`, `lock`, … — the
final list is exactly the verbs `shell.qml` already exposes; verify there, don't invent).
- `window` — target is a key of a fixed dispatcher table (`float-toggle`, `fullscreen`, `pin`,
`workspace:1`..`workspace:10`; `workspace:N` validated 110).
Resolution lives in a new `config/dot/hypr/actions.lua` required by both `keybinds.lua` and
`input.lua`. QML renders labels from the stored `label` and re-derives validity with the same
rules (a service-side `describeAction()` in Keybinds.qml is the single QML authority).
## Schema keys (already added by the orchestrator — do not re-add)
| key | type | def | group | internal | notes |
|---|---|---|---|---|---|
| `customBinds` | json | `[]` | input | yes | array of `{chord, kind, target, label}` |
| `windowRules` | json | `[]` | multitasking | yes | array, shape below |
| `gestureFourUp/Down/Left/Right` | json | `({})` | touchpad | yes | `{}` = unassigned, else a named action |
| `colorFilter` | string enum `none/grayscale/protanopia/deuteranopia/tritanopia` | `"none"` | accessibility | no | **no `hypr:` block** — stored value is the enum, not the shader path |
## 1. Custom shortcuts
- `keybinds.lua`: after the shipped binds, a `category("Custom")` loop over
`prefs.get("customBinds", {})`. Each entry: `valid_chord(chord)` must pass, `label` non-empty
(it becomes the bind description — `keybinds-contract` fails builds with description-less
binds), action resolved via `actions.lua` or the entry is skipped. Custom chords must also not
collide with an already-emitted chord (skip + keep the shipped one; QML prevents this upstream).
- `keybindOverrides` stays chord-only and keys by *shipped* chord; custom binds are edited in
place in `customBinds` (rebind rewrites the entry's `chord`), so the two mechanisms never meet
in `shippedChordFor`/`overrideOccupantFor`. Keybinds.qml must exclude custom chords from the
override map's domain.
- Keybinds.qml grows: `customBinds` reader, `addCustomBind(chord, kind, target, label)`,
`rebindCustomBind`, `removeCustomBind` (all conflict-checked via `boundTo`, all ending in
`applyReload()`), `describeAction(entry)`. `groupOrder` gains `Custom` **first**.
- ShortcutsPage: " Add shortcut" in the card header → the two-step flow from the mock
(ShortcutCapture for the chord, then kind picker + target picker; apps from the applications
catalog, shell verbs and window verbs from fixed lists mirroring `actions.lua`). Custom rows
render in the pinned-first "Custom" group with rebind + remove (remove via ConfirmAction,
`actionId: "custom-bind-remove:" + chord`).
- Contracts: `tests/hypr/keybind-categories-contract` — add `Custom` to `known` (keep the
Other-must-be-empty rule; Custom may be non-empty). `tests/quickshell/keybind-rebind-contract`
— keep the overrides pin verbatim; add a customBinds pin: every entry's `kind` ∈ the enum,
`target` matches the safe regex, `chord` < 64 chars, and a static check that `actions.lua`
builds exec strings only from its own whitelist tables (no `entry.target` concatenated into a
command except as a quoted argv element).
## 2. Window rules
Stored shape:
```json
{ "class": "<window class, literal>", "label": "<app display name>",
"float": bool, "center": bool, "size": [w, h] | null, "workspace": int | null,
"noAnim": bool, "game": bool, "noDim": bool, "pin": bool }
```
- `rules.lua`: after the shipped rules, a loop over `prefs.get("windowRules", {})` emitting one
`hl.window_rule` per entry, **class regex-escaped** (literal match). Validation: class
non-empty ≤ 128 chars, size within 50..10000, workspace 1..10; invalid entry skipped whole.
Shipped rules always emit first (user rules are anonymous → evaluated after named ones anyway;
do not name user rules).
- New `services/WindowRules.qml` singleton modeled on `Workspaces.qml`: owns the pref array,
`addRule/updateRule/removeRule`, the 120 ms write-settle + `hyprctl reload` + 350 ms refresh,
and an honest `applied` readback (`hyprctl -j` — use whatever rules listing the running
Hyprland exposes; if none exists, readback = "reload exit status + re-parse of prefs" and the
card says applied-on-reload rather than pretending).
- TilingPage: "App rules" card above the shipped Tiling card, per the mock — list rows (label,
plain-language behavior summary, mono rule line), edit + remove (ConfirmAction,
`actionId: "window-rule-remove:" + class`), add flow: app picker (running toplevels via
`Hyprland.toplevels` first, then the applications catalog) + behavior ticks.
- The shell's own surfaces cannot be matched: refuse classes matching `^(quickshell|qs-)`.
- New contract `tests/hypr/window-rules-contract` cloned from `workspace-rules-contract`'s
stub-lua harness: synthetic `settings.json` with N rules (one invalid, one `qs-` class) →
assert shipped count + N2 emitted, escaping applied, invalid skipped.
## 3. Gestures
- `input.lua`: the three shipped 3-finger gestures stay **verbatim**. After them, for each of
the four `gestureFour*` prefs that resolves to a valid named action, emit
`hl.gesture({ fingers = 4, direction = <up/down/left/right>, action = <resolved> })`.
- MousePage: new "Gestures" card above Touchpad per the mock — three read-only shipped rows,
four OptionPickerRows (options: Nothing + the named-action vocabulary; assigning writes the
pref and triggers `Keybinds.applyReload()` — reuse that seam, do not build a second reload),
then `swipeDistance` + `swipeInvert` moved up from the Touchpad card (schema keys unchanged —
`search-routing-contract` keeps passing because the group's keys stay on the same page).
- `tests/hypr/gestures-contract` rewrite: the three 3-finger pins stay verbatim (including the
no-`overview("toggle")` rule); the `== 3` count becomes "exactly 3 three-finger literals, plus
four-finger emission only inside the customGestures loop"; add a stub-lua run with a synthetic
settings.json asserting 0 four-finger gestures when unassigned and N when assigned, and that
`swipeDistance`/`swipeInvert` remain schema keys.
## 4. Network
New `panama-network` verbs (all: `shape_for` + `FALLBACKS` entries, `connection_state()` reply
shape for per-connection mutations, no absolute binary paths, secrets never in argv):
- `set-ip CONNECTION 4|6 auto` / `set-ip CONNECTION 4|6 manual ADDR/PREFIX GATEWAY DNS[,DNS…]`
`ipv4/ipv6.method`, `.addresses`, `.gateway`, `.dns`, `.ignore-auto-dns yes` on manual;
clears them on auto. New validation regexes (IPv4, IPv6, CIDR prefix ranges) in house style.
Reactivates the connection after the change (`nmcli connection up`) only if it was active.
- `details` additionally reports the *profile's* configured method + addresses/gateway/dns for
both stacks (today it only reads active state) and `metered` (`connection.metered`).
- `set-metered CONNECTION yes|no|auto`.
- `saved``nmcli connection show` listing (name, uuid, type, autoconnect, last-used,
in-range flag by cross-referencing active scan), secrets filtered.
- `join-hidden SSID PROFILE SECURITY` — password on stdin, `wifi.hidden yes`; security ∈
`wpa-psk|sae|none`.
- NetworkTools.qml: plumbing for each verb (same queued-Process pattern), drafts live in the
page.
- ConnectionDetails.qml: metered toggle + the IPv4/IPv6 editors from the mock (Automatic/Manual
choice; manual reveals address/gateway/DNS drafts; nothing applies until Apply; commit only
when the address validates — the proxy-drafts pattern from ConnectivityPage).
- ConnectivityPage: "Saved networks" card (house cap 6 + fold, in-range/connected/autoconnect
detail lines, Forget via ConfirmAction naming the password loss). WifiPanel: "Join a hidden
network…" row → SSID + security + password flow (reuse the join-path components).
- `network-tools-contract`: static half — new regexes exist, `shape_for` knows the new verbs,
AST secret check still passes; dynamic half — stubbed nmcli sees the right argv for set-ip
manual/auto, set-metered, join-hidden (password via stdin only, sentinel never in argv/JSON).
## 5. Color filters
- Ship 4 shaders under `config/dot/hypr/shaders/` (grayscale, protanopia, deuteranopia,
tritanopia — the mock's feColorMatrix values, as Hyprland screen shaders; end-of-pipe
`vec4 → vec4` GLSL). `declared-assets-contract`: they're installed by the existing hypr dir
symlink — verify, and add whatever declaration that contract wants.
- `looks.lua`: `local filter = prefs.get("colorFilter", "none")` → table lookup enum→absolute
shader path → `decoration.screen_shader` (empty string when none/unknown).
- Live apply: `SystemSettings` gains `applyColorFilter(name)` doing the same lookup +
`hyprctl eval decoration:screen_shader <path>` with read-back; the Accessibility page's
ChoiceRow (Seeing card, per mock) writes the pref and calls it. No `hypr:` block on the schema
entry (stored enum ≠ hyprctl's path value, which would break the shape/sweep contracts).
- `hypr-prefs-contract` requires the `prefs.get` default to match the schema default (`"none"`).
## 6. Smaller items
- **DND tile** (QuickSettingsPanel): beside Presentation, bound to `Notifs.doNotDisturb`,
subtitle On/Off; Presentation keeps its combined role. Check `control-center-contract` +
`control-center-services-contract` pins before and after.
- **Search tokenization** (SettingsSearch.qml): the needle splits on whitespace; every token
must match the haystack (AND). Ranking gains "all tokens in label" above the existing rules.
Keep the empty-query → `[]` behavior and all 21 pinned cases in `settings-search-contract`
(they are single-token and must not regress). Add `extraEntries` for every new Tier-2 surface
(custom shortcuts, app rules, gestures, saved networks, hidden network, metered, static IP,
color filter, DND tile) and synonyms for the known holes (`log out`, `wired`, `ethernet`,
`gestures`, `metered`). Results may carry an optional `section`; SettingsSidebar uses
`ShellState.openSettingsSection(page, section)` when present, else `pageRequested` as today.
- **Known hosts cap** (SshKeysPage): house cap 6 + "N more ▾" fold on the hosts Repeater.
- **GNOME umbrella button** (HealthPage): remove the "Fedora system settings" card
(`openGnomePanel("system")`). Wellbeing card stays. Add `[system]=about` to
`gnome-handoff-contract`'s OWNED map so the door stays shut.
## Ownership (disjoint)
- **Agent A — the Lua plane**: `config/dot/hypr/actions.lua` (new), `keybinds.lua`, `input.lua`,
`rules.lua`, `looks.lua`, `shaders/` (new); `tests/hypr/gestures-contract`,
`keybind-categories-contract`, `window-rules-contract` (new), `tests/quickshell/keybind-rebind-contract`.
- **Agent B — input-block services & UI**: `services/Keybinds.qml`, `services/WindowRules.qml`
(new), `services/SystemSettings.qml` (applyColorFilter only), `modules/settings/ShortcutsPage.qml`,
`TilingPage.qml`, `MousePage.qml`, the Accessibility Seeing page (color filter row),
`modules/settings/qmldir` + any new components; `tests/quickshell/keybinds-contract` needles if
needed.
- **Agent C — network & the rest**: `scripts/panama-network`, `services/NetworkTools.qml`,
`services/SettingsSearch.qml`, `modules/settings/ConnectivityPage.qml`, `ConnectionDetails.qml`,
`WifiPanel.qml`, `SettingsSidebar.qml`, `HealthPage.qml`, `SshKeysPage.qml`,
`modules/quicksettings/QuickSettingsPanel.qml`; `tests/quickshell/network-tools-contract`,
`settings-search-contract` (additions only), `gnome-handoff-contract` (OWNED addition),
`ssh-keys-contract` (cap needle if needed).
- **Orchestrator**: `PreferenceSchema.qml` (done first), `SettingsRoutes`/docs/commands regen,
seam audit, `settings-ownership-contract` if mirrors appear.
## Standing rules
Live desktop: every save must leave valid QML; check the journal (errors AND "Unable to
assign" warnings) after each. No live mutations: no real nmcli writes, no hyprctl reload storms
(batch: reload once per verified change-set, announce). Contracts run as you go (grant active);
never `settings-system-contract`. Theme tokens only; no continuously repainting animations;
destructive actions via ConfirmAction; errors via ErrorRow; unmeasured via NotMeasuredRow.
+4
View File
@@ -11,6 +11,10 @@ fprintd
gpu-screen-recorder gpu-screen-recorder
grim grim
grimblast grimblast
# Custom shortcuts launch applications by desktop id through gtk-launch.
# Workstation ships gtk3; declaring it keeps invented shortcuts working
# on an install that started from less.
gtk3
gtk-update-icon-cache gtk-update-icon-cache
helium-browser-bin helium-browser-bin
hypridle hypridle
+204 -10
View File
@@ -1,15 +1,27 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# The touchpad gestures, and the window swallowing beside them. # The touchpad gestures, and the two pieces of looks.lua beside them.
# #
# Both are behaviour that only exists on hardware this machine may not have, so # All of it is behaviour that cannot be observed without hardware this machine
# neither can be checked by running it. What can be pinned is the shape: # may not have, or without a compositor -- a swipe, a window swallowing
# another, a shader over the whole screen. What can be pinned is the shape:
# #
# * Three gestures, mirroring GNOME. Sideways moves workspaces, up opens the # * Three THREE-finger gestures, mirroring GNOME. Sideways moves workspaces,
# overview, down closes it. # up opens the overview, down closes it. These are the desktop's and are
# written as literals, so they can be read here character by character.
# * Up and down do not both toggle. That is the obvious way to write it and it # * Up and down do not both toggle. That is the obvious way to write it and it
# is wrong: swiping up from an open overview would close it, and swiping # is wrong: swiping up from an open overview would close it, and swiping
# down would reopen it, which is the opposite of what the fingers mean. # down would reopen it, which is the opposite of what the fingers mean.
# * FOUR-finger gestures are the user's, from settings.json, and are emitted
# only inside the loop that reads them -- one per direction that resolves to
# a valid named action, and none at all for a direction nobody assigned.
# That last part is not tidiness: a gesture registration is read at config
# time and cannot be removed afterwards, so a no-op gesture per direction
# would consume the four-finger swipes permanently.
# * Every color filter the settings page offers names a shader the repository
# actually ships. `decoration:screen_shader` is a path Hyprland compiles at
# config time, so a missing one is a shader compile failure on a
# whole-screen pass rather than a feature that quietly does nothing.
# * Swallowing is off by default and driven by a preference. Turning it on for # * Swallowing is off by default and driven by a preference. Turning it on for
# everybody would make terminals appear to vanish on a machine nobody asked. # everybody would make terminals appear to vanish on a machine nobody asked.
# * The swallow regex names only terminals this desktop ships. Anything the # * The swallow regex names only terminals this desktop ships. Anything the
@@ -19,17 +31,19 @@
set -uo pipefail set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
input="$repo_dir/config/dot/hypr/input.lua" hypr_dir="$repo_dir/config/dot/hypr"
looks="$repo_dir/config/dot/hypr/looks.lua" input="$hypr_dir/input.lua"
looks="$hypr_dir/looks.lua"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
findings=() findings=()
note() { findings+=("$1"); } note() { findings+=("$1"); }
# ── Gestures ───────────────────────────────────────────────────────────────── # ── The three that are the desktop's ─────────────────────────────────────────
gestures="$(grep -c 'hl\.gesture({' "$input" || true)" literals="$(grep -c 'hl\.gesture({ fingers = 3' "$input" || true)"
(( gestures == 3 )) || note "input.lua registers $gestures gestures, not the 3 that mirror GNOME" (( literals == 3 )) \
|| note "input.lua registers $literals three-finger gestures, not the 3 that mirror GNOME"
grep -qE 'fingers = 3, direction = "horizontal",[[:space:]]*action = "workspace"' "$input" \ grep -qE 'fingers = 3, direction = "horizontal",[[:space:]]*action = "workspace"' "$input" \
|| note 'three fingers sideways does not switch workspaces' || note 'three fingers sideways does not switch workspaces'
@@ -43,6 +57,111 @@ if grep -qE 'direction = "(up|down)",[[:space:]]*action = overview\("toggle"\)'
note 'a vertical gesture toggles the overview, so swiping the same way twice undoes itself' note 'a vertical gesture toggles the overview, so swiping the same way twice undoes itself'
fi fi
# ── The four that are the user's ─────────────────────────────────────────────
#
# Exactly one hl.gesture call beyond the three literals, and it lives inside the
# loop over the preferences. A second call site is a four-finger gesture emitted
# from somewhere the settings file does not control, which is a gesture nobody
# can take back.
calls="$(grep -c 'hl\.gesture({' "$input" || true)"
(( calls == 4 )) \
|| note "input.lua has $calls hl.gesture call sites; expected the 3 literals plus one in the loop"
grep -q 'custom_gestures' "$input" \
|| note 'the four-finger gestures are not read from a table of preferences'
python3 - "$input" <<'PY' || note 'a four-finger gesture is registered outside the loop that reads the preferences'
import re, sys
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
in_loop = False
problems = []
for number, line in enumerate(lines, 1):
if re.match(r"^for .*custom_gestures", line):
in_loop = True
elif re.match(r"^end\b", line):
in_loop = False
if "fingers = 4" in line and not in_loop:
problems.append(f"line {number}: {line.strip()[:70]}")
if problems:
print("\n".join(problems), file=sys.stderr)
raise SystemExit(1)
PY
# ── What the Lua actually emits ──────────────────────────────────────────────
#
# The checks above read the source. This runs it, against a stubbed `hl` and a
# synthetic settings file, so no compositor is touched and no real preference is
# read -- and so the validation in actions.lua is exercised rather than assumed.
if command -v lua >/dev/null 2>&1; then
work="$(mktemp -d /tmp/panama-gestures.XXXXXX)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/config/panama"
# The fingers count of every gesture input.lua registers, one per line.
emit() {
printf '%s' "$1" >"$work/config/panama/settings.json"
XDG_CONFIG_HOME="$work/config" lua -e "
package.path = '$hypr_dir/?.lua;' .. package.path
hl = {
config = function() end,
exec_cmd = function() end,
dispatch = function() end,
gesture = function(spec) print(spec.fingers .. ' ' .. tostring(spec.direction)) end,
}
dofile('$input')
" 2>/dev/null
}
four_fingers() { grep -c '^4 ' <<<"$1" || true; }
three_fingers() { grep -c '^3 ' <<<"$1" || true; }
# Nothing assigned: the three shipped gestures and not one more.
unassigned="$(emit '{}')"
(( "$(three_fingers "$unassigned")" == 3 )) \
|| note 'the three shipped gestures are not registered on a machine with no settings file'
(( "$(four_fingers "$unassigned")" == 0 )) \
|| note 'a four-finger gesture is registered with nothing assigned to it'
empty="$(emit '{"gestureFourUp":{},"gestureFourDown":{},"gestureFourLeft":{},"gestureFourRight":{}}')"
(( "$(four_fingers "$empty")" == 0 )) \
|| note 'an explicitly unassigned direction still registers a gesture'
# Two assigned, one per kind the vocabulary offers.
assigned="$(emit '{"gestureFourUp":{"kind":"shell","target":"overview","label":"Overview"},
"gestureFourLeft":{"kind":"window","target":"float-toggle","label":"Toggle float"}}')"
(( "$(four_fingers "$assigned")" == 2 )) \
|| note "two assigned directions produced $(four_fingers "$assigned") four-finger gestures"
grep -q '^4 up$' <<<"$assigned" || note 'an assigned four-finger up gesture was not registered'
grep -q '^4 left$' <<<"$assigned" || note 'an assigned four-finger left gesture was not registered'
(( "$(three_fingers "$assigned")" == 3 )) \
|| note 'assigning a four-finger gesture changed the three shipped ones'
# A stored value the whitelists do not recognise resolves to nothing. This
# is the property that keeps a hand-editable file from being executable: a
# command in `target` is not a command, it is an unknown key.
hostile="$(emit '{"gestureFourUp":{"kind":"shell","target":"rm -rf /","label":"x"},
"gestureFourDown":{"kind":"exec","target":"sh","label":"x"},
"gestureFourLeft":{"kind":"window","target":"workspace:99","label":"x"},
"gestureFourRight":{"kind":"app","target":"foo; reboot","label":"x"}}')"
(( "$(four_fingers "$hostile")" == 0 )) \
|| note 'an unrecognised or unsafe named action was registered as a gesture anyway'
(( "$(three_fingers "$hostile")" == 3 )) \
|| note 'a malformed gesture preference cost the shipped gestures'
# A malformed file must cost the customizations and nothing else.
for broken in '{"gestureFourUp":"overview"}' '{"gestureFourUp":[1,2]}' '{"gestureFourUp":null}'; do
(( "$(three_fingers "$(emit "$broken")")" == 3 )) \
|| note "a malformed gesture preference took input.lua down: $broken"
done
rm -rf "$work"
trap - EXIT
else
note 'lua is not installed, so what the gestures actually emit went unchecked'
fi
# The tuning that Hyprland does accept at runtime has to be reachable, or the # The tuning that Hyprland does accept at runtime has to be reachable, or the
# gestures are unadjustable without editing this file -- which is the thing # gestures are unadjustable without editing this file -- which is the thing
# Settings exists to avoid. # Settings exists to avoid.
@@ -51,6 +170,81 @@ for key in swipeDistance swipeInvert; do
|| note "$key is not a preference, so the gestures cannot be tuned from Settings" || note "$key is not a preference, so the gestures cannot be tuned from Settings"
done done
# ── Color filters ────────────────────────────────────────────────────────────
#
# looks.lua's other unrunnable half, and it sits here for the same reason
# swallowing does: it is behaviour whose effect cannot be observed without a
# compositor, but whose shape can be read.
#
# `decoration:screen_shader` is a PATH, and Hyprland compiles what it finds
# there at config time. A named shader the repository does not ship is not a
# filter that quietly does nothing -- it is a shader compile failure on a
# whole-screen pass, which is a much worse afternoon than a missing feature.
# So every enum the preference offers must name a file that exists.
filters="$(sed -n '/^local colorFilters = {/,/^}/p' "$looks" | grep -oE '[a-z]+\.frag')"
[[ -n "$filters" ]] || note 'looks.lua has no enum-to-shader table, so the color filter preference reaches nothing'
while IFS= read -r shader; do
[[ -n "$shader" ]] || continue
[[ -f "$repo_dir/config/dot/hypr/shaders/$shader" ]] \
|| note "looks.lua names shaders/$shader, which the repository does not ship"
done <<<"$filters"
# Every option the schema offers is mapped, and nothing is mapped that the
# schema does not offer. An unmapped enum is a filter the settings page lets
# you pick and the compositor never applies.
schema_options="$(sed -n '/key: "colorFilter"/,/^ },/p' "$schema" \
| grep -oE 'value: "[a-z]+"' | sed -E 's/value: "(.*)"/\1/' | grep -v '^none$')"
[[ -n "$schema_options" ]] || note 'the colorFilter schema entry offers no filters'
while IFS= read -r option; do
[[ -n "$option" ]] || continue
grep -q "^$option\.frag$" <<<"$filters" \
|| note "the schema offers the '$option' filter, but looks.lua maps it to no shader"
done <<<"$schema_options"
# "none" must not be in the table: it is the absence of a filter, and mapping it
# to a shader would make turning the feature off cost a full-screen pass.
grep -q '^none\.frag$' <<<"$filters" \
&& note 'looks.lua maps the "none" filter to a shader, so turning the filter off still runs one'
# The stored value is the enum, never the path. Storing the path would put a
# filesystem location into a hand-editable preference and make the stored value
# disagree with what hyprctl reports back.
if grep -q 'key: "colorFilter"' "$schema"; then
# Comments stripped first: the entry explains at length why it has no
# hypr: block, and the explanation contains the words it is denying.
block="$(sed -n '/key: "colorFilter"/,/^ },/p' "$schema" | grep -v '^[[:space:]]*//')"
grep -q 'hypr:' <<<"$block" \
&& note 'colorFilter declares a hypr option, but hyprctl stores a shader path rather than this enum'
else
note 'colorFilter is not in the schema'
fi
# Every shader is an end-of-pipe fragment shader in the form Hyprland's own
# example uses. A shader missing `tex` or its output samples nothing and paints
# nothing, which on a whole-screen pass is a black desktop.
for shader in "$repo_dir"/config/dot/hypr/shaders/*.frag; do
[[ -e "$shader" ]] || continue
name="$(basename "$shader")"
grep -q '^#version 300 es' "$shader" \
|| note "shaders/$name does not declare the GLSL version Hyprland compiles screen shaders as"
grep -q 'uniform sampler2D tex;' "$shader" \
|| note "shaders/$name never samples the screen"
grep -q 'in vec2 v_texcoord;' "$shader" \
|| note "shaders/$name does not take the screen coordinate Hyprland provides"
grep -qE 'out vec4 fragColor;' "$shader" \
|| note "shaders/$name declares no output, so it paints nothing"
grep -q 'fragColor =' "$shader" \
|| note "shaders/$name never writes its output"
# Alpha is carried through rather than assumed opaque: the pass runs over
# the composited frame, and forcing it to 1.0 is how a filter comes to
# paint over things that were meant to be see-through.
grep -q 'pixColor.a' "$shader" \
|| note "shaders/$name discards the alpha channel instead of carrying it through"
done
# ── Swallowing ─────────────────────────────────────────────────────────────── # ── Swallowing ───────────────────────────────────────────────────────────────
grep -qE 'enable_swallow = prefs\.get\("windowSwallow", false\)' "$looks" \ grep -qE 'enable_swallow = prefs\.get\("windowSwallow", false\)' "$looks" \
+18 -1
View File
@@ -45,6 +45,16 @@ grep -q 'write_categories()' "$keybinds" \
grep -q 'if file == nil then' "$keybinds" \ grep -q 'if file == nil then' "$keybinds" \
|| note 'the manifest writer does not tolerate being unable to open the file' || note 'the manifest writer does not tolerate being unable to open the file'
# The user's own shortcuts are a category too, and they are the one group whose
# membership is not written in this file. They are emitted last and outside the
# `bind` wrapper -- deliberately, so keybindOverrides (which is keyed by a
# SHIPPED chord) can never reach one -- which means they would be invisible to
# the manifest unless the loop records the category itself.
grep -q 'category("Custom")' "$keybinds" \
|| note 'custom shortcuts are emitted without a category, so they land in Other'
grep -q 'customBinds' "$keybinds" \
|| note 'keybinds.lua never reads customBinds, so shortcuts the user invents are not bound'
# The shell prefers the authored category and still works without one. # The shell prefers the authored category and still works without one.
grep -q 'categoryManifest' "$service" \ grep -q 'categoryManifest' "$service" \
|| note 'the shell never reads the category manifest' || note 'the shell never reads the category manifest'
@@ -100,7 +110,14 @@ if [[ -r "$manifest" ]]; then
# Every category is one the shell knows how to order. A typo produces a # Every category is one the shell knows how to order. A typo produces a
# group that sorts last and looks like a bug in the cheatsheet. # group that sorts last and looks like a bug in the cheatsheet.
known='Windows Workspaces Applications Shell Session Media & hardware Other' #
# Custom is the one group the user fills: shortcuts they invented, read
# from customBinds at the end of keybinds.lua. It is legitimately empty on
# a machine nobody has customized and legitimately full on one somebody
# has, so unlike Other it carries no count expectation -- only the
# requirement that it be a name the shell orders (Keybinds.qml's groupOrder
# puts it first) rather than an unknown that sorts last.
known='Windows Workspaces Applications Shell Session Media & hardware Custom Other'
while read -r value; do while read -r value; do
[[ -n "$value" ]] || continue [[ -n "$value" ]] || continue
grep -qF "$value" <<<"$known" \ grep -qF "$value" <<<"$known" \
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env bash
# Per-application window rules, written by the user.
#
# This is settings.json describing compositor behaviour, which is the same
# shape of risk custom shortcuts carry and is handled the same way: the stored
# entry is data -- a class, some booleans, two numbers -- and hypr/rules.lua is
# the only thing that turns it into a rule.
#
# Three properties, and every one of them was a real way to lose the desktop:
#
# 1. The class is matched LITERALLY. Hyprland matches with RE2, so a class
# typed into a text field is a regular expression: "org.gnome.Files" would
# also match "orgxgnomexFiles", and a half-typed "(" is a pattern error
# rather than a rule that matches nothing.
# 2. An invalid entry is skipped WHOLE. A rule that half-applies -- the size
# dropped, the float kept -- is harder to understand than one that is not
# there.
# 3. The shell's own surfaces cannot be matched. A user floating or moving
# Quickshell's windows from the Windows page is a person breaking their
# desktop with a supported control.
#
# And one about ordering: user rules are ANONYMOUS. Hyprland evaluates every
# named rule before every anonymous one, so naming a user rule would make it
# lose to the shipped rules it is meant to override.
#
# The Lua is exercised with a stubbed `hl`, so the rules can be counted without
# a compositor and without touching the running desktop.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
hypr_dir="$repo_dir/config/dot/hypr"
rules="$hypr_dir/rules.lua"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
findings=()
note() { findings+=("$1"); }
command -v lua >/dev/null 2>&1 || {
printf 'window rules contract: lua is not installed\n' >&2
exit 1
}
work="$(mktemp -d /tmp/panama-window-rules.XXXXXX)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/config/panama"
# Every window rule rules.lua emits for a given settings file, one per line, as
# "<name or -> <class pattern> <field=value ...>". Layer rules are swallowed:
# this is about window rules, and a layer rule is not one.
emit() {
printf '%s' "$1" >"$work/config/panama/settings.json"
XDG_CONFIG_HOME="$work/config" lua -e "
package.path = '$hypr_dir/?.lua;' .. package.path
hl = {
layer_rule = function() end,
window_rule = function(rule)
local fields = {}
for key, value in pairs(rule) do
if key ~= 'match' and key ~= 'name' then
if type(value) == 'table' then
value = tostring(value[1]) .. 'x' .. tostring(value[2])
end
fields[#fields + 1] = key .. '=' .. tostring(value)
end
end
table.sort(fields)
local class = (rule.match or {}).class
print((rule.name or '-') .. ' ' .. tostring(class) .. ' ' .. table.concat(fields, ' '))
end,
}
dofile('$rules')
" 2>/dev/null
}
count() { grep -c . <<<"$1" || true; }
# ── The shipped baseline ─────────────────────────────────────────────────────
#
# Read rather than pinned as a number. Pinning one means every new shipped rule
# fails this contract for no reason, and the property under test is the
# DIFFERENCE the user's rules make, not how many rules Panama ships.
shipped="$(emit '{"windowRules":[]}')"
shipped_count="$(count "$shipped")"
(( shipped_count > 0 )) || note 'rules.lua emitted no window rules at all, which cannot be right'
absent="$(emit '{}')"
(( "$(count "$absent")" == shipped_count )) \
|| note 'with no windowRules key at all, the shipped rule count changes'
# ── Five rules, two of which must not be emitted ──────────────────────────────
#
# * Calculator -- valid, and the one whose escaping is checked
# * qs-dock -- the shell's own surface, refused
# * Steam -- workspace 42, which does not exist; skipped whole
# * foo(bar -- a class that is not a valid regex, and must not become one
# * mygame -- valid, with every remaining field set
USER_RULES='{"windowRules":[
{"class":"org.gnome.Calculator","label":"Calculator","float":true,"center":true,"size":[400,600]},
{"class":"qs-dock","label":"Dock","float":true},
{"class":"Steam","label":"Steam","workspace":42},
{"class":"foo(bar","label":"Odd","float":true,"pin":true},
{"class":"mygame","label":"Game","game":true,"noAnim":true,"noDim":true,"workspace":9}
]}'
applied="$(emit "$USER_RULES")"
applied_count="$(count "$applied")"
(( applied_count == shipped_count + 3 )) \
|| note "five user rules with two invalid emitted $(( applied_count - shipped_count )) rules, not 3"
user_lines="$(comm -13 <(sort <<<"$shipped") <(sort <<<"$applied"))"
# 1. The class is escaped and anchored, so it matches itself and nothing else.
grep -qF '^org\.gnome\.Calculator$' <<<"$user_lines" \
|| note 'the class is not regex-escaped, so a dotted application id matches classes the user did not name'
grep -qF '^foo\(bar$' <<<"$user_lines" \
|| note 'a class containing a regex metacharacter is not escaped, so it is a pattern rather than a name'
# 2. Invalid entries are gone, and gone whole.
grep -q 'qs-dock' <<<"$user_lines" \
&& note 'a rule matching the shell\047s own surfaces was emitted'
grep -q 'Steam' <<<"$user_lines" \
&& note 'a rule with an out-of-range workspace was emitted'
grep -qE 'Steam.*float' <<<"$user_lines" \
&& note 'an invalid rule was emitted with its bad field dropped rather than skipped whole'
# 3. The valid ones carry what they were given, under Hyprland's own names.
grep -qE '\^org\\\.gnome\\\.Calculator\$.*center=true.*float=true.*size=400x600' <<<"$user_lines" \
|| note 'the float/center/size rule did not survive translation: '"$(grep Calculator <<<"$user_lines")"
grep -qE '\^mygame\$.*content=game.*no_anim=true.*no_dim=true.*workspace=9' <<<"$user_lines" \
|| note 'the game rule did not survive translation: '"$(grep mygame <<<"$user_lines")"
grep -qE '\^foo\\\(bar\$.*pin=true' <<<"$user_lines" \
|| note 'pin did not survive translation'
# 4. Anonymous, and after the shipped rules. Named would outrank every
# anonymous shipped rule; earlier would let a shipped rule win the last-match
# tiebreak against the user's own.
while IFS= read -r line; do
[[ -n "$line" ]] || continue
[[ "${line%% *}" == "-" ]] \
|| note "a user rule was emitted with the name '${line%% *}', which makes it outrank the shipped rules"
done <<<"$user_lines"
first_user="$(grep -n 'mygame' <<<"$applied" | head -1 | cut -d: -f1)"
last_shipped="$(grep -c . <<<"$shipped")"
[[ -n "$first_user" && "$first_user" -gt "$last_shipped" ]] \
|| note 'user rules are not emitted after every shipped rule'
# ── Sizes and workspaces have bounds ─────────────────────────────────────────
#
# Not decoration: a 4-pixel window is unreachable with the pointer, and a
# workspace number outside what the keybinds reach strands a window somewhere
# there is no shortcut to.
for bad in \
'{"class":"tiny","size":[4,4]}' \
'{"class":"huge","size":[99999,99999]}' \
'{"class":"half","size":[400]}' \
'{"class":"zero","workspace":0}' \
'{"class":"","float":true}' \
'{"class":"quickshell","float":true}' \
'{"class":"QS-Dock","float":true}'; do
out="$(emit "{\"windowRules\":[$bad]}")"
(( "$(count "$out")" == shipped_count )) \
|| note "an invalid rule was emitted anyway: $bad"
done
# A class of exactly the cap is fine; one past it is not.
ok_class="$(printf 'a%.0s' $(seq 1 128))"
long_class="$(printf 'a%.0s' $(seq 1 129))"
(( "$(count "$(emit "{\"windowRules\":[{\"class\":\"$ok_class\"}]}")")" == shipped_count + 1 )) \
|| note 'a class of exactly 128 characters is refused, so the cap is off by one'
(( "$(count "$(emit "{\"windowRules\":[{\"class\":\"$long_class\"}]}")")" == shipped_count )) \
|| note 'a class longer than 128 characters is emitted anyway'
# ── Nothing malformed can cost the compositor ────────────────────────────────
#
# The whole point of reading a hand-editable file at config time is that a bad
# read costs the setting and never the desktop. A raise here aborts rules.lua,
# and the desktop comes up with no window rules at all.
for hostile in \
'{"windowRules":"not an array"}' \
'{"windowRules":[null]}' \
'{"windowRules":[42]}' \
'{"windowRules":[{"class":123}]}' \
'{"windowRules":[{"class":"ok","size":"400x600"}]}' \
'{"windowRules":[{"class":"ok","workspace":"3"}]}'; do
out="$(emit "$hostile")"
(( "$(count "$out")" >= shipped_count )) \
|| note "a malformed windowRules value cost the shipped rules: $hostile"
done
# ── The preference cannot pretend to be an option ────────────────────────────
if grep -q 'key: "windowRules"' "$schema"; then
block="$(sed -n '/key: "windowRules"/,/^ },/p' "$schema")"
grep -q 'hypr:' <<<"$block" \
&& note 'windowRules declares a hypr option, but window rules are not settable options'
else
note 'windowRules is not in the schema'
fi
# ── Report ───────────────────────────────────────────────────────────────────
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'window rules contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'window rules contract: PASS (%d shipped rules, user rules validated and escaped)\n' "$shipped_count"
+10
View File
@@ -70,7 +70,17 @@ fail() {
# locales, so a row pointing at it is now a door out of a page that does the # locales, so a row pointing at it is now a door out of a page that does the
# thing. The retired page carried two: that one, and an "Open appearance" # thing. The retired page carried two: that one, and an "Open appearance"
# handoff left over from when the fonts lived here. # handoff left over from when the fonts lived here.
#
# "system" is the umbrella. System Health's Fedora card used to lead with a
# button reading "Open GNOME Settings" that landed on GNOME's System panel as a
# generic front door -- not a handoff for anything in particular, which is
# exactly why nothing here caught it while every specific door was being closed
# one at a time. By the end it pointed at an application whose Users, Sharing,
# Printers, Online Accounts, Privacy, Region, Colour and Network panels all have
# Panama pages. It maps to About, which is the page that answers "what is this
# machine" -- the last honest reason anyone opened that panel.
declare -A OWNED=( declare -A OWNED=(
[system]=about
[network]=connectivity [network]=connectivity
[wifi]=connectivity [wifi]=connectivity
[printers]=printers [printers]=printers
+18 -8
View File
@@ -78,14 +78,19 @@ rg -Fq 'implicitHeight: 62' "$settings_dir/HealthCheckRow.qml" \
|| fail 'health rows are below the approved 62px target' || fail 'health rows are below the approved 62px target'
rg -Fq 'Health.refresh()' "$settings_dir/HealthPage.qml" \ rg -Fq 'Health.refresh()' "$settings_dir/HealthPage.qml" \
|| fail 'opening System Health does not request a fresh scan' || fail 'opening System Health does not request a fresh scan'
# "Open GNOME Settings" opens the application, not a panel -- but # The umbrella button is gone, and this is the assertion that keeps it gone.
# gnome-control-center will not start without naming one, so it names the #
# landing page. It used to name "network", which stopped being honest when # It read "Open GNOME Settings" and landed on the System panel -- not a handoff
# Connections absorbed VPN, hotspot, proxy and per-connection details: Panama # for anything in particular, which is exactly why nothing caught it while every
# owns that panel now, and gnome-handoff-contract fails any page pointing at an # specific door beside it was being closed one at a time. It first named
# owned one. "system" is a panel Panama does not have. # "network", then "system" when Connections absorbed VPN, hotspot, proxy and
# per-connection details. By the end it pointed at an application whose Users,
# Sharing, Printers, Online Accounts, Privacy, Region, Colour and Network panels
# all have Panama pages: a generic front door to a settings app you no longer
# need is a habit rather than a boundary. gnome-handoff-contract holds the same
# door shut from the other side, by naming `system` in its OWNED map.
rg -Fq 'SystemSettings.openGnomePanel("system")' "$settings_dir/HealthPage.qml" \ rg -Fq 'SystemSettings.openGnomePanel("system")' "$settings_dir/HealthPage.qml" \
|| fail 'Fedora ownership boundary does not open GNOME Settings' && fail 'the "Open GNOME Settings" umbrella button is back, on a page whose panels Panama owns'
rg -Fq 'SystemSettings.openGnomePanel("network")' "$settings_dir/HealthPage.qml" \ rg -Fq 'SystemSettings.openGnomePanel("network")' "$settings_dir/HealthPage.qml" \
&& fail 'System Health lands GNOME Settings on its network panel, which Panama now owns' && fail 'System Health lands GNOME Settings on its network panel, which Panama now owns'
# Users and Sharing are Panama pages now. A handoff here would send someone to # Users and Sharing are Panama pages now. A handoff here would send someone to
@@ -138,8 +143,13 @@ rg -Fq 'Health.saveReport(' "$settings_dir/HealthPage.qml" \
# Exact authored handoffs are asserted above. Also prove every panel named by # Exact authored handoffs are asserted above. Also prove every panel named by
# this boundary is accepted by SystemSettings, so a typo cannot ship a dead # this boundary is accepted by SystemSettings, so a typo cannot ship a dead
# button even if its copy still looks correct. # button even if its copy still looks correct.
# The card that used to be headed "Fedora system settings" is now headed by the
# one thing left inside it. Screen time is a real boundary: GNOME's wellbeing
# panel does something Panama does not, and that button genuinely works.
rg -Fq 'title: "Digital wellbeing"' "$settings_dir/HealthPage.qml" \
|| fail 'the wellbeing handoff card is gone'
rg -Fq 'title: "Fedora system settings"' "$settings_dir/HealthPage.qml" \ rg -Fq 'title: "Fedora system settings"' "$settings_dir/HealthPage.qml" \
|| fail 'the Fedora ownership boundary card is gone' && fail 'the card is headed "Fedora system settings" again, for a single wellbeing button'
allowed="$(rg -o '"[a-z-]+"' "$repo_dir/config/dot/quickshell/services/SystemSettings.qml" \ allowed="$(rg -o '"[a-z-]+"' "$repo_dir/config/dot/quickshell/services/SystemSettings.qml" \
| sed -n '/"\(applications\|background\|bluetooth\|color\|display\|keyboard\|mouse\|multitasking\|network\|notifications\|online-accounts\|power\|printers\|privacy\|search\|sharing\|sound\|system\|universal-access\|wacom\|wellbeing\|wifi\|wwan\)"/p' \ | sed -n '/"\(applications\|background\|bluetooth\|color\|display\|keyboard\|mouse\|multitasking\|network\|notifications\|online-accounts\|power\|printers\|privacy\|search\|sharing\|sound\|system\|universal-access\|wacom\|wellbeing\|wifi\|wwan\)"/p' \
+196
View File
@@ -87,6 +87,202 @@ rg -Fq 'Restore every shipped shortcut' "$page" \
rg -Fq 'Object.keys(Keybinds.overrides).length' "$page" \ rg -Fq 'Object.keys(Keybinds.overrides).length' "$page" \
|| fail 'the restore-all row no longer counts what it would put back' || fail 'the restore-all row no longer counts what it would put back'
# ── Shortcuts the user invented ──────────────────────────────────────────────
#
# Overrides move a shipped bind and can only ever carry a chord, which is what
# the checks above are about. Custom shortcuts are the harder case: the stored
# entry has to describe an ACTION, and the file it is stored in is one the user
# can open in a text editor.
#
# It stays non-executable, and hypr/actions.lua is the whole reason. A stored
# entry is { chord, kind, target, label }: `kind` is an enum with three
# members, and `target` either names a key of a whitelist table whose values
# are command strings written in Lua by a human, or -- for an application -- is
# an identifier restricted to a character class containing no shell
# metacharacter, quoted as a single argv element for the launch-or-focus path.
#
# There is no third path. Nothing stored anywhere contributes a character to a
# command string. These pin that, because it is the property that makes the
# whole feature safe rather than a config-file injection with a settings page.
actions="$repo_dir/config/dot/hypr/actions.lua"
keybinds="$repo_dir/config/dot/hypr/keybinds.lua"
input_lua="$repo_dir/config/dot/hypr/input.lua"
[[ -r "$actions" ]] || fail "cannot read $actions -- the named-action resolver is gone"
# One resolver, required by both the things that resolve names.
grep -Fq 'require("actions")' "$keybinds" \
|| fail 'keybinds.lua does not use the named-action resolver'
grep -Fq 'require("actions")' "$input_lua" \
|| fail 'input.lua does not use the named-action resolver, so gestures resolve names some other way'
# The target character class. Written as a Lua pattern, so `-` is escaped as
# `%-`; the length bound is a separate check because Lua patterns have no {n,m}.
grep -Fq '"^[A-Za-z0-9@._%-]+$"' "$actions" \
|| fail 'the application target pattern is not the safe character class'
grep -Fq '#target <= 128' "$actions" \
|| fail 'the application target has no length bound'
# Every exec string in actions.lua comes from a table literal in actions.lua.
# The one place a stored value reaches a command is the launch-or-focus path,
# and there it is shell_quote()d -- an argument, not a fragment of a command.
python3 - "$actions" <<'PY' || fail 'actions.lua builds a command out of something other than its own whitelist tables'
import re, sys
source = open(sys.argv[1], encoding="utf-8").read()
# Whole-line comments only. A `--` anywhere else in a Lua line may well be
# inside a string -- "--class" is an argument this very file passes -- and
# treating it as a comment blinds the scan to the rest of the line.
lines = ["" if l.lstrip().startswith("--") else l for l in source.splitlines()]
problems = []
# Anything that puts `target` into a string being concatenated. Exactly two
# forms are permitted, both of which make it one quoted argv element rather
# than a fragment of a command; they are matched literally, so any third way of
# reaching a command string is a finding rather than a regex to be outwitted.
PERMITTED = (
'shell_quote("^" .. escape_regex(target) .. "$")',
"shell_quote(target)",
)
for number, line in enumerate(lines, 1):
if "target" not in line:
continue
stripped = line
for permitted in PERMITTED:
stripped = stripped.replace(permitted, "")
if re.search(r"\.\.\s*[A-Za-z_.]*target|target\s*\.\.", stripped):
problems.append(f"line {number}: {line.strip()[:80]}")
# The command strings themselves are literals in the whitelist table.
for match in re.finditer(r"(?<![\w.])command\s*=\s*([^,\n]+)", source):
value = match.group(1).strip()
if not value.startswith('"'):
problems.append(f"a whitelist command is not a literal: {value[:60]}")
# exec_cmd is only ever handed a whitelist command or the launcher command
# assembled from literals above it.
for number, line in enumerate(lines, 1):
m = re.search(r"exec_cmd\(([^)]*)\)", line)
if m and m.group(1).strip() not in ("verb.command", "launch_command"):
problems.append(f"line {number}: exec_cmd takes {m.group(1).strip()[:60]}")
if problems:
print("\n".join(problems), file=sys.stderr)
raise SystemExit(1)
PY
# ── What the Lua actually emits ──────────────────────────────────────────────
#
# The static read above says the code is shaped right. This runs it, with a
# stubbed `hl` and a synthetic settings file, so the validation is exercised
# rather than trusted -- no compositor, no real preferences.
if command -v lua >/dev/null 2>&1; then
lua_work="$(mktemp -d /tmp/panama-custom-binds.XXXXXX)"
mkdir -p "$lua_work/config/panama" "$lua_work/state"
# Every bind keybinds.lua emits, as "<chord>\t<description>\t<action>".
emit_binds() {
printf '%s' "$1" >"$lua_work/config/panama/settings.json"
XDG_CONFIG_HOME="$lua_work/config" XDG_STATE_HOME="$lua_work/state" lua -e "
package.path = '$repo_dir/config/dot/hypr/?.lua;' .. package.path
hl = {
config = function() end,
dispatch = function() end,
bind = function(chord, action, opts)
print(chord .. '\t' .. tostring((opts or {}).description) .. '\t' .. tostring(action))
end,
dsp = setmetatable({}, { __index = function(_, name)
local function node(path)
return setmetatable({}, {
__index = function(_, key) return node(path .. '.' .. key) end,
__call = function(_, argument)
if type(argument) == 'string' then
return path .. '(' .. argument .. ')'
end
return path .. '()'
end,
})
end
return node(name)
end }),
}
dofile('$keybinds')
" 2>/dev/null
}
baseline="$(emit_binds '{}' | wc -l)"
(( baseline > 100 )) || fail "the shipped keymap emitted $baseline binds, which cannot be right"
# Two good entries, and seven ways of being wrong: an unknown shell verb, a
# command in the target, a command in an app id, a workspace outside 1..10,
# a kind nobody defined, an empty label, and a chord already taken by a
# shipped bind.
shipped_chord="$(emit_binds '{}' | cut -f1 | grep -Fx 'SUPER + T')"
[[ -n "$shipped_chord" ]] || fail 'could not find a shipped chord to collide with'
custom="$(emit_binds '{"customBinds":[
{"chord":"SUPER + SHIFT + F1","kind":"shell","target":"dnd-toggle","label":"Do Not Disturb"},
{"chord":"SUPER + SHIFT + F2","kind":"app","target":"org.gnome.Nautilus","label":"Files"},
{"chord":"SUPER + SHIFT + F3","kind":"window","target":"workspace:4","label":"Workspace 4"},
{"chord":"SUPER + SHIFT + F4","kind":"shell","target":"reboot","label":"Unknown verb"},
{"chord":"SUPER + SHIFT + F5","kind":"shell","target":"dnd-toggle; reboot","label":"Command"},
{"chord":"SUPER + SHIFT + F6","kind":"app","target":"foo $(reboot)","label":"Command in an id"},
{"chord":"SUPER + SHIFT + F7","kind":"window","target":"workspace:0","label":"No such workspace"},
{"chord":"SUPER + SHIFT + F8","kind":"exec","target":"reboot","label":"Invented kind"},
{"chord":"SUPER + SHIFT + F9","kind":"shell","target":"overview","label":""},
{"chord":"SUPER + T","kind":"shell","target":"lock","label":"Steals the terminal key"}
]}')"
added=$(( $(wc -l <<<"$custom") - baseline ))
(( added == 3 )) || fail "ten custom binds with seven invalid added $added binds, not 3"
for chord in 'SUPER + SHIFT + F1' 'SUPER + SHIFT + F2' 'SUPER + SHIFT + F3'; do
grep -Fq "$chord" <<<"$custom" || fail "the valid custom bind $chord was not emitted"
done
# The shipped key kept its action. A custom bind that collides loses; the
# alternative is two binds on one chord and whichever Hyprland reads last.
terminal_line="$(grep -F "$shipped_chord"$'\t' <<<"$custom" | head -1)"
grep -Fq 'Terminal' <<<"$terminal_line" \
|| fail "a custom bind took over a shipped chord: $terminal_line"
# Every custom bind carries the label as its description, because a bind
# with no description is invisible to the cheatsheet and to the page that
# would let you change it.
while IFS=$'\t' read -r chord description _; do
[[ -n "$description" && "$description" != "nil" ]] \
|| fail "the bind $chord has no description"
done <<<"$custom"
# And the actions are only ever whitelist commands or a quoted launch.
while IFS=$'\t' read -r _ _ action; do
case "$action" in
*reboot*) fail "a stored target reached a command: $action" ;;
esac
done <<<"$custom"
grep -Fq "panama-launch --class '^org\\.gnome\\.Nautilus\$' -- gtk-launch 'org.gnome.Nautilus'" <<<"$custom" \
|| fail "the app target is not passed as a quoted argument to the launch-or-focus path: $(grep -F 'SUPER + SHIFT + F2' <<<"$custom")"
# Chords have a bound, and it is the same one overrides have. A 4 KB
# "chord" is not a chord, it is a way to make hyprctl binds unreadable.
long_chord="$(printf 'A%.0s' $(seq 1 65))"
over="$(emit_binds "{\"customBinds\":[{\"chord\":\"$long_chord\",\"kind\":\"shell\",\"target\":\"lock\",\"label\":\"Long\"}]}" | wc -l)"
(( over == baseline )) || fail 'a chord longer than 64 characters was bound anyway'
# A malformed file costs the customizations and never the keymap.
for broken in '{"customBinds":"nope"}' '{"customBinds":[null]}' '{"customBinds":[{"chord":42}]}'; do
(( "$(emit_binds "$broken" | wc -l)" == baseline )) \
|| fail "a malformed customBinds value changed the shipped keymap: $broken"
done
rm -rf "$lua_work"
else
fail 'lua is not installed, so what a custom shortcut becomes went unchecked'
fi
if [[ "${PANAMA_KEYBINDS_STATIC_ONLY:-0}" == "1" ]]; then if [[ "${PANAMA_KEYBINDS_STATIC_ONLY:-0}" == "1" ]]; then
printf 'keybind rebind contract: PASS (static)\n' printf 'keybind rebind contract: PASS (static)\n'
exit 0 exit 0
+249 -2
View File
@@ -71,6 +71,7 @@ readonly WIFI_PSK='psk-must-never-leave-9c1f'
readonly VPN_SECRET='vpn-secret-must-never-leave-7b20' readonly VPN_SECRET='vpn-secret-must-never-leave-7b20'
readonly ENTERPRISE_PW='enterprise-pw-must-never-leave-4e88' readonly ENTERPRISE_PW='enterprise-pw-must-never-leave-4e88'
readonly HOTSPOT_PW='hotspot-pw-must-never-leave-3a55' readonly HOTSPOT_PW='hotspot-pw-must-never-leave-3a55'
readonly HIDDEN_PW='hidden-pw-must-never-leave-8d13'
# ── Static: the helper cannot walk past the stubs ──────────────────────────── # ── Static: the helper cannot walk past the stubs ────────────────────────────
# #
@@ -134,6 +135,58 @@ PY
grep -q -- '--show-secrets' "$helper" \ grep -q -- '--show-secrets' "$helper" \
&& fail 'the helper asks NetworkManager to print secrets; the details view has no use for them' && fail 'the helper asks NetworkManager to print secrets; the details view has no use for them'
# ── Static: the address validators exist, and every verb has a shape ─────────
#
# A static address that NetworkManager refuses is a connection that comes up
# with no address at all, which is a worse failure than being told to retype it
# -- so the refusal has to happen here, before nmcli is called. Named rather
# than only exercised, because the dynamic half below can only ever prove that
# SOME check ran, not that the right family's check did.
for pattern in IPV4 IPV6 IPV4_PREFIX IPV6_PREFIX; do
grep -qE "^${pattern} = re\.compile" "$helper" \
|| fail "no $pattern pattern, so a static address is whatever NetworkManager will take"
done
# Every verb has to be in shape_for AND in FALLBACKS, or a refusal comes back
# in a shape the page cannot read -- the reason a page never has to branch on
# whether the reply is an error.
python3 - "$helper" <<'PY' || fail 'a verb has no reply shape, or a shape has no fallback'
import ast
import sys
source = open(sys.argv[1], encoding="utf-8").read()
tree = ast.parse(source)
shapes = {}
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == "shape_for":
for inner in ast.walk(node):
if isinstance(inner, ast.Dict):
shapes = {key.value: value.value for key, value in
zip(inner.keys, inner.values)
if isinstance(key, ast.Constant) and isinstance(value, ast.Constant)}
fallbacks = set()
for node in tree.body:
target = getattr(node.targets[0], "id", "") if isinstance(node, ast.Assign) else ""
if target == "FALLBACKS" and isinstance(node.value, ast.Dict):
fallbacks = {key.value for key in node.value.keys if isinstance(key, ast.Constant)}
required = {"details", "forget", "saved", "set-autoconnect", "set-mac-random",
"set-metered", "set-ip", "join-enterprise", "join-hidden",
"import-vpn", "hotspot", "proxy", "airplane"}
missing = sorted(required - set(shapes))
if missing:
print(f"shape_for does not know: {missing}", file=sys.stderr)
raise SystemExit(1)
orphans = sorted(set(shapes.values()) - fallbacks)
if orphans:
print(f"shapes with no FALLBACKS entry: {orphans}", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ── Static: the enterprise password is read, not passed ────────────────────── # ── Static: the enterprise password is read, not passed ──────────────────────
# #
# The mechanism is a choice (libnm's GObject bindings when they are installed, a # The mechanism is a choice (libnm's GObject bindings when they are installed, a
@@ -222,6 +275,13 @@ command -v jq >/dev/null 2>&1 || { printf 'network tools contract: SKIP (no jq)\
command -v python3 >/dev/null 2>&1 || { printf 'network tools contract: SKIP (no python3)\n'; exit 0; } command -v python3 >/dev/null 2>&1 || { printf 'network tools contract: SKIP (no python3)\n'; exit 0; }
# ── The fake machine ───────────────────────────────────────────────────────── # ── The fake machine ─────────────────────────────────────────────────────────
#
# The field list the saved-profile stub answers to, read from the helper rather
# than retyped: a stub that answered a list the helper no longer asks for would
# quietly stop being consulted, and the in-range assertions below would pass on
# an empty table.
SAVED_FIELDS="$(sed -nE 's/^SAVED_FIELDS = "([^"]+)"$/\1/p' "$helper")"
[[ -n "$SAVED_FIELDS" ]] || fail 'the helper names no field list for the saved profiles'
work="$(mktemp -d /tmp/panama-network-contract.XXXXXX)" work="$(mktemp -d /tmp/panama-network-contract.XXXXXX)"
stub_dir="$work/bin" stub_dir="$work/bin"
@@ -280,12 +340,37 @@ detail() {
printf 'IP4.GATEWAY:192.168.7.1\n' printf 'IP4.GATEWAY:192.168.7.1\n'
printf 'IP4.DNS[1]:192.168.7.1\n' printf 'IP4.DNS[1]:192.168.7.1\n'
printf 'IP4.DNS[2]:1.1.1.1\n' printf 'IP4.DNS[2]:1.1.1.1\n'
# What the PROFILE asks for, as distinct from what is on the wire above.
# The editor reads these; a helper that reported only the active state
# would show an empty form over a static address.
printf 'connection.metered:unknown\n'
printf 'ipv4.method:auto\n'
printf 'ipv4.addresses:--\n'
printf 'ipv4.gateway:--\n'
printf 'ipv4.dns:--\n'
printf 'ipv6.method:auto\n'
printf 'ipv6.addresses:--\n'
printf 'ipv6.gateway:--\n'
printf 'ipv6.dns:--\n'
printf '802-11-wireless-security.psk:$WIFI_PSK\n' printf '802-11-wireless-security.psk:$WIFI_PSK\n'
printf '802-1x.password:$ENTERPRISE_PW\n' printf '802-1x.password:$ENTERPRISE_PW\n'
printf 'vpn.secrets.password:$VPN_SECRET\n' printf 'vpn.secrets.password:$VPN_SECRET\n'
} }
case "\$joined" in case "\$joined" in
*"-f $SAVED_FIELDS"*)
# The saved-profile table: NAME last, so a network called "Cafe: Guest"
# survives the field separator.
printf '22222222-0000-0000-0000-000000000002:802-11-wireless:yes:yes:1700000000:Home Wi-Fi\n'
printf '44444444-0000-0000-0000-000000000004:802-11-wireless:yes:no:1600000000:Office-Corp\n'
printf '55555555-0000-0000-0000-000000000005:802-11-wireless:no:no:1500000000:Cafe: Guest\n'
printf '66666666-0000-0000-0000-000000000006:802-3-ethernet:yes:no:1400000000:Wired connection 1\n'
exit 0 ;;
*"-f SSID device wifi list"*)
# Only one of the saved networks is anywhere near this machine, which is
# the whole point of the in-range flag.
printf 'Home Wi-Fi\nCoffeeHaus_Guest\n'
exit 0 ;;
*"connection edit"*) *"connection edit"*)
# The only invocation that is fed anything, and it is drained under a # The only invocation that is fed anything, and it is drained under a
# timeout: every other one inherits whatever stdin the test runner had, # timeout: every other one inherits whatever stdin the test runner had,
@@ -446,6 +531,168 @@ runh set-mac-random 'Home Wi-Fi' false >/dev/null 2>&1
grep -Eq 'cloned-mac-address +permanent' "$state_dir/argv" \ grep -Eq 'cloned-mac-address +permanent' "$state_dir/argv" \
|| fail "turning randomization off did not restore the permanent address: $(log)" || fail "turning randomization off did not restore the permanent address: $(log)"
# ── details reports the profile, not only the wire ───────────────────────────
#
# The editor writes the profile's addressing, so it has to be able to read it.
# The four fields at the top of `details` are what the connection currently
# holds, which is a different question: a static address that has not been
# applied yet is in the profile and not on the wire, and an editor bound to the
# wire would show an empty form over a setting somebody just typed.
: >"$state_dir/argv"
profile="$(runh details 'Home Wi-Fi' 2>/dev/null)"
jq -e 'has("metered") and has("ip4Method") and has("ip4Addresses")
and has("ip4Gateway") and has("ip4Dns") and has("ip6Method")' \
<<<"$profile" >/dev/null || fail "details does not report the profile's own addressing: $profile"
jq -e '.metered == "auto"' <<<"$profile" >/dev/null \
|| fail "NetworkManager's 'unknown' metered flag is not reported as automatic: $profile"
# "--" is nmcli for "unset", and a page that rendered it would show two dashes
# in the gateway box.
jq -e '.ip4Gateway == "" and (.ip4Dns | length) == 0' <<<"$profile" >/dev/null \
|| fail "an unset profile property came through as nmcli's placeholder: $profile"
# ── set-metered is three states, not two ─────────────────────────────────────
: >"$state_dir/argv"
runh set-metered 'Home Wi-Fi' yes >/dev/null 2>&1
grep -Eq 'connection\.metered +yes' "$state_dir/argv" \
|| fail "marking a connection metered did not reach nmcli: $(log)"
: >"$state_dir/argv"
runh set-metered 'Home Wi-Fi' auto >/dev/null 2>&1
# "automatic" is NetworkManager deciding, which it spells "unknown". Sending it
# "auto" would be refused, and sending it "no" would report a guess as a fact.
grep -Eq 'connection\.metered +unknown' "$state_dir/argv" \
|| fail "leaving metered to NetworkManager did not send its own spelling: $(log)"
: >"$state_dir/argv"
[[ -n "$(error_of set-metered 'Home Wi-Fi' sometimes)" ]] \
|| fail 'an unknown metered state was accepted'
[[ ! -s "$state_dir/argv" ]] || fail 'an unknown metered state still reached nmcli'
# ── set-ip writes a whole stack, and validates before it does ────────────────
: >"$state_dir/argv"
runh set-ip 'Home Wi-Fi' 4 manual 192.168.7.50/24 192.168.7.1 '1.1.1.1,9.9.9.9' >/dev/null 2>&1
for setting in 'ipv4\.method +manual' 'ipv4\.addresses +192\.168\.7\.50/24' \
'ipv4\.gateway +192\.168\.7\.1' 'ipv4\.dns +1\.1\.1\.1,9\.9\.9\.9'; do
grep -Eq "$setting" "$state_dir/argv" \
|| fail "a manual IPv4 address did not set $setting: $(log)"
done
# Without this NetworkManager appends DHCP's nameservers to the ones just
# typed, so "manual DNS" silently becomes "manual DNS and whatever else".
grep -Eq 'ipv4\.ignore-auto-dns +yes' "$state_dir/argv" \
|| fail "manual DNS does not ignore the ones DHCP hands out: $(log)"
# It was active in the listing, so it has to come back up or the change is
# saved and invisible.
grep -Eq 'connection up Home Wi-Fi' "$state_dir/argv" \
|| fail "an active connection was not reactivated after its address changed: $(log)"
: >"$state_dir/argv"
runh set-ip 'Home Wi-Fi' 6 manual 'fd00::42/64' 'fd00::1' 'fd00::1' >/dev/null 2>&1
grep -Eq 'ipv6\.method +manual' "$state_dir/argv" \
|| fail "a manual IPv6 address did not set the IPv6 method: $(log)"
grep -Fq 'fd00::42/64' "$state_dir/argv" \
|| fail "the IPv6 address never reached nmcli: $(log)"
# Going back to automatic has to CLEAR what manual left behind, or the
# connection comes up holding both.
: >"$state_dir/argv"
runh set-ip 'Home Wi-Fi' 4 auto >/dev/null 2>&1
grep -Eq 'ipv4\.method +auto' "$state_dir/argv" \
|| fail "returning to DHCP did not set the method: $(log)"
grep -Eq 'ipv4\.addresses' "$state_dir/argv" \
|| fail "returning to DHCP left the static address in place: $(log)"
grep -Eq 'ipv4\.ignore-auto-dns +no' "$state_dir/argv" \
|| fail "returning to DHCP kept ignoring the nameservers it hands out: $(log)"
# Each of these must be refused BEFORE nmcli, for the reason at the top of the
# name-validation block: nmcli refuses them too, so a check that only asks "did
# something error" passes with the validation deleted.
while IFS='|' read -r family address gateway dns why; do
: >"$state_dir/argv"
[[ -n "$(error_of set-ip 'Home Wi-Fi' "$family" manual "$address" "$gateway" "$dns")" ]] \
|| fail "$why was accepted"
[[ ! -s "$state_dir/argv" ]] || fail "$why reached nmcli before being refused"
done <<'BAD'
4|192.168.7.50|192.168.7.1|1.1.1.1|an address with no prefix
4|999.1.1.1/24|192.168.7.1|1.1.1.1|an address whose octets are not octets
4|192.168.7.50/33|192.168.7.1|1.1.1.1|an IPv4 prefix past 32
6|fd00::42/129|fd00::1|fd00::1|an IPv6 prefix past 128
4|192.168.7.50/24|not-a-gateway|1.1.1.1|a gateway that is not an address
4|192.168.7.50/24|192.168.7.1|nameserver|a nameserver that is not an address
6|192.168.7.50/24|fd00::1|fd00::1|an IPv4 address typed into the IPv6 stack
BAD
: >"$state_dir/argv"
[[ -n "$(error_of set-ip 'Home Wi-Fi' 5 auto)" ]] || fail 'a third IP family was accepted'
[[ -n "$(error_of set-ip 'Home Wi-Fi' 4 sideways)" ]] \
|| fail 'an addressing mode that is neither automatic nor manual was accepted'
# ── saved: the profiles this machine holds, in range or not ─────────────────
: >"$state_dir/argv"
saved="$(runh saved 2>/dev/null)"
jq -e '(.connections | length) == 4' <<<"$saved" >/dev/null \
|| fail "the saved listing did not parse four profiles: $saved"
# NAME is read last precisely so this one survives: nothing else in the row can
# contain a colon.
jq -e '[.connections[].name] | index("Cafe: Guest") != null' <<<"$saved" >/dev/null \
|| fail "a network name containing a colon was cut in half: $saved"
jq -e '.connections[] | select(.name == "Home Wi-Fi")
| .active == true and .autoconnect == true and .inRange == true' <<<"$saved" >/dev/null \
|| fail "the connected profile is not reported as connected and in range: $saved"
# The whole reason for the scan cross-reference: a saved network you are
# nowhere near is otherwise invisible until you stand next to it.
jq -e '.connections[] | select(.name == "Office-Corp") | .inRange == false' <<<"$saved" >/dev/null \
|| fail "a saved network that the scan did not see is not reported as out of range: $saved"
# A wired profile is not somewhere else; it is a cable. Saying "out of range"
# about one would be inventing a fact.
jq -e '.connections[] | select(.name == "Wired connection 1") | .inRange == null' <<<"$saved" >/dev/null \
|| fail "a wired profile was given an in-range answer, which it cannot have: $saved"
grep -Fq -- '--rescan no' "$state_dir/argv" \
|| fail "listing saved profiles made the radio go looking: $(log)"
offenders="$(jq -r '[paths | map(tostring) | join(".")]
| map(select(test("(password|secret|psk|passphrase)$";"i"))) | join(", ")' <<<"$saved")"
[[ -z "$offenders" ]] || fail "the saved listing carries credential-shaped fields: $offenders"
# ── join-hidden: the same stdin rule as the enterprise join ─────────────────
: >"$state_dir/argv"
: >"$state_dir/stdin"
hidden_out="$(printf '%s\n' "$HIDDEN_PW" \
| runh join-hidden 'office-private' 'office-private' wpa-psk 2>"$work/hidden.err")"
grep -Fq "$HIDDEN_PW" "$state_dir/argv" \
&& fail 'the hidden network passphrase was passed as a command argument'
grep -Fq "$HIDDEN_PW" <<<"$hidden_out" \
&& fail 'the hidden network passphrase is echoed back in the helper output'
grep -Fq "$HIDDEN_PW" "$work/hidden.err" \
&& fail 'the hidden network passphrase was written to stderr'
leaked="$(leak_in_scratch "$HIDDEN_PW")"
[[ -z "$leaked" ]] || fail "the hidden network passphrase was written to $leaked"
grep -Fq "$HIDDEN_PW" "$state_dir/stdin" \
|| fail 'the hidden network passphrase never reached nmcli at all, on stdin or otherwise'
# Without this the profile saves and never connects: NetworkManager only probes
# for a network by name when it is told the name is not broadcast.
grep -Fq '802-11-wireless.hidden yes' "$state_dir/stdin" \
|| fail 'the profile is not marked hidden, so NetworkManager will never look for it'
grep -Fq 'connection up office-private' "$state_dir/argv" \
|| fail "join-hidden saved a profile and never brought it up: $(log)"
# An open hidden network is a real thing, and it has no passphrase to wait for.
: >"$state_dir/argv"
: >"$state_dir/stdin"
runh join-hidden 'open-hidden' 'open-hidden' none </dev/null >/dev/null 2>&1
grep -Fq 'connection edit' "$state_dir/argv" \
|| fail "an open hidden network was not created: $(log)"
grep -Fq 'wireless-security' "$state_dir/stdin" \
&& fail 'an open network was given a key-management setting'
: >"$state_dir/argv"
[[ -n "$(runh join-hidden 'office-private' 'office-private' wep </dev/null 2>/dev/null \
| jq -r '.error // ""')" ]] || fail 'an unknown hidden-network security was accepted'
[[ ! -s "$state_dir/argv" ]] \
|| fail 'an unknown hidden-network security still reached nmcli'
[[ -n "$(runh join-hidden 'office-private' 'office-private' wpa-psk </dev/null 2>/dev/null \
| jq -r '.error // ""')" ]] || fail 'a secured hidden network with no password was accepted'
# ── import-vpn picks its plugin from the extension ─────────────────────────── # ── import-vpn picks its plugin from the extension ───────────────────────────
printf '[Interface]\n' >"$work/tunnel.conf" printf '[Interface]\n' >"$work/tunnel.conf"
@@ -606,9 +853,9 @@ done
[[ -n "$(error_of bogus-verb)" ]] || fail 'an unknown command was accepted' [[ -n "$(error_of bogus-verb)" ]] || fail 'an unknown command was accepted'
# ── Nothing anywhere left a secret behind ─────────────────────────────────── # ── Nothing anywhere left a secret behind ───────────────────────────────────
for secret in "$WIFI_PSK" "$VPN_SECRET" "$ENTERPRISE_PW" "$HOTSPOT_PW"; do for secret in "$WIFI_PSK" "$VPN_SECRET" "$ENTERPRISE_PW" "$HOTSPOT_PW" "$HIDDEN_PW"; do
leaked="$(leak_in_scratch "$secret")" leaked="$(leak_in_scratch "$secret")"
[[ -z "$leaked" ]] || fail "a secret was left behind in $leaked" [[ -z "$leaked" ]] || fail "a secret was left behind in $leaked"
done done
printf 'network tools contract: PASS (details, forget, autoconnect, MAC, import, hotspot, enterprise, proxy, airplane)\n' printf 'network tools contract: PASS (details, forget, saved, autoconnect, MAC, metered, static IP, import, hotspot, enterprise, hidden, proxy, airplane)\n'
+70
View File
@@ -43,6 +43,9 @@ find_top() {
# ── An empty query is navigation, not a search ─────────────────────────────── # ── An empty query is navigation, not a search ───────────────────────────────
[[ "$(find_top '' | jq -r .count)" == "0" ]] || fail 'an empty query returned results' [[ "$(find_top '' | jq -r .count)" == "0" ]] || fail 'an empty query returned results'
# Whitespace is an empty query wearing a hat. Tokenizing it produces no tokens,
# which must mean "no search" rather than "every setting matches nothing".
[[ "$(find_top ' ' | jq -r .count)" == "0" ]] || fail 'a whitespace query returned results'
# ── Real settings are findable by what they are ────────────────────────────── # ── Real settings are findable by what they are ──────────────────────────────
while IFS='|' read -r query expect_label expect_page; do while IFS='|' read -r query expect_label expect_page; do
@@ -80,6 +83,73 @@ CASES
! rg -Fq 'Startup & Services' "$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" \ ! rg -Fq 'Startup & Services' "$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" \
|| fail 'search index still uses the retired Startup & Services name' || fail 'search index still uses the retired Startup & Services name'
# ── Words, not one contiguous substring ──────────────────────────────────────
#
# The 21 cases above are all single-token or exactly-adjacent, and they were
# passing before tokenization -- they are here to prove tokenizing did not move
# what was already right. These are the ones that were returning nothing at all:
# every word is present in the index, just not adjacent and not in that order.
#
# "wifi password" is the sharpest: the words live in two different fields of the
# same entry, so no substring of any haystack contains the query as typed.
while IFS='|' read -r query expect_label expect_page; do
result="$(find_top "$query")"
got_label="$(jq -r .top <<<"$result")"
got_page="$(jq -r .topPage <<<"$result")"
[[ "$got_label" == "$expect_label" ]] \
|| fail "searching '$query' put '$got_label' first, expected '$expect_label'"
[[ "$got_page" == "$expect_page" ]] \
|| fail "searching '$query' routes to '$got_page', expected '$expect_page'"
done <<'TOKENS'
log out|Log out|power
metered|Metered connection|connectivity
ethernet|Ethernet|connectivity
gestures|Gestures|mouse
saved networks|Saved networks|connectivity
hidden network|Hidden network|connectivity
static ip|Static IP address|connectivity
color filter|Color filter|accessibility
custom shortcut|Custom shortcut|shortcuts
app rules|App rules|tiling
window rules|Window rules|tiling
do not disturb|Do Not Disturb|notifications
four-finger swipe|Four-finger swipe|mouse
TOKENS
# Every word must match, in any order and in any field. Order was the
# accidental part of substring matching, and it was doing the most damage.
for query in 'wifi password' 'password wifi'; do
[[ "$(find_top "$query" | jq -r .count)" != "0" ]] \
|| fail "searching '$query' found nothing, on a page that shows the Wi-Fi password"
[[ "$(find_top "$query" | jq -r .topPage)" == "connectivity" ]] \
|| fail "searching '$query' did not lead with a network result"
done
# AND, not OR. A query holding a word the index does not have anywhere must
# return nothing, or tokenizing has only made the field louder.
[[ "$(find_top 'wallpaper zzzznotathing' | jq -r .count)" == "0" ]] \
|| fail 'a query containing an unmatchable word still returned results'
# ── A result may name the tab it lives on ────────────────────────────────────
#
# Landing on Appearance's Themes tab after searching "theme editor" is the
# search half-working: the page is right and the thing searched for is behind
# another click. Results that name no section route exactly as before, which is
# every result the schema produces.
[[ "$(find_top 'theme editor' | jq -r .topSection)" == "editor" ]] \
|| fail 'the theme editor result does not name the tab it lives on'
[[ "$(find_top 'video wallpaper' | jq -r .topSection)" == "background" ]] \
|| fail 'the video wallpaper result does not name the tab it lives on'
[[ "$(find_top 'timezone' | jq -r .topSection)" == "" ]] \
|| fail 'a result on a page with no tabs still names a section'
# And the sidebar has to consume it, or the field is decorative.
sidebar="$repo_dir/config/dot/quickshell/modules/settings/SettingsSidebar.qml"
rg -Fq 'ShellState.openSettingsSection' "$sidebar" \
|| fail 'the sidebar never opens a result at its section'
rg -Fq 'root.pageRequested' "$sidebar" \
|| fail 'the sidebar lost the plain page route, which most results still use'
# ── Shortcuts are searchable by what they do ───────────────────────────────── # ── Shortcuts are searchable by what they do ─────────────────────────────────
[[ "$(find_top screenshot | jq -r .topPage)" == "shortcuts" ]] \ [[ "$(find_top screenshot | jq -r .topPage)" == "shortcuts" ]] \
|| fail 'searching a shortcut description did not route to the shortcuts page' || fail 'searching a shortcut description did not route to the shortcuts page'
+13
View File
@@ -241,6 +241,19 @@ grep -q 'copiedKey' "$page" \
grep -qE 'SshKeys\.refresh\(\)' "$page" \ grep -qE 'SshKeys\.refresh\(\)' "$page" \
|| fail 'the page cannot refresh, so a key made in a terminal never appears' || fail 'the page cannot refresh, so a key made in a terminal never appears'
# Known hosts are folded at the house cap. A machine in daily use accumulates
# dozens of these, and an unbounded Repeater makes the last card taller than the
# rest of the page put together -- the keys and the agent, which are what
# somebody came here for, end up above the fold of a list nobody reads.
grep -q 'shownHosts' "$page" \
|| fail 'the known-hosts list is not capped, so it grows without limit'
grep -qE 'hostCap: 6' "$page" \
|| fail 'the known-hosts cap is not the house cap of 6'
grep -q 'hiddenHostCount' "$page" \
|| fail 'nothing counts the folded hosts, so the fold row cannot say how many'
grep -qE 'model: root\.shownHosts' "$page" \
|| fail 'the hosts Repeater still walks the whole list, so the cap is decorative'
# ══ The hermetic half ═══════════════════════════════════════════════════════ # ══ The hermetic half ═══════════════════════════════════════════════════════
# #
# Everything above reads. Everything below writes -- into a scratch home, with # Everything above reads. Everything below writes -- into a scratch home, with