diff --git a/README.md b/README.md index 7761af3..87ace67 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ docs/ Settings reference, and the design specs behind the work ## 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 panama test # everything diff --git a/config/dot/hypr/README.md b/config/dot/hypr/README.md index 65d8d74..9d7089b 100644 --- a/config/dot/hypr/README.md +++ b/config/dot/hypr/README.md @@ -33,9 +33,11 @@ Don't "fix" them. | `looks.lua` | Colors, blur, glow, shadows, animations, VRR, scanout | | `input.lua` | Keyboard/mouse. Click-to-focus, like GNOME | | `rules.lua` | Window rules, gaming rules, layer rules for the shell | -| `keybinds.lua` | The full keymap | +| `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 | | `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) | Validate any change without leaving your session: diff --git a/config/dot/hypr/actions.lua b/config/dot/hypr/actions.lua new file mode 100644 index 0000000..60dd08f --- /dev/null +++ b/config/dot/hypr/actions.lua @@ -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 = "", label = "" } +-- +-- `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 diff --git a/config/dot/hypr/input.lua b/config/dot/hypr/input.lua index 5bd8b53..f4fbbc3 100644 --- a/config/dot/hypr/input.lua +++ b/config/dot/hypr/input.lua @@ -6,6 +6,7 @@ -- ───────────────────────────────────────────────────────────────────────────── local prefs = require("prefs") +local actions = require("actions") hl.config({ 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 = "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 diff --git a/config/dot/hypr/keybinds.lua b/config/dot/hypr/keybinds.lua index 6dde0dc..33fd3b6 100644 --- a/config/dot/hypr/keybinds.lua +++ b/config/dot/hypr/keybinds.lua @@ -12,6 +12,7 @@ -- ───────────────────────────────────────────────────────────────────────────── local prefs = require("prefs") +local actions = require("actions") 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("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() return true diff --git a/config/dot/hypr/looks.lua b/config/dot/hypr/looks.lua index edafe55..b1ed2e3 100644 --- a/config/dot/hypr/looks.lua +++ b/config/dot/hypr/looks.lua @@ -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 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({ general = { 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. 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) }, diff --git a/config/dot/hypr/rules.lua b/config/dot/hypr/rules.lua index dedfce7..eeb8612 100644 --- a/config/dot/hypr/rules.lua +++ b/config/dot/hypr/rules.lua @@ -9,6 +9,8 @@ -- to invert it. -- ───────────────────────────────────────────────────────────────────────────── +local prefs = require("prefs") + -- ── Upstream sanity rules ─────────────────────────────────────────────────── hl.window_rule({ name = "suppress-maximize-events", @@ -136,6 +138,97 @@ hl.window_rule({ 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 ───────────────────────────────────────────────────────── -- Deliberately NO "smart gaps". -- diff --git a/config/dot/hypr/shaders/deuteranopia.frag b/config/dot/hypr/shaders/deuteranopia.frag new file mode 100644 index 0000000..aff625e --- /dev/null +++ b/config/dot/hypr/shaders/deuteranopia.frag @@ -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); +} diff --git a/config/dot/hypr/shaders/grayscale.frag b/config/dot/hypr/shaders/grayscale.frag new file mode 100644 index 0000000..de160de --- /dev/null +++ b/config/dot/hypr/shaders/grayscale.frag @@ -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 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); +} diff --git a/config/dot/hypr/shaders/protanopia.frag b/config/dot/hypr/shaders/protanopia.frag new file mode 100644 index 0000000..11ec0fc --- /dev/null +++ b/config/dot/hypr/shaders/protanopia.frag @@ -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); +} diff --git a/config/dot/hypr/shaders/tritanopia.frag b/config/dot/hypr/shaders/tritanopia.frag new file mode 100644 index 0000000..33d8f8c --- /dev/null +++ b/config/dot/hypr/shaders/tritanopia.frag @@ -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); +} diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index a253a49..3445cf2 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -1059,6 +1059,23 @@ Singleton { detail: "How much darker unfocused windows are", 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", label: "Flash the screen for notifications", @@ -1757,6 +1774,63 @@ Singleton { 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 ─────────────────────────────────────────── // { "": { mode, scale, transform, x, y, primary, vrrMode, // colorProfile, bitdepth, sdrBrightness, sdrSaturation, mirrorOf } }, diff --git a/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml b/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml index bd16550..7bb0c0b 100644 --- a/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml +++ b/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml @@ -170,6 +170,22 @@ Item { 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: // the half you forget to arm is the one that fires a message // preview onto the big screen. Restores both exactly as found. diff --git a/config/dot/quickshell/modules/settings/AccessibilityPage.qml b/config/dot/quickshell/modules/settings/AccessibilityPage.qml index d5dd962..54edefe 100644 --- a/config/dot/quickshell/modules/settings/AccessibilityPage.qml +++ b/config/dot/quickshell/modules/settings/AccessibilityPage.qml @@ -101,7 +101,35 @@ SettingsPage { ToggleRow { setting: "magnifierRigid" } SliderRow { setting: "textScale" } 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 { diff --git a/config/dot/quickshell/modules/settings/ConnectionDetails.qml b/config/dot/quickshell/modules/settings/ConnectionDetails.qml index c427d0a..e1516e7 100644 --- a/config/dot/quickshell/modules/settings/ConnectionDetails.qml +++ b/config/dot/quickshell/modules/settings/ConnectionDetails.qml @@ -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 // 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 -// is a page of its own rather than four fields smuggled into a details drawer. +// The facts above are what is on the wire; the editors below are what the +// 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 // addresses only -- no PSK, no enterprise password -- so this component can be // 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 // 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. import QtQuick import qs.config +import qs.services Column { id: root - // { ip4, gateway, dns: [], mac, macRandomized } as the helper reports it, - // or null while the read has not come back. Null is NOT "no address": the - // component says it is still reading rather than claiming an answer. + // { ip4, gateway, dns: [], mac, macRandomized, metered, ip4Method … } as + // the helper reports it, or null while the read has not come back. Null is + // NOT "no address": the component says it is still reading rather than + // claiming an answer. 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 // connection with no gateway shows three rows rather than a blank one. readonly property var facts: { @@ -145,4 +164,192 @@ Column { font.pixelSize: Theme.fontSizeSmall 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() + } + } + } } diff --git a/config/dot/quickshell/modules/settings/ConnectivityPage.qml b/config/dot/quickshell/modules/settings/ConnectivityPage.qml index 755fef9..670f911 100644 --- a/config/dot/quickshell/modules/settings/ConnectivityPage.qml +++ b/config/dot/quickshell/modules/settings/ConnectivityPage.qml @@ -48,6 +48,20 @@ SettingsPage { property bool importOpen: false 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 // whole setting. // @@ -187,6 +201,7 @@ SettingsPage { ConnectionDetails { width: parent.width visible: root.wiredOpen && Connectivity.wiredOn + connection: root.wiredConnection details: root.wiredConnection !== "" ? 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 ────────────────────────────────────────────────────────────────── SettingsCard { diff --git a/config/dot/quickshell/modules/settings/CustomShortcutEditor.qml b/config/dot/quickshell/modules/settings/CustomShortcutEditor.qml new file mode 100644 index 0000000..301c014 --- /dev/null +++ b/config/dot/quickshell/modules/settings/CustomShortcutEditor.qml @@ -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; + } +} diff --git a/config/dot/quickshell/modules/settings/CustomShortcutRow.qml b/config/dot/quickshell/modules/settings/CustomShortcutRow.qml new file mode 100644 index 0000000..c49eef9 --- /dev/null +++ b/config/dot/quickshell/modules/settings/CustomShortcutRow.qml @@ -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() + } + } +} diff --git a/config/dot/quickshell/modules/settings/HealthPage.qml b/config/dot/quickshell/modules/settings/HealthPage.qml index bdd8ae7..6b5a3c1 100644 --- a/config/dot/quickshell/modules/settings/HealthPage.qml +++ b/config/dot/quickshell/modules/settings/HealthPage.qml @@ -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 { - title: "Fedora system settings" - subtitle: "These areas remain owned by Fedora and GNOME's mature system panels." + title: "Digital wellbeing" + 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 { objectName: "health-fedora-handoff:wellbeing" label: "Digital wellbeing" diff --git a/config/dot/quickshell/modules/settings/MousePage.qml b/config/dot/quickshell/modules/settings/MousePage.qml index 94c1010..98d08af 100644 --- a/config/dot/quickshell/modules/settings/MousePage.qml +++ b/config/dot/quickshell/modules/settings/MousePage.qml @@ -11,10 +11,11 @@ // with no members, so the user would be toggling settings that can never affect // anything with nothing to say so. // -// The gestures that were a card of their own now sit at the bottom of the -// touchpad card. They are touchpad settings -- a three-finger swipe has nowhere -// else to happen -- and a card holding two sliders was a heading standing in -// for a section. +// Gestures are a card again. They were folded into the touchpad card when the +// only thing to say about them was how far a swipe travels and which way round +// it goes -- a heading standing in for a section. Four assignable four-finger +// 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 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 { visible: InputDevices.hasTouchpad 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: "touchpadClickfinger" } @@ -138,13 +261,7 @@ SettingsPage { ToggleRow { setting: "touchpadNaturalScroll" } SliderRow { setting: "touchpadScrollFactor" } ToggleRow { setting: "touchpadDisableWhileTyping" } - ToggleRow { setting: "touchpadMiddleButtonEmulation" } - - // 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 } + ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false } } SettingsCard { diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index 68539a6..321043b 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -22,6 +22,20 @@ Rectangle { 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 // tabs is opened at its first available tab by ShellState's resolution. readonly property var destinations: SettingsRoutes.categories @@ -186,7 +200,7 @@ Rectangle { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - root.pageRequested(hit.modelData.page); + root.openResult(hit.modelData); searchInput.text = ""; } } diff --git a/config/dot/quickshell/modules/settings/ShortcutsPage.qml b/config/dot/quickshell/modules/settings/ShortcutsPage.qml index 475ccdd..39c0007 100644 --- a/config/dot/quickshell/modules/settings/ShortcutsPage.qml +++ b/config/dot/quickshell/modules/settings/ShortcutsPage.qml @@ -106,13 +106,46 @@ SettingsPage { readonly property bool filtering: root.filter.trim() !== "" 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 // 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. + // + // "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: { const needle = root.filter.trim().toLowerCase(); const out = []; for (const group of Keybinds.grouped()) { + if (group.name === "Custom") + continue; const hits = needle === "" ? group.binds : group.binds.filter(bind => @@ -264,7 +297,7 @@ SettingsPage { SettingsCard { 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 { width: parent.width @@ -283,14 +316,107 @@ SettingsPage { Text { id: counts - anchors.right: parent.right + anchors.right: addButton.left + anchors.rightMargin: 12 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 font.family: Theme.fontFamily font.features: Theme.tabularFigures 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 @@ -363,6 +489,7 @@ SettingsPage { onChangeRequested: { root.conflict = ""; root.conflictChord = ""; + root.rebindingChord = ""; root.capturingChord = shortcutRow.modelData.luaChord; } @@ -395,7 +522,7 @@ SettingsPage { Text { width: parent.width - visible: root.groups.length === 0 && Keybinds.loaded + visible: root.groups.length === 0 && root.customBinds.length === 0 && Keybinds.loaded text: root.filtering ? "Nothing matches — the filter searches shortcut names and group names." : "The compositor reported no shortcuts." diff --git a/config/dot/quickshell/modules/settings/SshKeysPage.qml b/config/dot/quickshell/modules/settings/SshKeysPage.qml index 60f5be3..84bd63e 100644 --- a/config/dot/quickshell/modules/settings/SshKeysPage.qml +++ b/config/dot/quickshell/modules/settings/SshKeysPage.qml @@ -50,6 +50,19 @@ SettingsPage { 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 { root.showingGenerator = false; 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." Repeater { - model: SshKeys.hosts + model: root.shownHosts delegate: SettingRow { 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 { label: "Check again" detail: "Re-reads ~/.ssh and asks the agent what it is holding" diff --git a/config/dot/quickshell/modules/settings/TilingPage.qml b/config/dot/quickshell/modules/settings/TilingPage.qml index 8fb49d9..592ac36 100644 --- a/config/dot/quickshell/modules/settings/TilingPage.qml +++ b/config/dot/quickshell/modules/settings/TilingPage.qml @@ -16,6 +16,109 @@ SettingsPage { title: "Tiling" 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 { title: "Window layout" subtitle: "Follows the Forge mental model, with native Hyprland tiling." diff --git a/config/dot/quickshell/modules/settings/WifiPanel.qml b/config/dot/quickshell/modules/settings/WifiPanel.qml index 77e359a..54e46a4 100644 --- a/config/dot/quickshell/modules/settings/WifiPanel.qml +++ b/config/dot/quickshell/modules/settings/WifiPanel.qml @@ -45,6 +45,24 @@ Column { property string failedSsid: "" 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 // 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 @@ -72,6 +90,10 @@ Column { 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 // connected network opens rather than reconnecting to itself. function activate(network: var): void { @@ -202,6 +224,7 @@ Column { ConnectionDetails { width: parent.width + connection: entry.ssid details: entry.details } @@ -426,4 +449,116 @@ Column { : "" 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(); + } + } + } + } } diff --git a/config/dot/quickshell/modules/settings/WindowRuleEditor.qml b/config/dot/quickshell/modules/settings/WindowRuleEditor.qml new file mode 100644 index 0000000..65403ee --- /dev/null +++ b/config/dot/quickshell/modules/settings/WindowRuleEditor.qml @@ -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; + } +} diff --git a/config/dot/quickshell/modules/settings/WindowRuleRow.qml b/config/dot/quickshell/modules/settings/WindowRuleRow.qml new file mode 100644 index 0000000..1e2d812 --- /dev/null +++ b/config/dot/quickshell/modules/settings/WindowRuleRow.qml @@ -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) + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index e830b2d..0846254 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -139,3 +139,7 @@ IdleTimeline 1.0 IdleTimeline.qml PowerProfileTiles 1.0 PowerProfileTiles.qml FieldActionRow 1.0 FieldActionRow.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 diff --git a/config/dot/quickshell/scripts/panama-network b/config/dot/quickshell/scripts/panama-network index 5f67253..8162d61 100755 --- a/config/dot/quickshell/scripts/panama-network +++ b/config/dot/quickshell/scripts/panama-network @@ -13,11 +13,16 @@ native service stays native. panama-network details CONNECTION panama-network forget CONNECTION + panama-network saved panama-network set-autoconnect 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 hotspot start SSID | hotspot stop | hotspot status 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 set none 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])?$") 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" # produces a profile that fails to authenticate with no way to tell why. EAP_PROFILES = { @@ -199,6 +246,69 @@ def require_connection(value: str) -> str: 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 @@ -284,6 +394,19 @@ def connection_state(name: str, note: str = "") -> dict: "mac": "", "macRandomized": 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, "error": "", } @@ -296,6 +419,13 @@ def connection_state(name: str, note: str = "") -> dict: state["type"] = found.get("connection.type", "") state["autoconnect"] = found.get("connection.autoconnect", "") in ("yes", "true") 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["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.") +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 @@ -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.")) +# ---------------------------------------------------------------- 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 @@ -791,7 +1105,11 @@ FALLBACKS = { "connection": {"connection": "", "exists": False, "uuid": "", "type": "", "interface": "", "active": False, "ip4": "", "ip6": "", "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": ""}, "hotspot": {"active": False, "ssid": "", "password": "", "connection": HOTSPOT_CONNECTION, "band": "", "interface": ""}, @@ -800,10 +1118,14 @@ FALLBACKS = { "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-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 | " "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] | " "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])) if verb == "forget" and len(rest) == 1: 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: return "connection", set_autoconnect(rest[0], require_bool(rest[1])) 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): return "connection", join_enterprise( 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"]: return "proxy", proxy_get() 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 "" return {"details": "connection", "forget": "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", "airplane": "airplane"}.get(verb, "connection") diff --git a/config/dot/quickshell/services/Keybinds.qml b/config/dot/quickshell/services/Keybinds.qml index 14159d4..9eccfe4 100644 --- a/config/dot/quickshell/services/Keybinds.qml +++ b/config/dot/quickshell/services/Keybinds.qml @@ -98,6 +98,13 @@ Singleton { // Displayed binds come from the compositor and so already reflect any // override; the override map is what tells us where they started. 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) { if (root.overrides[shipped] === currentChord) return shipped; @@ -131,6 +138,12 @@ Singleton { } 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) return false; @@ -183,6 +196,209 @@ Singleton { 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 { id: reloadRun command: ["hyprctl", "reload"] @@ -388,7 +604,10 @@ Singleton { // after them are the ones the substring derivation produces, kept so a // machine whose compositor has not reloaded since the manifest was added // 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", "Focus", "Move & split", "Size", "Window state", "Applications & shell", "Media & hardware keys"] diff --git a/config/dot/quickshell/services/NetworkTools.qml b/config/dot/quickshell/services/NetworkTools.qml index f918928..9026b38 100644 --- a/config/dot/quickshell/services/NetworkTools.qml +++ b/config/dot/quickshell/services/NetworkTools.qml @@ -35,6 +35,12 @@ Singleton { // connection name -> the helper's connection shape. See detailsFor(). 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 string proxyMode: "none" property string proxyHost: "" @@ -54,7 +60,7 @@ Singleton { // Guards read the Process objects directly; a derived binding is stale // 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 // 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 { try { const parsed = JSON.parse(text); @@ -185,6 +203,7 @@ Singleton { function absorb(shape: string, subject: string, text: string): void { switch (shape) { case "connection": root.absorbDetails(subject, text); break; + case "saved": root.absorbSaved(text); break; case "hotspot": root.absorbHotspot(text); break; case "proxy": root.absorbProxy(text); break; case "airplane": root.absorbAirplane(text); break; @@ -206,6 +225,10 @@ Singleton { function forget(connection: string): void { 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 { @@ -218,6 +241,36 @@ Singleton { ["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 function importVpn(path: string): void { @@ -273,16 +326,34 @@ Singleton { // above: only this one ever opens stdin. function joinEnterprise(ssid: string, profile: string, identity: string, password: string, caCert: string): void { - if (enterprise.running) + if (passwordJoin.running) return; root.lastError = ""; root.pendingPassword = password; - enterprise.subject = ssid; - enterprise.command = String(caCert ?? "") !== "" + passwordJoin.subject = ssid; + passwordJoin.command = String(caCert ?? "") !== "" ? [root.helperPath, "join-enterprise", ssid, profile, identity, caCert] : [root.helperPath, "join-enterprise", ssid, profile, identity]; - enterprise.stdinEnabled = true; - enterprise.running = true; + passwordJoin.stdinEnabled = 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 @@ -291,6 +362,7 @@ Singleton { root.refreshProxy(); root.refreshAirplaneSoon(); root.refreshHotspotSoon(); + root.refreshSavedSoon(); for (const connection in root.details) root.requestDetails(connection); } @@ -299,6 +371,7 @@ Singleton { // over it rather than dropped by its running guard. function refreshAirplaneSoon(): void { airplaneSoon.restart(); } function refreshHotspotSoon(): void { hotspotSoon.restart(); } + function refreshSavedSoon(): void { savedSoon.restart(); } 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 // from inside onExited would look at a `running` that has not gone false // yet, and the queue would stall on its own guard. @@ -369,24 +453,29 @@ Singleton { } Process { - id: enterprise + id: passwordJoin property string subject: "" onStarted: { - enterprise.write(root.pendingPassword + "\n"); + passwordJoin.write(root.pendingPassword + "\n"); // Held for as long as it takes to hand over, and no longer. root.pendingPassword = ""; // Closing stdin is what lets the helper's read return; without it // the join waits forever for a line that is already sent. - enterprise.stdinEnabled = false; + passwordJoin.stdinEnabled = false; } stdout: StdioCollector { - onStreamFinished: root.absorbDetails(enterprise.subject, this.text) + onStreamFinished: root.absorbDetails(passwordJoin.subject, this.text) } stderr: StdioCollector { 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(); + } } } diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index bce8272..076a2d3 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -69,6 +69,12 @@ Singleton { // Settings that are real but have no schema entry, because the system owns // them rather than Panama. Without these, searching "timezone" would fail // 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: [ { label: "Manual", detail: "How this desktop works, in chapters", 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: "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" }, + // 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" }, // The Applications tab manages applications now, rather than only // 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: "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: "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: "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" }, @@ -277,6 +304,18 @@ Singleton { // a row — so searching for the thing people actually want to do would // 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" }, + // 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: "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 @@ -294,13 +333,18 @@ Singleton { { 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: "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: "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: "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: "Password field", detail: "Choose whether the empty lock-screen field stays visible", page: "appearance" }, - { label: "Per-display wallpaper", detail: "Assign a different image to each connected display", 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", section: "background" }, + { 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: "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" }, @@ -316,18 +360,18 @@ Singleton { { 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: "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: "Themes", detail: "Light and dark mode, and the theme for each", page: "appearance" }, - { label: "Dark mode", detail: "Flip the desktop to your dark theme", page: "appearance" }, - { label: "Light mode", detail: "Flip the desktop to your light theme", page: "appearance" }, - { label: "Theme editor", detail: "Build your own theme — colors, saturation, and effects", page: "appearance" }, - { label: "Catppuccin", detail: "Mocha and Latte, in the theme galleries", page: "appearance" }, - { label: "Nord", detail: "The arctic dark theme, in the gallery", page: "appearance" }, - { label: "Gruvbox", detail: "Dark and light, in the theme galleries", page: "appearance" }, - { label: "Everforest", detail: "Dark and light, in the theme galleries", page: "appearance" }, - { label: "Tokyo Night", detail: "Moon and Day, the shipped defaults", page: "appearance" }, - { label: "Video wallpaper", detail: "A looping video as the desktop background", page: "appearance" }, - { label: "Advanced accent", detail: "Adjust primary and secondary hue, saturation, and value", 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", section: "themes" }, + { 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", section: "themes" }, + { 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", section: "themes" }, + { 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", section: "themes" }, + { 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", section: "themes" }, + { 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", section: "editor" }, { label: "Pick colour from screen", detail: "Sample an accent colour with hyprpicker", page: "appearance" } ] @@ -335,41 +379,77 @@ Singleton { return root.groupPages[group] ?? "home"; } - // [{ label, detail, page, kind }] for a query. Empty query yields nothing: - // the sidebar shows its normal navigation in that case. + // Both sides of a comparison, in the one spelling. + // + // 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 { - const needle = String(query).trim().toLowerCase(); + const needle = root.flatten(query); if (needle === "") 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 seen = {}; - function add(label, detail, page, kind) { + function add(label, detail, page, kind, section) { const dedupe = `${kind}:${label}:${page}`; if (seen[dedupe]) return; 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) { if (entry.internal) continue; const optionLabels = (entry.options ?? []).map(option => option.label).join(" "); - const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group} ${optionLabels}`.toLowerCase(); - if (haystack.indexOf(needle) >= 0) - add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting"); + const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group} ${optionLabels}`; + if (hit(haystack)) + add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting", ""); } for (const entry of root.extraEntries) { - if (`${entry.label} ${entry.detail}`.toLowerCase().indexOf(needle) >= 0) - add(entry.label, entry.detail, entry.page, "setting"); + if (hit(`${entry.label} ${entry.detail}`)) + add(entry.label, entry.detail, entry.page, "setting", entry.section ?? ""); } for (const bind of Keybinds.binds) { - if (bind.description.toLowerCase().indexOf(needle) >= 0) - add(bind.description, bind.chord, "shortcuts", "shortcut"); + if (hit(bind.description)) + add(bind.description, bind.chord, "shortcuts", "shortcut", ""); } // 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 // mode choice, so Wallpaper mode belongs above the interval row that // 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) => { const aSpec = PreferenceSchema.entries.find(entry => entry.label === a.label); const bSpec = PreferenceSchema.entries.find(entry => entry.label === b.label); - const ao = (aSpec?.options ?? []).some(option => option.label.toLowerCase() === needle); - const bo = (bSpec?.options ?? []).some(option => option.label.toLowerCase() === needle); + const ao = (aSpec?.options ?? []).some(option => root.flatten(option.label) === needle); + const bo = (bSpec?.options ?? []).some(option => root.flatten(option.label) === needle); if (ao !== bo) return ao ? -1 : 1; - const al = a.label.toLowerCase(); - const bl = b.label.toLowerCase(); + const al = root.flatten(a.label); + 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 bp = bl === needle ? 0 : (bl.indexOf(needle) === 0 ? 1 : 2); return ap !== bp ? ap - bp : al.localeCompare(bl); diff --git a/config/dot/quickshell/services/SystemSettings.qml b/config/dot/quickshell/services/SystemSettings.qml index 74aa72e..523db08 100644 --- a/config/dot/quickshell/services/SystemSettings.qml +++ b/config/dot/quickshell/services/SystemSettings.qml @@ -652,6 +652,81 @@ Singleton { 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 // a value the user changed in Settings survives a reboot even though the // Lua config only reads the file once, at launch. diff --git a/config/dot/quickshell/services/WindowRules.qml b/config/dot/quickshell/services/WindowRules.qml new file mode 100644 index 0000000..fb4c6ed --- /dev/null +++ b/config/dot/quickshell/services/WindowRules.qml @@ -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 +} diff --git a/config/dot/quickshell/settings-search-harness.qml b/config/dot/quickshell/settings-search-harness.qml index 12af5c5..e7107d0 100644 --- a/config/dot/quickshell/settings-search-harness.qml +++ b/config/dot/quickshell/settings-search-harness.qml @@ -14,7 +14,11 @@ ShellRoot { count: hits.length, top: hits.length > 0 ? hits[0].label : "", 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) }); } } diff --git a/config/local/share/vicinae/scripts/settings-accessibility b/config/local/share/vicinae/scripts/settings-accessibility index dac7aac..93af499 100755 --- a/config/local/share/vicinae/scripts/settings-accessibility +++ b/config/local/share/vicinae/scripts/settings-accessibility @@ -5,6 +5,6 @@ # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @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 diff --git a/docs/settings.md b/docs/settings.md index 42897b6..c027487 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -4,7 +4,7 @@ Do not edit this file. Run `quickshell/scripts/panama-settings-docs` 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 @@ -17,6 +17,7 @@ Found on **Accessibility**. | **High contrast**
`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**
`dimInactive` `decoration:dim_inactive` | false | Darkens every window except the focused one, so the active window is unmistakable | | **Dim amount**
`dimStrength` `decoration:dim_strength` | 0.5 | How much darker unfocused windows are. Range 0.05–0.9. | +| **Color filter**
`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**
`visualAlerts` | false | A single flash at the edges of every screen when a notification arrives that would ring the bell | | **Pointer size**
`cursorSize` | 24 px | Applies to the compositor and to applications. Range 16–64. | | **Text size**
`textScale` | 1.0 | Scales interface text everywhere; 1.00 is the design size. Range 0.75–2.0. | diff --git a/docs/superpowers/specs/2026-08-25-tier2-identity.md b/docs/superpowers/specs/2026-08-25-tier2-identity.md new file mode 100644 index 0000000..8084a2c --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-tier2-identity.md @@ -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": "", "label": "" } +``` + +- `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 1–10). + +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": "", "label": "", + "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 + N−2 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 = , action = })`. +- 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 ` 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. diff --git a/setup/packages/hyprland-packages b/setup/packages/hyprland-packages index 2d0a3b3..adeaaf1 100644 --- a/setup/packages/hyprland-packages +++ b/setup/packages/hyprland-packages @@ -11,6 +11,10 @@ fprintd gpu-screen-recorder grim 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 helium-browser-bin hypridle diff --git a/tests/hypr/gestures-contract b/tests/hypr/gestures-contract index 5af989f..39d4a13 100755 --- a/tests/hypr/gestures-contract +++ b/tests/hypr/gestures-contract @@ -1,15 +1,27 @@ #!/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 -# neither can be checked by running it. What can be pinned is the shape: +# All of it is behaviour that cannot be observed without hardware this machine +# 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 -# overview, down closes it. +# * Three THREE-finger gestures, mirroring GNOME. Sideways moves workspaces, +# 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 # 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. +# * 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 # everybody would make terminals appear to vanish on a machine nobody asked. # * The swallow regex names only terminals this desktop ships. Anything the @@ -19,17 +31,19 @@ set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -input="$repo_dir/config/dot/hypr/input.lua" -looks="$repo_dir/config/dot/hypr/looks.lua" +hypr_dir="$repo_dir/config/dot/hypr" +input="$hypr_dir/input.lua" +looks="$hypr_dir/looks.lua" schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" findings=() note() { findings+=("$1"); } -# ── Gestures ───────────────────────────────────────────────────────────────── +# ── The three that are the desktop's ───────────────────────────────────────── -gestures="$(grep -c 'hl\.gesture({' "$input" || true)" -(( gestures == 3 )) || note "input.lua registers $gestures gestures, not the 3 that mirror GNOME" +literals="$(grep -c 'hl\.gesture({ fingers = 3' "$input" || true)" +(( 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" \ || 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' 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 # gestures are unadjustable without editing this file -- which is the thing # 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" 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 ─────────────────────────────────────────────────────────────── grep -qE 'enable_swallow = prefs\.get\("windowSwallow", false\)' "$looks" \ diff --git a/tests/hypr/keybind-categories-contract b/tests/hypr/keybind-categories-contract index a24fa41..5e72c01 100755 --- a/tests/hypr/keybind-categories-contract +++ b/tests/hypr/keybind-categories-contract @@ -45,6 +45,16 @@ grep -q 'write_categories()' "$keybinds" \ grep -q 'if file == nil then' "$keybinds" \ || 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. grep -q 'categoryManifest' "$service" \ || 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 # 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 [[ -n "$value" ]] || continue grep -qF "$value" <<<"$known" \ diff --git a/tests/hypr/window-rules-contract b/tests/hypr/window-rules-contract new file mode 100755 index 0000000..716ffe1 --- /dev/null +++ b/tests/hypr/window-rules-contract @@ -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 +# " ". 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" diff --git a/tests/quickshell/gnome-handoff-contract b/tests/quickshell/gnome-handoff-contract index e3d7ccf..e00ed5e 100755 --- a/tests/quickshell/gnome-handoff-contract +++ b/tests/quickshell/gnome-handoff-contract @@ -70,7 +70,17 @@ fail() { # 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" # 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=( + [system]=about [network]=connectivity [wifi]=connectivity [printers]=printers diff --git a/tests/quickshell/health-ui-contract b/tests/quickshell/health-ui-contract index 619c4f2..699768f 100755 --- a/tests/quickshell/health-ui-contract +++ b/tests/quickshell/health-ui-contract @@ -78,14 +78,19 @@ rg -Fq 'implicitHeight: 62' "$settings_dir/HealthCheckRow.qml" \ || fail 'health rows are below the approved 62px target' rg -Fq 'Health.refresh()' "$settings_dir/HealthPage.qml" \ || fail 'opening System Health does not request a fresh scan' -# "Open GNOME Settings" opens the application, not a panel -- but -# 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 -# Connections absorbed VPN, hotspot, proxy and per-connection details: Panama -# owns that panel now, and gnome-handoff-contract fails any page pointing at an -# owned one. "system" is a panel Panama does not have. +# The umbrella button is gone, and this is the assertion that keeps it gone. +# +# It read "Open GNOME Settings" and landed on the System panel -- not a handoff +# for anything in particular, which is exactly why nothing caught it while every +# specific door beside it was being closed one at a time. It first named +# "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" \ - || 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" \ && 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 @@ -138,8 +143,13 @@ rg -Fq 'Health.saveReport(' "$settings_dir/HealthPage.qml" \ # 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 # 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" \ - || 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" \ | 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' \ diff --git a/tests/quickshell/keybind-rebind-contract b/tests/quickshell/keybind-rebind-contract index 157dcdf..b33ae8e 100755 --- a/tests/quickshell/keybind-rebind-contract +++ b/tests/quickshell/keybind-rebind-contract @@ -87,6 +87,202 @@ rg -Fq 'Restore every shipped shortcut' "$page" \ rg -Fq 'Object.keys(Keybinds.overrides).length' "$page" \ || 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"(?/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 "\t\t". + 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 printf 'keybind rebind contract: PASS (static)\n' exit 0 diff --git a/tests/quickshell/network-tools-contract b/tests/quickshell/network-tools-contract index 4cc1561..e714648 100755 --- a/tests/quickshell/network-tools-contract +++ b/tests/quickshell/network-tools-contract @@ -71,6 +71,7 @@ readonly WIFI_PSK='psk-must-never-leave-9c1f' readonly VPN_SECRET='vpn-secret-must-never-leave-7b20' readonly ENTERPRISE_PW='enterprise-pw-must-never-leave-4e88' 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 ──────────────────────────── # @@ -134,6 +135,58 @@ PY grep -q -- '--show-secrets' "$helper" \ && 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 ────────────────────── # # 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; } # ── 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)" stub_dir="$work/bin" @@ -280,12 +340,37 @@ detail() { printf 'IP4.GATEWAY:192.168.7.1\n' printf 'IP4.DNS[1]:192.168.7.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-1x.password:$ENTERPRISE_PW\n' printf 'vpn.secrets.password:$VPN_SECRET\n' } 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"*) # The only invocation that is fed anything, and it is drained under a # 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" \ || 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 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 \ + | 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 \ + | jq -r '.error // ""')" ]] || fail 'a secured hidden network with no password was accepted' + # ── import-vpn picks its plugin from the extension ─────────────────────────── printf '[Interface]\n' >"$work/tunnel.conf" @@ -606,9 +853,9 @@ done [[ -n "$(error_of bogus-verb)" ]] || fail 'an unknown command was accepted' # ── 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")" [[ -z "$leaked" ]] || fail "a secret was left behind in $leaked" 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' diff --git a/tests/quickshell/settings-search-contract b/tests/quickshell/settings-search-contract index f9a56c4..4fc0b4a 100755 --- a/tests/quickshell/settings-search-contract +++ b/tests/quickshell/settings-search-contract @@ -43,6 +43,9 @@ find_top() { # ── An empty query is navigation, not a search ─────────────────────────────── [[ "$(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 ────────────────────────────── 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" \ || 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 ───────────────────────────────── [[ "$(find_top screenshot | jq -r .topPage)" == "shortcuts" ]] \ || fail 'searching a shortcut description did not route to the shortcuts page' diff --git a/tests/quickshell/ssh-keys-contract b/tests/quickshell/ssh-keys-contract index b2fd07b..a39d313 100755 --- a/tests/quickshell/ssh-keys-contract +++ b/tests/quickshell/ssh-keys-contract @@ -241,6 +241,19 @@ grep -q 'copiedKey' "$page" \ grep -qE 'SshKeys\.refresh\(\)' "$page" \ || 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 ═══════════════════════════════════════════════════════ # # Everything above reads. Everything below writes -- into a scratch home, with