Shortcuts you invent, rules you write, gestures you own - all still just data
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Named actions
|
||||
--
|
||||
-- The one thing that makes it safe for settings.json to describe a shortcut.
|
||||
--
|
||||
-- Panama Settings lets a person invent a keyboard shortcut and assign a
|
||||
-- four-finger gesture. Both are stored in the same user-editable JSON file the
|
||||
-- rest of the desktop reads, and both have to end up as something the
|
||||
-- compositor executes -- which is exactly the shape of every configuration
|
||||
-- format that turned out to be a shell injection.
|
||||
--
|
||||
-- It is not one here, and this file is why. A stored action is DATA:
|
||||
--
|
||||
-- { kind = "app" | "shell" | "window", target = "<id>", label = "<text>" }
|
||||
--
|
||||
-- `kind` is an enum with three members. `target` is either a key of one of the
|
||||
-- whitelist tables below -- whose values are literals written here, in Lua, by
|
||||
-- a human -- or, for `app`, an identifier that has to match a character class
|
||||
-- containing no shell metacharacter at all, and which is then quoted as a
|
||||
-- single argv element for panama-launch rather than pasted into a command.
|
||||
--
|
||||
-- So the worst a hand-edited (or maliciously written) settings file can do is
|
||||
-- pick a different entry from a list that is fixed at ship time, or launch an
|
||||
-- application by id. It cannot introduce a command. There is no path from a
|
||||
-- stored string to a new exec string; the table lookups are the only source of
|
||||
-- one.
|
||||
--
|
||||
-- Everything invalid returns nil and the caller skips the bind or gesture --
|
||||
-- the prefs.lua philosophy: never raise, never guess. A malformed entry costs
|
||||
-- one shortcut, never the keymap and never the compositor.
|
||||
--
|
||||
-- Required by keybinds.lua (custom shortcuts) and input.lua (four-finger
|
||||
-- gestures). services/Keybinds.qml's describeAction() is the QML mirror of the
|
||||
-- vocabulary below; the two lists have to be edited together.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
local actions = {}
|
||||
|
||||
-- ── Shell verbs ─────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Every entry is a command string written HERE. Nothing stored anywhere else
|
||||
-- contributes a character to one; `target` only chooses which of these to use.
|
||||
--
|
||||
-- The `qs ipc call` targets and functions are the ones quickshell/shell.qml
|
||||
-- actually registers -- an IpcHandler silently declines to register a function
|
||||
-- it cannot type-check, so a verb invented here would be a shortcut that does
|
||||
-- nothing. Checked against shell.qml, not remembered.
|
||||
--
|
||||
-- The three that are not IPC (`launcher`, `color-picker`, `lock`) are the same
|
||||
-- literal commands the shipped binds in keybinds.lua use, for the same reason
|
||||
-- they use them: they are the tools, not the shell.
|
||||
local SHELL = {
|
||||
["dnd-toggle"] = { label = "Do Not Disturb", command = "qs ipc call notifications dnd" },
|
||||
["notifications"] = { label = "Notifications", command = "qs ipc call notifications toggle" },
|
||||
["overview"] = { label = "Overview", command = "qs ipc call overview toggle" },
|
||||
["launcher"] = { label = "Launcher", command = "vicinae toggle" },
|
||||
["clipboard"] = { label = "Clipboard history", command = "qs ipc call clipboard toggle" },
|
||||
["screenshot"] = { label = "Screenshot / record", command = "qs ipc call capture open" },
|
||||
["screenshot-screen"] = { label = "Screenshot: whole screen", command = "qs ipc call capture screenNow" },
|
||||
["screenshot-window"] = { label = "Screenshot: window", command = "qs ipc call capture windowNow" },
|
||||
["screen-intelligence"]= { label = "Screen Intelligence", command = "qs ipc call screen-intelligence open" },
|
||||
["color-picker"] = { label = "Color picker", command = "hyprpicker -a -f hex" },
|
||||
["quick-settings"] = { label = "Quick settings", command = "qs ipc call quicksettings toggle" },
|
||||
["settings"] = { label = "Settings", command = "qs ipc call settings toggle" },
|
||||
["cheatsheet"] = { label = "Keyboard shortcuts", command = "qs ipc call cheatsheet toggle" },
|
||||
["focus-session"] = { label = "Focus session", command = "qs ipc call focus reveal" },
|
||||
["caffeine"] = { label = "Keep awake", command = "qs ipc call caffeine toggle" },
|
||||
["night-light"] = { label = "Night Light", command = "qs ipc call night-light toggle" },
|
||||
["activity"] = { label = "Activity", command = "qs ipc call activity toggle" },
|
||||
["power-menu"] = { label = "Power menu", command = "qs ipc call powermenu toggle" },
|
||||
["lock"] = { label = "Lock", command = "loginctl lock-session" },
|
||||
}
|
||||
|
||||
-- ── Window verbs ────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Builders rather than dispatchers, so nothing is constructed for a verb that
|
||||
-- is never chosen, and so a gesture builds its dispatcher when the fingers
|
||||
-- move rather than holding one from config time.
|
||||
--
|
||||
-- `workspace:N` is not in the table: it is ten entries that differ by a number,
|
||||
-- and the number is validated as 1..10 in `window_action` below.
|
||||
local WINDOW = {
|
||||
["float-toggle"] = { label = "Toggle float", build = function() return hl.dsp.window.float({ action = "toggle" }) end },
|
||||
["fullscreen"] = { label = "Fullscreen", build = function() return hl.dsp.window.fullscreen({ mode = "fullscreen" }) end },
|
||||
["pin"] = { label = "Pin window", build = function() return hl.dsp.window.pin({ action = "toggle" }) end },
|
||||
}
|
||||
|
||||
-- Published so a contract can read the vocabulary without parsing this file,
|
||||
-- and so the ten workspace verbs have one definition rather than two.
|
||||
actions.shell_verbs = SHELL
|
||||
actions.window_verbs = WINDOW
|
||||
actions.workspace_min = 1
|
||||
actions.workspace_max = 10
|
||||
|
||||
-- ── Targets ─────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Letters, digits, and the four punctuation marks a desktop id actually uses.
|
||||
-- Deliberately excludes every shell metacharacter, quote, slash and space, so
|
||||
-- an id that passes cannot change the meaning of a command line even before it
|
||||
-- is quoted -- the quoting below is the second lock on the same door.
|
||||
local APP_TARGET = "^[A-Za-z0-9@._%-]+$"
|
||||
|
||||
local function valid_app_target(target)
|
||||
return type(target) == "string"
|
||||
and #target >= 1 and #target <= 128
|
||||
and target:match(APP_TARGET) ~= nil
|
||||
end
|
||||
|
||||
-- Single-quoted for the shell, with the one escape single quotes need. Same
|
||||
-- function keybinds.lua uses for the go-to patterns, and used here for the
|
||||
-- same reason: the value reaches the command as one argument.
|
||||
local function shell_quote(value)
|
||||
return "'" .. value:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
-- Google RE2 metacharacters, escaped so the id matches itself literally.
|
||||
-- panama-launch takes a regular expression, and an unescaped "org.gnome.Files"
|
||||
-- would also match "orgxgnomexFiles".
|
||||
local function escape_regex(value)
|
||||
return (value:gsub("[%^%$%(%)%%%.%[%]%*%+%-%?%{%}%|\\]", "\\%0"))
|
||||
end
|
||||
|
||||
-- ── Resolution ──────────────────────────────────────────────────────────────
|
||||
|
||||
local launcher_bin = "$HOME/.local/share/Panama/bin/panama-launch"
|
||||
|
||||
-- The launch-or-focus path the shipped application keys use: raise the window
|
||||
-- if it is already open, start it if it is not. The id is the class pattern
|
||||
-- (anchored, escaped) and the thing to start; on Wayland an application's
|
||||
-- desktop id and its window class are the same string often enough that this
|
||||
-- is the right first guess, and the wrong guess costs a second window rather
|
||||
-- than an error.
|
||||
--
|
||||
-- gtk-launch activates a desktop entry by id, which is what the applications
|
||||
-- catalog in Settings offers -- a desktop id is not a binary and cannot be
|
||||
-- exec'd directly.
|
||||
local function app_action(target)
|
||||
if not valid_app_target(target) then
|
||||
return nil
|
||||
end
|
||||
local launch_command = table.concat({
|
||||
launcher_bin,
|
||||
"--class", shell_quote("^" .. escape_regex(target) .. "$"),
|
||||
"--", "gtk-launch", shell_quote(target),
|
||||
}, " ")
|
||||
return function() return hl.dsp.exec_cmd(launch_command) end
|
||||
end
|
||||
|
||||
local function shell_action(target)
|
||||
if type(target) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local verb = SHELL[target]
|
||||
if verb == nil then
|
||||
return nil
|
||||
end
|
||||
return function() return hl.dsp.exec_cmd(verb.command) end
|
||||
end
|
||||
|
||||
local function window_action(target)
|
||||
if type(target) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local verb = WINDOW[target]
|
||||
if verb ~= nil then
|
||||
return verb.build
|
||||
end
|
||||
|
||||
local index = target:match("^workspace:(%d+)$")
|
||||
if index == nil then
|
||||
return nil
|
||||
end
|
||||
local number = tonumber(index)
|
||||
if number == nil or number < actions.workspace_min or number > actions.workspace_max then
|
||||
return nil
|
||||
end
|
||||
return function() return hl.dsp.focus({ workspace = number }) end
|
||||
end
|
||||
|
||||
local KINDS = {
|
||||
app = app_action,
|
||||
shell = shell_action,
|
||||
window = window_action,
|
||||
}
|
||||
|
||||
-- The builder for one stored entry, or nil when the entry is anything this
|
||||
-- file does not recognise. Everything above funnels through here, so there is
|
||||
-- exactly one place where a stored value becomes an action.
|
||||
local function builder(entry)
|
||||
if type(entry) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
local resolve = KINDS[entry.kind]
|
||||
if resolve == nil then
|
||||
return nil
|
||||
end
|
||||
return resolve(entry.target)
|
||||
end
|
||||
|
||||
-- For hl.bind: the dispatcher itself, or nil.
|
||||
function actions.dispatcher(entry)
|
||||
local build = builder(entry)
|
||||
if build == nil then
|
||||
return nil
|
||||
end
|
||||
return build()
|
||||
end
|
||||
|
||||
-- For hl.gesture: a function, which is what a gesture action has to be when it
|
||||
-- is not one of Hyprland's own built-in names ("workspace" and friends).
|
||||
function actions.gesture(entry)
|
||||
local build = builder(entry)
|
||||
if build == nil then
|
||||
return nil
|
||||
end
|
||||
return function() hl.dispatch(build()) end
|
||||
end
|
||||
|
||||
-- True when an entry resolves to something. Cheap enough to call twice; used
|
||||
-- where the caller wants to check before it commits to emitting anything.
|
||||
function actions.valid(entry)
|
||||
return builder(entry) ~= nil
|
||||
end
|
||||
|
||||
return actions
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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".
|
||||
--
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Grayscale.
|
||||
//
|
||||
// Panama's accessibility color filters. Selected by the `colorFilter`
|
||||
// preference; hypr/looks.lua maps the enum to this path at config time and
|
||||
// services/SystemSettings.qml does the same live.
|
||||
//
|
||||
// Hyprland runs one fragment shader over the finished frame, so this covers
|
||||
// every window, the shell, video and the cursor alike.
|
||||
//
|
||||
// Rec. 709 luminance weights -- the same ones an SVG <feColorMatrix
|
||||
// type="saturate" values="0"> uses. A flat average would make reds and blues
|
||||
// far too bright and greens far too dark, because the eye does not weigh the
|
||||
// channels equally.
|
||||
|
||||
#version 300 es
|
||||
|
||||
precision mediump float;
|
||||
in vec2 v_texcoord;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
uniform sampler2D tex;
|
||||
|
||||
void main() {
|
||||
vec4 pixColor = texture(tex, v_texcoord);
|
||||
|
||||
float luminance = dot(pixColor.rgb, vec3(0.2126, 0.7152, 0.0722));
|
||||
|
||||
fragColor = vec4(vec3(luminance), pixColor.a);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Tritanopia -- blue-blind.
|
||||
//
|
||||
// Panama's accessibility color filters. Selected by the `colorFilter`
|
||||
// preference; hypr/looks.lua maps the enum to this path at config time and
|
||||
// services/SystemSettings.qml does the same live.
|
||||
//
|
||||
// Same family of matrices as protanopia.frag, weighted for the missing blue
|
||||
// cone. See that file for why this is a correction rather than a simulation,
|
||||
// and for the column-major note.
|
||||
|
||||
#version 300 es
|
||||
|
||||
precision mediump float;
|
||||
in vec2 v_texcoord;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
uniform sampler2D tex;
|
||||
|
||||
const mat3 tritanopia = mat3(
|
||||
0.950, 0.000, 0.000,
|
||||
0.050, 0.433, 0.475,
|
||||
0.000, 0.567, 0.525
|
||||
);
|
||||
|
||||
void main() {
|
||||
vec4 pixColor = texture(tex, v_texcoord);
|
||||
|
||||
fragColor = vec4(clamp(tritanopia * pixColor.rgb, 0.0, 1.0), pixColor.a);
|
||||
}
|
||||
@@ -1059,6 +1059,23 @@ Singleton {
|
||||
detail: "How much darker unfocused windows are",
|
||||
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 ───────────────────────────────────────────
|
||||
// { "<output>": { mode, scale, transform, x, y, primary, vrrMode,
|
||||
// colorProfile, bitdepth, sdrBrightness, sdrSaturation, mirrorOf } },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
// The two-step flow behind "+ Add shortcut".
|
||||
//
|
||||
// Step one records the chord, because a shortcut whose keys are already taken
|
||||
// is not worth choosing an action for -- the conflict is reported here, before
|
||||
// anything is stored. Step two picks what it does, from three fixed
|
||||
// vocabularies: an installed application, one of the shell's own actions, or
|
||||
// one of the compositor's window verbs.
|
||||
//
|
||||
// Nothing typed here becomes a command. The editor reports an enum kind and a
|
||||
// target that Keybinds.describeAction() already recognises; hypr/actions.lua
|
||||
// resolves that to something runnable through whitelist tables of its own. That
|
||||
// is the whole reason there is no "run this command" option: settings.json has
|
||||
// to stay a file it is safe to hand somebody.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.clipboard
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
// { chord, kind, target, label }
|
||||
signal committed(string chord, string kind, string target, string label)
|
||||
signal canceled
|
||||
|
||||
property string chord: ""
|
||||
property string kind: "app"
|
||||
property string target: ""
|
||||
property string targetLabel: ""
|
||||
|
||||
// The action already holding a chord somebody just pressed, and the chord
|
||||
// itself, so the refusal can name both.
|
||||
property string conflict: ""
|
||||
property string conflictChord: ""
|
||||
|
||||
readonly property bool capturing: root.chord === ""
|
||||
readonly property bool complete: root.chord !== "" && root.target !== "" && root.targetLabel !== ""
|
||||
|
||||
function reset(): void {
|
||||
root.chord = "";
|
||||
root.kind = "app";
|
||||
root.target = "";
|
||||
root.targetLabel = "";
|
||||
root.conflict = "";
|
||||
root.conflictChord = "";
|
||||
appSearch.text = "";
|
||||
}
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 0
|
||||
|
||||
SectionLabel {
|
||||
text: root.capturing ? "New shortcut · press the keys" : "New shortcut · pick what it does"
|
||||
count: root.chord === "" ? "" : root.chord
|
||||
}
|
||||
|
||||
// ── Step one: the chord ─────────────────────────────────────────────────
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: root.capturing ? 56 : 0
|
||||
visible: root.capturing
|
||||
clip: true
|
||||
|
||||
ShortcutCapture {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 300
|
||||
height: 32
|
||||
focus: root.visible && root.capturing
|
||||
message: root.conflict === ""
|
||||
? ""
|
||||
: root.conflictChord + " is already " + root.conflict
|
||||
|
||||
onCaptured: chord => {
|
||||
const taken = Keybinds.boundTo(chord, "");
|
||||
if (taken !== "") {
|
||||
root.conflict = taken;
|
||||
root.conflictChord = chord;
|
||||
return;
|
||||
}
|
||||
if (Keybinds.isCustomChord(chord)) {
|
||||
root.conflict = "one of your own shortcuts";
|
||||
root.conflictChord = chord;
|
||||
return;
|
||||
}
|
||||
root.conflict = "";
|
||||
root.chord = chord;
|
||||
}
|
||||
|
||||
onCanceled: root.canceled()
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 312
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "A modifier is required. Esc cancels. A chord another action holds is reported, never taken."
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step two: the action ────────────────────────────────────────────────
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: !root.capturing
|
||||
spacing: 0
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
label: "What it does"
|
||||
detail: "Three kinds, and only three: Panama resolves the name you pick when you press the keys, so no shortcut ever stores a command."
|
||||
current: root.kind
|
||||
options: [
|
||||
{ value: "app", label: "Launch an application" },
|
||||
{ value: "shell", label: "Shell action" },
|
||||
{ value: "window", label: "Window & workspace" }
|
||||
]
|
||||
onPicked: value => {
|
||||
root.kind = String(value);
|
||||
root.target = "";
|
||||
root.targetLabel = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Applications are searched rather than listed: a normal machine has
|
||||
// several hundred desktop entries, and a flow of pills for all of them
|
||||
// is a page nobody can read.
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.kind === "app"
|
||||
spacing: 0
|
||||
|
||||
SearchField {
|
||||
id: appSearch
|
||||
width: parent.width
|
||||
placeholder: "Search installed applications"
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.appMatches
|
||||
|
||||
SettingRow {
|
||||
id: candidate
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: String(candidate.modelData.name ?? "")
|
||||
detail: String(candidate.modelData.id ?? "")
|
||||
value: candidate.modelData.id === root.target ? "Chosen" : ""
|
||||
controlWidth: 96
|
||||
divider: candidate.index < root.appMatches.length - 1
|
||||
activatable: true
|
||||
|
||||
onActivated: {
|
||||
root.target = String(candidate.modelData.id ?? "");
|
||||
root.targetLabel = "Launch " + String(candidate.modelData.name ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.appMatches.length === 0
|
||||
text: appSearch.text.trim() === ""
|
||||
? "Type to find an application."
|
||||
: "Nothing installed matches that."
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
topPadding: 10
|
||||
bottomPadding: 10
|
||||
}
|
||||
}
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
visible: root.kind === "shell"
|
||||
label: "Shell action"
|
||||
detail: "The shell's own surfaces, each one something Panama already answers over IPC."
|
||||
current: root.target
|
||||
options: root.shellOptions
|
||||
divider: false
|
||||
onPicked: value => {
|
||||
root.target = String(value);
|
||||
root.targetLabel = Keybinds.shellActionLabel(root.target);
|
||||
}
|
||||
}
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
visible: root.kind === "window"
|
||||
label: "Window & workspace"
|
||||
detail: "Compositor verbs: the first three act on the focused window, the rest go to a workspace."
|
||||
current: root.target
|
||||
options: root.windowOptions
|
||||
divider: false
|
||||
onPicked: value => {
|
||||
root.target = String(value);
|
||||
root.targetLabel = Keybinds.windowActionLabel(root.target);
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 50
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
tone: "accent"
|
||||
text: "Add it"
|
||||
enabled: root.complete && !Keybinds.reloading
|
||||
onClicked: root.committed(root.chord, root.kind, root.target, root.targetLabel)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Cancel"
|
||||
onClicked: root.canceled()
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - 200
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: root.complete
|
||||
? root.targetLabel + " · saves, then the compositor reloads — same as rebinding"
|
||||
: "Pick what the shortcut does."
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── The vocabularies ────────────────────────────────────────────────────
|
||||
// Shell and window targets come from Keybinds, which is the single QML
|
||||
// authority on what hypr/actions.lua will resolve. Listing them here would
|
||||
// be a second opinion.
|
||||
|
||||
readonly property var shellOptions:
|
||||
Keybinds.shellActions.map(action => ({ value: action.target, label: action.label }))
|
||||
|
||||
readonly property var windowOptions:
|
||||
Keybinds.windowActions.map(action => ({ value: action.target, label: action.label }))
|
||||
|
||||
readonly property var appMatches: {
|
||||
const needle = appSearch.text.trim().toLowerCase();
|
||||
if (needle === "")
|
||||
return [];
|
||||
const out = [];
|
||||
for (const entry of DesktopEntries.applications.values) {
|
||||
if (entry.noDisplay)
|
||||
continue;
|
||||
if (String(entry.name).toLowerCase().indexOf(needle) >= 0)
|
||||
out.push(entry);
|
||||
if (out.length >= 8)
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// One shortcut the user invented, in the pinned Custom group.
|
||||
//
|
||||
// ShortcutRow renders a bind the compositor reported; this renders a stored
|
||||
// `customBinds` entry, which is a different thing and deliberately not the same
|
||||
// component. A custom bind has an action to describe (the compositor reports
|
||||
// every Lua bind as "__lua" plus a bytecode offset, so the description has to
|
||||
// come from the entry), it is removable, and it is never "overridden" -- a
|
||||
// rebind rewrites the entry in place rather than adding to the override map.
|
||||
//
|
||||
// The row reports and never decides: conflicts, the write and the reload all
|
||||
// belong to Keybinds, and the page owns the one capture at a time.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
required property var entry
|
||||
property bool capturing: false
|
||||
// Shown inside the capture field -- the page puts a refused chord here.
|
||||
property string message: ""
|
||||
|
||||
signal rebindRequested
|
||||
signal removeRequested
|
||||
signal captured(string chord)
|
||||
signal canceled
|
||||
|
||||
readonly property string chord: String(root.entry?.chord ?? "")
|
||||
readonly property string action: Keybinds.describeAction(root.entry)
|
||||
|
||||
// A stored entry is an intention; the keymap is the fact. hypr/keybinds.lua
|
||||
// skips an entry whose action does not resolve or whose chord a shipped
|
||||
// bind already holds, and a shortcut that quietly does nothing is exactly
|
||||
// what a settings page must not draw as working.
|
||||
readonly property bool live: root.action !== "" && Keybinds.customBindApplied(root.entry)
|
||||
|
||||
label: String(root.entry?.label ?? "")
|
||||
detail: root.action === ""
|
||||
? "This shortcut names an action Panama no longer has — remove it"
|
||||
: (root.live
|
||||
? root.action
|
||||
: root.action + " · not answering yet — the compositor reloads on save")
|
||||
labelColor: root.action === "" ? Theme.danger : Theme.fg
|
||||
controlWidth: root.capturing ? 250 : (removeConfirm.armed ? 330 : 250)
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !root.capturing
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Rebind"
|
||||
enabled: !Keybinds.reloading
|
||||
onClicked: root.rebindRequested()
|
||||
}
|
||||
|
||||
ConfirmAction {
|
||||
id: removeConfirm
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
actionId: "custom-bind-remove:" + root.chord
|
||||
armText: "Remove…"
|
||||
confirmText: "Remove it"
|
||||
enabled: !Keybinds.reloading
|
||||
onConfirmed: root.removeRequested()
|
||||
}
|
||||
|
||||
KeycapChord {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
chord: root.chord
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 240
|
||||
height: 30
|
||||
active: root.capturing
|
||||
// Focus has to travel through the Loader for the capture inside it to
|
||||
// ever see a key press.
|
||||
focus: root.capturing
|
||||
sourceComponent: ShortcutCapture {
|
||||
focus: true
|
||||
message: root.message
|
||||
onCaptured: chord => root.captured(chord)
|
||||
onCanceled: root.canceled()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,51 +426,28 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
// The one thing left that GNOME genuinely owns.
|
||||
//
|
||||
// This card used to be headed "Fedora system settings" and led with an
|
||||
// umbrella button reading "Open GNOME Settings", which landed on the System
|
||||
// panel. That button was the last door of its kind, and by the end it was
|
||||
// pointing at a house Panama had bought: Users, Sharing, Printers, Online
|
||||
// Accounts, Privacy, Region, Colour and the whole of Connections are pages
|
||||
// here now. A generic front door to a settings app you no longer need is
|
||||
// not a boundary, it is a habit -- so it is gone, and gnome-handoff-contract
|
||||
// holds the door shut by naming `system` in its OWNED map.
|
||||
//
|
||||
// Screen time is the exception, and it is a real one: GNOME's wellbeing
|
||||
// panel does something Panama does not, and that button genuinely works.
|
||||
// Colour profiles used to sit here too, with a detail line explaining that
|
||||
// pressing the button changed nothing, because the colord daemon that
|
||||
// applies an ICC profile is not running under Hyprland. A handoff that
|
||||
// documents its own uselessness is a dead button with an apology attached,
|
||||
// so that one went first.
|
||||
SettingsCard {
|
||||
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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
// Writing a window rule as behaviors instead of as a regex.
|
||||
//
|
||||
// Two halves, in the order somebody actually thinks in: which application, then
|
||||
// what it should do. The application half offers the windows open right now
|
||||
// first -- that is the list where the class is a fact rather than a guess,
|
||||
// because Hyprland is reporting it -- and falls back to a search over installed
|
||||
// applications, whose class comes from their own StartupWMClass.
|
||||
//
|
||||
// The behavior half is ticks. Nothing here composes a rule string: the draft is
|
||||
// booleans and two bounded numbers, WindowRules validates it, and hypr/rules.lua
|
||||
// escapes the class before the compositor's matcher sees it.
|
||||
//
|
||||
// Panama's own surfaces are not offerable and not typeable: a rule that floated
|
||||
// a Quickshell layer would break the desktop from inside Settings.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.clipboard
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
// Set to an existing rule to edit it; null to add a new one.
|
||||
property var editing: null
|
||||
|
||||
signal committed(var rule)
|
||||
signal canceled
|
||||
|
||||
property string windowClass: ""
|
||||
property string appLabel: ""
|
||||
|
||||
property bool floats: false
|
||||
property bool center: false
|
||||
property bool noAnim: false
|
||||
property bool game: false
|
||||
property bool noDim: false
|
||||
property bool pin: false
|
||||
|
||||
property bool sizeOn: false
|
||||
property int sizeWidth: 900
|
||||
property int sizeHeight: 600
|
||||
|
||||
property bool workspaceOn: false
|
||||
property int workspace: 1
|
||||
|
||||
readonly property bool chosen: root.windowClass !== ""
|
||||
|
||||
function load(rule: var): void {
|
||||
root.editing = rule ?? null;
|
||||
root.windowClass = String(rule?.class ?? "");
|
||||
root.appLabel = String(rule?.label ?? rule?.class ?? "");
|
||||
root.floats = rule?.float === true;
|
||||
root.center = rule?.center === true;
|
||||
root.noAnim = rule?.noAnim === true;
|
||||
root.game = rule?.game === true;
|
||||
root.noDim = rule?.noDim === true;
|
||||
root.pin = rule?.pin === true;
|
||||
root.sizeOn = Array.isArray(rule?.size) && rule.size.length === 2;
|
||||
root.sizeWidth = root.sizeOn ? rule.size[0] : 900;
|
||||
root.sizeHeight = root.sizeOn ? rule.size[1] : 600;
|
||||
root.workspaceOn = Number.isFinite(rule?.workspace);
|
||||
root.workspace = root.workspaceOn ? rule.workspace : 1;
|
||||
appSearch.text = "";
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
root.load(null);
|
||||
}
|
||||
|
||||
function draft(): var {
|
||||
return {
|
||||
"class": root.windowClass,
|
||||
label: root.appLabel === "" ? root.windowClass : root.appLabel,
|
||||
float: root.floats,
|
||||
center: root.center,
|
||||
size: root.sizeOn ? [root.sizeWidth, root.sizeHeight] : null,
|
||||
workspace: root.workspaceOn ? root.workspace : null,
|
||||
noAnim: root.noAnim,
|
||||
game: root.game,
|
||||
noDim: root.noDim,
|
||||
pin: root.pin
|
||||
};
|
||||
}
|
||||
|
||||
readonly property bool complete: root.chosen
|
||||
&& WindowRules.validRule(root.draft())
|
||||
&& WindowRules.hasBehavior(root.draft())
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 0
|
||||
|
||||
SectionLabel {
|
||||
text: root.editing ? "Edit rule" : "New rule"
|
||||
count: root.chosen ? root.windowClass : "pick an application"
|
||||
}
|
||||
|
||||
// ── Which application ───────────────────────────────────────────────────
|
||||
// Hidden while editing: the class is the rule's identity, and letting it be
|
||||
// changed in place would be a second rule wearing the first one's history.
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.editing === null
|
||||
spacing: 0
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
visible: root.openWindows.length > 0
|
||||
label: "Open right now"
|
||||
detail: "The class comes from the compositor, so these are exact."
|
||||
current: root.windowClass
|
||||
options: root.openWindows
|
||||
divider: false
|
||||
onPicked: value => {
|
||||
root.windowClass = String(value);
|
||||
const found = root.openWindows.find(option => option.value === root.windowClass);
|
||||
root.appLabel = found ? String(found.label) : root.windowClass;
|
||||
}
|
||||
}
|
||||
|
||||
SearchField {
|
||||
id: appSearch
|
||||
width: parent.width
|
||||
placeholder: "…or search installed applications"
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.appMatches
|
||||
|
||||
SettingRow {
|
||||
id: candidate
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: String(candidate.modelData.label ?? "")
|
||||
detail: String(candidate.modelData.value ?? "")
|
||||
value: candidate.modelData.value === root.windowClass ? "Chosen" : ""
|
||||
controlWidth: 96
|
||||
divider: candidate.index < root.appMatches.length - 1
|
||||
activatable: true
|
||||
|
||||
onActivated: {
|
||||
root.windowClass = String(candidate.modelData.value ?? "");
|
||||
root.appLabel = String(candidate.modelData.label ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.appMatches.length === 0 && appSearch.text.trim() !== ""
|
||||
text: "Nothing installed matches that."
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
topPadding: 10
|
||||
bottomPadding: 10
|
||||
}
|
||||
}
|
||||
|
||||
// ── What it should do ───────────────────────────────────────────────────
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.chosen
|
||||
spacing: 0
|
||||
|
||||
SectionLabel {
|
||||
text: "Behavior"
|
||||
count: root.appLabel
|
||||
}
|
||||
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: 7
|
||||
bottomPadding: 12
|
||||
|
||||
SettingsChip {
|
||||
text: "Float"
|
||||
active: root.floats
|
||||
onClicked: root.floats = !root.floats
|
||||
}
|
||||
SettingsChip {
|
||||
text: "Center when opened"
|
||||
active: root.center
|
||||
onClicked: root.center = !root.center
|
||||
}
|
||||
SettingsChip {
|
||||
text: "Fixed size"
|
||||
active: root.sizeOn
|
||||
onClicked: root.sizeOn = !root.sizeOn
|
||||
}
|
||||
SettingsChip {
|
||||
text: "Open on a workspace"
|
||||
active: root.workspaceOn
|
||||
onClicked: root.workspaceOn = !root.workspaceOn
|
||||
}
|
||||
SettingsChip {
|
||||
text: "No animations"
|
||||
active: root.noAnim
|
||||
onClicked: root.noAnim = !root.noAnim
|
||||
}
|
||||
SettingsChip {
|
||||
text: "Treat as a game"
|
||||
active: root.game
|
||||
onClicked: root.game = !root.game
|
||||
}
|
||||
SettingsChip {
|
||||
text: "Never dim"
|
||||
active: root.noDim
|
||||
onClicked: root.noDim = !root.noDim
|
||||
}
|
||||
SettingsChip {
|
||||
text: "Pin on every workspace"
|
||||
active: root.pin
|
||||
onClicked: root.pin = !root.pin
|
||||
}
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
visible: root.sizeOn
|
||||
label: "Width"
|
||||
detail: "Pixels, between " + WindowRules.minSize + " and " + WindowRules.maxSize
|
||||
text: String(root.sizeWidth)
|
||||
onAccepted: value => {
|
||||
const parsed = parseInt(value, 10);
|
||||
if (Number.isFinite(parsed))
|
||||
root.sizeWidth = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
visible: root.sizeOn
|
||||
label: "Height"
|
||||
text: String(root.sizeHeight)
|
||||
onAccepted: value => {
|
||||
const parsed = parseInt(value, 10);
|
||||
if (Number.isFinite(parsed))
|
||||
root.sizeHeight = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
visible: root.workspaceOn
|
||||
label: "Workspace"
|
||||
detail: "The ten the keymap reaches."
|
||||
current: root.workspace
|
||||
options: root.workspaceOptions
|
||||
divider: false
|
||||
onPicked: value => root.workspace = Number(value)
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 50
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
tone: "accent"
|
||||
text: root.editing ? "Save the rule" : "Add the rule"
|
||||
enabled: root.complete && !WindowRules.reloading
|
||||
onClicked: root.committed(root.draft())
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Cancel"
|
||||
onClicked: root.canceled()
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - 240
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: root.complete
|
||||
? WindowRules.ruleLineFor(root.draft())
|
||||
: "Tick at least one behavior — a rule that does nothing is a row you will wonder about later."
|
||||
color: Theme.fgMuted
|
||||
font.family: root.complete ? Theme.fontMono : Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── The candidate lists ─────────────────────────────────────────────────
|
||||
|
||||
readonly property var workspaceOptions: {
|
||||
const out = [];
|
||||
for (let n = 1; n <= WindowRules.maxWorkspace; n++)
|
||||
out.push({ value: n, label: String(n) });
|
||||
return out;
|
||||
}
|
||||
|
||||
// Windows open now, one entry per class. `wayland.appId` is the class
|
||||
// Hyprland matches rules against; there is no separate class property.
|
||||
readonly property var openWindows: {
|
||||
const seen = {};
|
||||
const out = [];
|
||||
for (const toplevel of (Hyprland.toplevels?.values ?? [])) {
|
||||
const appId = String(toplevel?.wayland?.appId ?? "");
|
||||
if (appId === "" || seen[appId])
|
||||
continue;
|
||||
if (!WindowRules.validClass(appId))
|
||||
continue;
|
||||
if (WindowRules.indexOfClass(appId) >= 0)
|
||||
continue;
|
||||
seen[appId] = true;
|
||||
const entry = DesktopEntries.heuristicLookup(appId);
|
||||
out.push({ value: appId, label: String(entry?.name ?? appId) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
readonly property var appMatches: {
|
||||
const needle = appSearch.text.trim().toLowerCase();
|
||||
if (needle === "")
|
||||
return [];
|
||||
const out = [];
|
||||
for (const entry of DesktopEntries.applications.values) {
|
||||
if (entry.noDisplay)
|
||||
continue;
|
||||
if (String(entry.name).toLowerCase().indexOf(needle) < 0)
|
||||
continue;
|
||||
const windowClass = String(entry.startupClass ?? "") !== ""
|
||||
? String(entry.startupClass)
|
||||
: String(entry.id ?? "").replace(/\.desktop$/, "");
|
||||
if (!WindowRules.validClass(windowClass))
|
||||
continue;
|
||||
if (WindowRules.indexOfClass(windowClass) >= 0)
|
||||
continue;
|
||||
out.push({ value: windowClass, label: String(entry.name) });
|
||||
if (out.length >= 8)
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// One per-application window rule.
|
||||
//
|
||||
// Three lines rather than SettingRow's two, and that third line is the point:
|
||||
// the rule was chosen as behaviors -- float, center, workspace 4 -- and it is
|
||||
// written to the compositor as a rule. Showing only the friendly sentence makes
|
||||
// the card a black box; showing only the rule makes it a config file with a
|
||||
// nicer font. Both, with the compositor line in mono underneath, is how
|
||||
// somebody learns what the ticks actually did.
|
||||
//
|
||||
// The rule text and the sentence both come from WindowRules, which is the one
|
||||
// place that knows what hypr/rules.lua emits.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var rule
|
||||
property bool divider: true
|
||||
|
||||
signal editRequested
|
||||
signal removeRequested
|
||||
|
||||
readonly property string windowClass: String(root.rule?.class ?? "")
|
||||
readonly property int openNow: WindowRules.matchesOpen(root.windowClass)
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: Math.max(64, copy.implicitHeight + 22)
|
||||
|
||||
Column {
|
||||
id: copy
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: trailing.left
|
||||
anchors.rightMargin: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: String(root.rule?.label ?? root.windowClass)
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: WindowRules.summaryFor(root.rule)
|
||||
+ (root.openNow === 0
|
||||
? ""
|
||||
: (root.openNow === 1
|
||||
? " · 1 window open now"
|
||||
: " · " + root.openNow + " windows open now"))
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: WindowRules.ruleLineFor(root.rule)
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: trailing
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: removeConfirm.armed ? 250 : 170
|
||||
height: 32
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Edit"
|
||||
enabled: !WindowRules.reloading
|
||||
onClicked: root.editRequested()
|
||||
}
|
||||
|
||||
ConfirmAction {
|
||||
id: removeConfirm
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
actionId: "window-rule-remove:" + root.windowClass
|
||||
armText: "Remove…"
|
||||
confirmText: "Remove it"
|
||||
enabled: !WindowRules.reloading
|
||||
onConfirmed: root.removeRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: copy.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 1
|
||||
visible: root.divider
|
||||
color: Theme.alpha(Theme.fg, 0.065)
|
||||
}
|
||||
}
|
||||
@@ -139,3 +139,7 @@ IdleTimeline 1.0 IdleTimeline.qml
|
||||
PowerProfileTiles 1.0 PowerProfileTiles.qml
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
pragma Singleton
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Per-application window rules: how a named application behaves when it opens.
|
||||
//
|
||||
// Modelled on Workspaces.qml, and for the same reason. A window rule is read by
|
||||
// Hyprland at config time and cannot be taken back at runtime, so the only way
|
||||
// to change the set is `hyprctl reload`, which re-runs the config and lets
|
||||
// hypr/rules.lua emit exactly the rules the preference asks for.
|
||||
//
|
||||
// That makes this service two things: the reload, and an honest answer to "has
|
||||
// it taken effect yet". The second is the harder half here, because Hyprland
|
||||
// publishes no window-rule listing -- `hyprctl` offers `workspacerules` and
|
||||
// nothing equivalent for these. So `applied` is not a read-back of the rules
|
||||
// themselves: it is the exit status of the reload that last ran, against the
|
||||
// rule set that was stored when it ran. The page says applied-on-reload in
|
||||
// those words rather than dressing that up as a confirmation it is not.
|
||||
//
|
||||
// What CAN be read back is the effect: `matchesOpen()` counts the windows open
|
||||
// right now whose class a rule names, which is the difference between "we wrote
|
||||
// a rule for org.gnome.Calculator" and "the calculator on your screen is the
|
||||
// thing this rule is about".
|
||||
//
|
||||
// Nothing stored here is a command. A rule is a literal class string plus
|
||||
// booleans and two bounded numbers; hypr/rules.lua regex-escapes the class
|
||||
// before Hyprland's matcher sees it and skips any entry that fails validation,
|
||||
// so an entry that arrived by hand-editing settings.json is inert rather than
|
||||
// dangerous.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Hyprland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// [{ class, label, float, center, size, workspace, noAnim, game, noDim, pin }]
|
||||
readonly property var rules: {
|
||||
const stored = DesktopPreferences.get("windowRules");
|
||||
return Array.isArray(stored) ? stored : [];
|
||||
}
|
||||
|
||||
property bool reloading: false
|
||||
property string lastError: ""
|
||||
|
||||
// The rule set the last successful reload actually carried. Compared by
|
||||
// value because the array is rewritten wholesale on every edit.
|
||||
property string appliedSignature: ""
|
||||
|
||||
readonly property string signature: JSON.stringify(root.rules)
|
||||
|
||||
// Has the compositor been through a reload since the rules last changed?
|
||||
// An empty rule set needs no reload to be true of the compositor, so it is
|
||||
// applied by definition.
|
||||
readonly property bool applied: root.rules.length === 0
|
||||
|| root.appliedSignature === root.signature
|
||||
|
||||
// ── Validation ──────────────────────────────────────────────────────────
|
||||
// The same rules hypr/rules.lua applies, so the page can refuse an entry
|
||||
// rather than saving one the compositor will silently drop.
|
||||
|
||||
readonly property int maxClassLength: 128
|
||||
readonly property int minSize: 50
|
||||
readonly property int maxSize: 10000
|
||||
readonly property int maxWorkspace: 10
|
||||
|
||||
// The shell's own surfaces are not addressable. A rule that floated or
|
||||
// moved a Quickshell layer would break the desktop from inside Settings,
|
||||
// and there is no legitimate reason to write one.
|
||||
readonly property var shellClassPattern: /^(quickshell|qs-)/i
|
||||
|
||||
function isShellClass(windowClass: string): bool {
|
||||
return root.shellClassPattern.test(String(windowClass).trim());
|
||||
}
|
||||
|
||||
// A class is matched literally, so anything printable is allowed -- but a
|
||||
// control character or a newline could not have come from a real window and
|
||||
// would end up inside a compositor rule.
|
||||
function validClass(windowClass: string): bool {
|
||||
const text = String(windowClass ?? "").trim();
|
||||
if (text === "" || text.length > root.maxClassLength)
|
||||
return false;
|
||||
if (/[\x00-\x1f\x7f]/.test(text))
|
||||
return false;
|
||||
return !root.isShellClass(text);
|
||||
}
|
||||
|
||||
function validSize(size: var): bool {
|
||||
if (size === null || size === undefined)
|
||||
return true;
|
||||
if (!Array.isArray(size) || size.length !== 2)
|
||||
return false;
|
||||
return size.every(value => Number.isFinite(value)
|
||||
&& value >= root.minSize && value <= root.maxSize);
|
||||
}
|
||||
|
||||
function validWorkspace(workspace: var): bool {
|
||||
if (workspace === null || workspace === undefined)
|
||||
return true;
|
||||
return Number.isFinite(workspace) && workspace >= 1 && workspace <= root.maxWorkspace;
|
||||
}
|
||||
|
||||
function validRule(rule: var): bool {
|
||||
if (!rule || typeof rule !== "object")
|
||||
return false;
|
||||
return root.validClass(rule.class)
|
||||
&& root.validSize(rule.size ?? null)
|
||||
&& root.validWorkspace(rule.workspace ?? null);
|
||||
}
|
||||
|
||||
// A rule that ticks nothing is a rule that does nothing, which is a row
|
||||
// somebody would later wonder about.
|
||||
function hasBehavior(rule: var): bool {
|
||||
if (!rule)
|
||||
return false;
|
||||
return rule.float === true || rule.center === true || rule.noAnim === true
|
||||
|| rule.game === true || rule.noDim === true || rule.pin === true
|
||||
|| (Array.isArray(rule.size) && rule.size.length === 2)
|
||||
|| Number.isFinite(rule.workspace);
|
||||
}
|
||||
|
||||
// ── How a rule reads ────────────────────────────────────────────────────
|
||||
// Two spellings, both from here so the page never invents a third: the
|
||||
// plain sentence somebody chose these ticks by, and the compositor line
|
||||
// underneath it for anyone who wants to see what was actually written.
|
||||
|
||||
function summaryFor(rule: var): string {
|
||||
const parts = [];
|
||||
if (rule?.float === true)
|
||||
parts.push("Floats");
|
||||
if (Array.isArray(rule?.size) && rule.size.length === 2)
|
||||
parts.push(`fixed size (${rule.size[0]} × ${rule.size[1]})`);
|
||||
if (rule?.center === true)
|
||||
parts.push("centered");
|
||||
if (Number.isFinite(rule?.workspace))
|
||||
parts.push("opens on workspace " + rule.workspace);
|
||||
if (rule?.game === true)
|
||||
parts.push("treated as a game");
|
||||
if (rule?.noAnim === true)
|
||||
parts.push("no animations");
|
||||
if (rule?.noDim === true)
|
||||
parts.push("never dimmed");
|
||||
if (rule?.pin === true)
|
||||
parts.push("pinned to every workspace");
|
||||
if (parts.length === 0)
|
||||
return "No behavior chosen — this rule does nothing";
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function ruleLineFor(rule: var): string {
|
||||
const verbs = [];
|
||||
if (rule?.float === true)
|
||||
verbs.push("float");
|
||||
if (Array.isArray(rule?.size) && rule.size.length === 2)
|
||||
verbs.push(`size ${rule.size[0]} ${rule.size[1]}`);
|
||||
if (rule?.center === true)
|
||||
verbs.push("center");
|
||||
if (Number.isFinite(rule?.workspace))
|
||||
verbs.push("workspace " + rule.workspace);
|
||||
if (rule?.game === true)
|
||||
verbs.push("content:game");
|
||||
if (rule?.noAnim === true)
|
||||
verbs.push("no_anim");
|
||||
if (rule?.noDim === true)
|
||||
verbs.push("no_dim");
|
||||
if (rule?.pin === true)
|
||||
verbs.push("pin");
|
||||
return `match class ${String(rule?.class ?? "")} → ${verbs.length === 0 ? "nothing" : verbs.join(", ")}`;
|
||||
}
|
||||
|
||||
// ── The effect, read from the live desktop ──────────────────────────────
|
||||
|
||||
function indexOfClass(windowClass: string): int {
|
||||
const wanted = String(windowClass).trim();
|
||||
return root.rules.findIndex(rule => String(rule?.class ?? "").trim() === wanted);
|
||||
}
|
||||
|
||||
// Windows open right now that this rule's class names. Hyprland reports a
|
||||
// Wayland client's app id, which is the class its rules match on.
|
||||
function matchesOpen(windowClass: string): int {
|
||||
const wanted = String(windowClass).trim();
|
||||
if (wanted === "")
|
||||
return 0;
|
||||
let count = 0;
|
||||
for (const toplevel of (Hyprland.toplevels?.values ?? [])) {
|
||||
if (toplevel?.wayland?.appId === wanted)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ── Editing ─────────────────────────────────────────────────────────────
|
||||
// Each write replaces the whole array and then reloads, because that is the
|
||||
// only way a removed rule stops applying.
|
||||
|
||||
function write(next: var, failure: string): bool {
|
||||
if (!DesktopPreferences.set("windowRules", next)) {
|
||||
root.lastError = failure;
|
||||
return false;
|
||||
}
|
||||
root.lastError = "";
|
||||
root.apply();
|
||||
return true;
|
||||
}
|
||||
|
||||
function addRule(rule: var): bool {
|
||||
if (!root.validRule(rule)) {
|
||||
root.lastError = root.isShellClass(rule?.class ?? "")
|
||||
? "Panama's own surfaces cannot be given window rules."
|
||||
: "That rule is not one the compositor would accept.";
|
||||
return false;
|
||||
}
|
||||
if (root.indexOfClass(rule.class) >= 0) {
|
||||
root.lastError = `There is already a rule for ${rule.class}.`;
|
||||
return false;
|
||||
}
|
||||
const next = root.rules.slice();
|
||||
next.push(rule);
|
||||
return root.write(next, "That rule could not be saved.");
|
||||
}
|
||||
|
||||
function updateRule(windowClass: string, rule: var): bool {
|
||||
const at = root.indexOfClass(windowClass);
|
||||
if (at < 0)
|
||||
return false;
|
||||
if (!root.validRule(rule)) {
|
||||
root.lastError = "That rule is not one the compositor would accept.";
|
||||
return false;
|
||||
}
|
||||
const next = root.rules.slice();
|
||||
next[at] = rule;
|
||||
return root.write(next, "That rule could not be saved.");
|
||||
}
|
||||
|
||||
function removeRule(windowClass: string): bool {
|
||||
const at = root.indexOfClass(windowClass);
|
||||
if (at < 0)
|
||||
return false;
|
||||
const next = root.rules.slice();
|
||||
next.splice(at, 1);
|
||||
return root.write(next, "That rule could not be removed.");
|
||||
}
|
||||
|
||||
// ── Applying ────────────────────────────────────────────────────────────
|
||||
|
||||
Process {
|
||||
id: reloadRun
|
||||
command: ["hyprctl", "reload"]
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.reloading = false;
|
||||
if (exitCode !== 0) {
|
||||
root.lastError = "The compositor did not reload, so the rules above are not in effect yet.";
|
||||
return;
|
||||
}
|
||||
root.lastError = "";
|
||||
settle.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: settle
|
||||
interval: 350
|
||||
// The rules the reload just read are the ones stored at that moment.
|
||||
// Recorded after the settle rather than before the reload so a write
|
||||
// that lands late is not credited to a reload that ran before it.
|
||||
onTriggered: root.appliedSignature = root.signature
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: reloadDelay
|
||||
interval: 120
|
||||
onTriggered: reloadRun.running = true
|
||||
}
|
||||
|
||||
function apply(): void {
|
||||
if (reloadRun.running)
|
||||
return;
|
||||
root.reloading = true;
|
||||
// DesktopPreferences coalesces its write on a timer; the reload has to
|
||||
// land after it or it re-reads the previous file.
|
||||
reloadDelay.restart();
|
||||
}
|
||||
|
||||
// A rule set that was already in the config when the shell started is in
|
||||
// effect: the compositor read it at login. Recorded once so an untouched
|
||||
// list does not read as pending forever.
|
||||
Component.onCompleted: root.appliedSignature = root.signature
|
||||
}
|
||||
@@ -14,7 +14,11 @@ ShellRoot {
|
||||
count: hits.length,
|
||||
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)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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**<br>`highContrast` | false | Increases contrast in applications that support it. Modern GTK applications read this from the desktop portal and restyle themselves; older ones need a high-contrast theme, which is not installed here. |
|
||||
| **Dim inactive windows**<br>`dimInactive` `decoration:dim_inactive` | false | Darkens every window except the focused one, so the active window is unmistakable |
|
||||
| **Dim amount**<br>`dimStrength` `decoration:dim_strength` | 0.5 | How much darker unfocused windows are. Range 0.05–0.9. |
|
||||
| **Color filter**<br>`colorFilter` | none | A whole-screen filter rendered by the compositor — grayscale, or a correction for one kind of color blindness. Costs nothing when off. Choices: None, Grayscale, Protanopia, Deuteranopia, Tritanopia. |
|
||||
| **Flash the screen for notifications**<br>`visualAlerts` | false | A single flash at the edges of every screen when a notification arrives that would ring the bell |
|
||||
| **Pointer size**<br>`cursorSize` | 24 px | Applies to the compositor and to applications. Range 16–64. |
|
||||
| **Text size**<br>`textScale` | 1.0 | Scales interface text everywhere; 1.00 is the design size. Range 0.75–2.0. |
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# Tier 2 — the identity block
|
||||
|
||||
Approved from mock `tier2-identity.html` (2026-08-25). Custom shortcuts, per-app window rules,
|
||||
assignable four-finger gestures — all fed by one named-action system — plus network truths
|
||||
(static IP both stacks, metered, saved networks, hidden SSID), color filters, the DND tile,
|
||||
tokenized search, the known-hosts cap, and the GNOME umbrella button's removal.
|
||||
|
||||
## The one safety property
|
||||
|
||||
`settings.json` stays non-executable. No stored value is ever a command. A custom shortcut, a
|
||||
gesture assignment, and a window rule are all **data**: an enum kind, a validated target id, and
|
||||
booleans. The Lua resolves data → action at config time through whitelist tables only; an unknown
|
||||
kind or an invalid target means the bind/gesture/rule is silently not emitted (the `prefs.get`
|
||||
philosophy: never raise, never guess).
|
||||
|
||||
## Named actions (shared vocabulary)
|
||||
|
||||
Stored shape, used by `customBinds` and the four gesture keys:
|
||||
|
||||
```json
|
||||
{ "kind": "app" | "shell" | "window", "target": "<id>", "label": "<display text>" }
|
||||
```
|
||||
|
||||
- `app` — target is an application/desktop id matching `^[A-Za-z0-9@._-]{1,128}$`, launched via
|
||||
the existing launch-or-focus path. The id is an *argument*, never interpolated into a shell string.
|
||||
- `shell` — target is a key of a fixed Lua table mapping to existing shell IPC verbs
|
||||
(`dnd-toggle`, `screenshot-area`, `color-picker`, `clipboard`, `overview`, `lock`, … — the
|
||||
final list is exactly the verbs `shell.qml` already exposes; verify there, don't invent).
|
||||
- `window` — target is a key of a fixed dispatcher table (`float-toggle`, `fullscreen`, `pin`,
|
||||
`workspace:1`..`workspace:10`; `workspace:N` validated 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": "<window class, literal>", "label": "<app display name>",
|
||||
"float": bool, "center": bool, "size": [w, h] | null, "workspace": int | null,
|
||||
"noAnim": bool, "game": bool, "noDim": bool, "pin": bool }
|
||||
```
|
||||
|
||||
- `rules.lua`: after the shipped rules, a loop over `prefs.get("windowRules", {})` emitting one
|
||||
`hl.window_rule` per entry, **class regex-escaped** (literal match). Validation: class
|
||||
non-empty ≤ 128 chars, size within 50..10000, workspace 1..10; invalid entry skipped whole.
|
||||
Shipped rules always emit first (user rules are anonymous → evaluated after named ones anyway;
|
||||
do not name user rules).
|
||||
- New `services/WindowRules.qml` singleton modeled on `Workspaces.qml`: owns the pref array,
|
||||
`addRule/updateRule/removeRule`, the 120 ms write-settle + `hyprctl reload` + 350 ms refresh,
|
||||
and an honest `applied` readback (`hyprctl -j` — use whatever rules listing the running
|
||||
Hyprland exposes; if none exists, readback = "reload exit status + re-parse of prefs" and the
|
||||
card says applied-on-reload rather than pretending).
|
||||
- TilingPage: "App rules" card above the shipped Tiling card, per the mock — list rows (label,
|
||||
plain-language behavior summary, mono rule line), edit + remove (ConfirmAction,
|
||||
`actionId: "window-rule-remove:" + class`), add flow: app picker (running toplevels via
|
||||
`Hyprland.toplevels` first, then the applications catalog) + behavior ticks.
|
||||
- The shell's own surfaces cannot be matched: refuse classes matching `^(quickshell|qs-)`.
|
||||
- New contract `tests/hypr/window-rules-contract` cloned from `workspace-rules-contract`'s
|
||||
stub-lua harness: synthetic `settings.json` with N rules (one invalid, one `qs-` class) →
|
||||
assert shipped count + 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 = <up/down/left/right>, action = <resolved> })`.
|
||||
- MousePage: new "Gestures" card above Touchpad per the mock — three read-only shipped rows,
|
||||
four OptionPickerRows (options: Nothing + the named-action vocabulary; assigning writes the
|
||||
pref and triggers `Keybinds.applyReload()` — reuse that seam, do not build a second reload),
|
||||
then `swipeDistance` + `swipeInvert` moved up from the Touchpad card (schema keys unchanged —
|
||||
`search-routing-contract` keeps passing because the group's keys stay on the same page).
|
||||
- `tests/hypr/gestures-contract` rewrite: the three 3-finger pins stay verbatim (including the
|
||||
no-`overview("toggle")` rule); the `== 3` count becomes "exactly 3 three-finger literals, plus
|
||||
four-finger emission only inside the customGestures loop"; add a stub-lua run with a synthetic
|
||||
settings.json asserting 0 four-finger gestures when unassigned and N when assigned, and that
|
||||
`swipeDistance`/`swipeInvert` remain schema keys.
|
||||
|
||||
## 4. Network
|
||||
|
||||
New `panama-network` verbs (all: `shape_for` + `FALLBACKS` entries, `connection_state()` reply
|
||||
shape for per-connection mutations, no absolute binary paths, secrets never in argv):
|
||||
|
||||
- `set-ip CONNECTION 4|6 auto` / `set-ip CONNECTION 4|6 manual ADDR/PREFIX GATEWAY DNS[,DNS…]`
|
||||
— `ipv4/ipv6.method`, `.addresses`, `.gateway`, `.dns`, `.ignore-auto-dns yes` on manual;
|
||||
clears them on auto. New validation regexes (IPv4, IPv6, CIDR prefix ranges) in house style.
|
||||
Reactivates the connection after the change (`nmcli connection up`) only if it was active.
|
||||
- `details` additionally reports the *profile's* configured method + addresses/gateway/dns for
|
||||
both stacks (today it only reads active state) and `metered` (`connection.metered`).
|
||||
- `set-metered CONNECTION yes|no|auto`.
|
||||
- `saved` — `nmcli connection show` listing (name, uuid, type, autoconnect, last-used,
|
||||
in-range flag by cross-referencing active scan), secrets filtered.
|
||||
- `join-hidden SSID PROFILE SECURITY` — password on stdin, `wifi.hidden yes`; security ∈
|
||||
`wpa-psk|sae|none`.
|
||||
- NetworkTools.qml: plumbing for each verb (same queued-Process pattern), drafts live in the
|
||||
page.
|
||||
- ConnectionDetails.qml: metered toggle + the IPv4/IPv6 editors from the mock (Automatic/Manual
|
||||
choice; manual reveals address/gateway/DNS drafts; nothing applies until Apply; commit only
|
||||
when the address validates — the proxy-drafts pattern from ConnectivityPage).
|
||||
- ConnectivityPage: "Saved networks" card (house cap 6 + fold, in-range/connected/autoconnect
|
||||
detail lines, Forget via ConfirmAction naming the password loss). WifiPanel: "Join a hidden
|
||||
network…" row → SSID + security + password flow (reuse the join-path components).
|
||||
- `network-tools-contract`: static half — new regexes exist, `shape_for` knows the new verbs,
|
||||
AST secret check still passes; dynamic half — stubbed nmcli sees the right argv for set-ip
|
||||
manual/auto, set-metered, join-hidden (password via stdin only, sentinel never in argv/JSON).
|
||||
|
||||
## 5. Color filters
|
||||
|
||||
- Ship 4 shaders under `config/dot/hypr/shaders/` (grayscale, protanopia, deuteranopia,
|
||||
tritanopia — the mock's feColorMatrix values, as Hyprland screen shaders; end-of-pipe
|
||||
`vec4 → vec4` GLSL). `declared-assets-contract`: they're installed by the existing hypr dir
|
||||
symlink — verify, and add whatever declaration that contract wants.
|
||||
- `looks.lua`: `local filter = prefs.get("colorFilter", "none")` → table lookup enum→absolute
|
||||
shader path → `decoration.screen_shader` (empty string when none/unknown).
|
||||
- Live apply: `SystemSettings` gains `applyColorFilter(name)` doing the same lookup +
|
||||
`hyprctl eval decoration:screen_shader <path>` with read-back; the Accessibility page's
|
||||
ChoiceRow (Seeing card, per mock) writes the pref and calls it. No `hypr:` block on the schema
|
||||
entry (stored enum ≠ hyprctl's path value, which would break the shape/sweep contracts).
|
||||
- `hypr-prefs-contract` requires the `prefs.get` default to match the schema default (`"none"`).
|
||||
|
||||
## 6. Smaller items
|
||||
|
||||
- **DND tile** (QuickSettingsPanel): beside Presentation, bound to `Notifs.doNotDisturb`,
|
||||
subtitle On/Off; Presentation keeps its combined role. Check `control-center-contract` +
|
||||
`control-center-services-contract` pins before and after.
|
||||
- **Search tokenization** (SettingsSearch.qml): the needle splits on whitespace; every token
|
||||
must match the haystack (AND). Ranking gains "all tokens in label" above the existing rules.
|
||||
Keep the empty-query → `[]` behavior and all 21 pinned cases in `settings-search-contract`
|
||||
(they are single-token and must not regress). Add `extraEntries` for every new Tier-2 surface
|
||||
(custom shortcuts, app rules, gestures, saved networks, hidden network, metered, static IP,
|
||||
color filter, DND tile) and synonyms for the known holes (`log out`, `wired`, `ethernet`,
|
||||
`gestures`, `metered`). Results may carry an optional `section`; SettingsSidebar uses
|
||||
`ShellState.openSettingsSection(page, section)` when present, else `pageRequested` as today.
|
||||
- **Known hosts cap** (SshKeysPage): house cap 6 + "N more ▾" fold on the hosts Repeater.
|
||||
- **GNOME umbrella button** (HealthPage): remove the "Fedora system settings" card
|
||||
(`openGnomePanel("system")`). Wellbeing card stays. Add `[system]=about` to
|
||||
`gnome-handoff-contract`'s OWNED map so the door stays shut.
|
||||
|
||||
## Ownership (disjoint)
|
||||
|
||||
- **Agent A — the Lua plane**: `config/dot/hypr/actions.lua` (new), `keybinds.lua`, `input.lua`,
|
||||
`rules.lua`, `looks.lua`, `shaders/` (new); `tests/hypr/gestures-contract`,
|
||||
`keybind-categories-contract`, `window-rules-contract` (new), `tests/quickshell/keybind-rebind-contract`.
|
||||
- **Agent B — input-block services & UI**: `services/Keybinds.qml`, `services/WindowRules.qml`
|
||||
(new), `services/SystemSettings.qml` (applyColorFilter only), `modules/settings/ShortcutsPage.qml`,
|
||||
`TilingPage.qml`, `MousePage.qml`, the Accessibility Seeing page (color filter row),
|
||||
`modules/settings/qmldir` + any new components; `tests/quickshell/keybinds-contract` needles if
|
||||
needed.
|
||||
- **Agent C — network & the rest**: `scripts/panama-network`, `services/NetworkTools.qml`,
|
||||
`services/SettingsSearch.qml`, `modules/settings/ConnectivityPage.qml`, `ConnectionDetails.qml`,
|
||||
`WifiPanel.qml`, `SettingsSidebar.qml`, `HealthPage.qml`, `SshKeysPage.qml`,
|
||||
`modules/quicksettings/QuickSettingsPanel.qml`; `tests/quickshell/network-tools-contract`,
|
||||
`settings-search-contract` (additions only), `gnome-handoff-contract` (OWNED addition),
|
||||
`ssh-keys-contract` (cap needle if needed).
|
||||
- **Orchestrator**: `PreferenceSchema.qml` (done first), `SettingsRoutes`/docs/commands regen,
|
||||
seam audit, `settings-ownership-contract` if mirrors appear.
|
||||
|
||||
## Standing rules
|
||||
|
||||
Live desktop: every save must leave valid QML; check the journal (errors AND "Unable to
|
||||
assign" warnings) after each. No live mutations: no real nmcli writes, no hyprctl reload storms
|
||||
(batch: reload once per verified change-set, announce). Contracts run as you go (grant active);
|
||||
never `settings-system-contract`. Theme tokens only; no continuously repainting animations;
|
||||
destructive actions via ConfirmAction; errors via ErrorRow; unmeasured via NotMeasuredRow.
|
||||
@@ -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
|
||||
|
||||
+204
-10
@@ -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" \
|
||||
|
||||
@@ -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" \
|
||||
|
||||
Executable
+217
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Per-application window rules, written by the user.
|
||||
#
|
||||
# This is settings.json describing compositor behaviour, which is the same
|
||||
# shape of risk custom shortcuts carry and is handled the same way: the stored
|
||||
# entry is data -- a class, some booleans, two numbers -- and hypr/rules.lua is
|
||||
# the only thing that turns it into a rule.
|
||||
#
|
||||
# Three properties, and every one of them was a real way to lose the desktop:
|
||||
#
|
||||
# 1. The class is matched LITERALLY. Hyprland matches with RE2, so a class
|
||||
# typed into a text field is a regular expression: "org.gnome.Files" would
|
||||
# also match "orgxgnomexFiles", and a half-typed "(" is a pattern error
|
||||
# rather than a rule that matches nothing.
|
||||
# 2. An invalid entry is skipped WHOLE. A rule that half-applies -- the size
|
||||
# dropped, the float kept -- is harder to understand than one that is not
|
||||
# there.
|
||||
# 3. The shell's own surfaces cannot be matched. A user floating or moving
|
||||
# Quickshell's windows from the Windows page is a person breaking their
|
||||
# desktop with a supported control.
|
||||
#
|
||||
# And one about ordering: user rules are ANONYMOUS. Hyprland evaluates every
|
||||
# named rule before every anonymous one, so naming a user rule would make it
|
||||
# lose to the shipped rules it is meant to override.
|
||||
#
|
||||
# The Lua is exercised with a stubbed `hl`, so the rules can be counted without
|
||||
# a compositor and without touching the running desktop.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
hypr_dir="$repo_dir/config/dot/hypr"
|
||||
rules="$hypr_dir/rules.lua"
|
||||
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
command -v lua >/dev/null 2>&1 || {
|
||||
printf 'window rules contract: lua is not installed\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
work="$(mktemp -d /tmp/panama-window-rules.XXXXXX)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
mkdir -p "$work/config/panama"
|
||||
|
||||
# Every window rule rules.lua emits for a given settings file, one per line, as
|
||||
# "<name or -> <class pattern> <field=value ...>". Layer rules are swallowed:
|
||||
# this is about window rules, and a layer rule is not one.
|
||||
emit() {
|
||||
printf '%s' "$1" >"$work/config/panama/settings.json"
|
||||
XDG_CONFIG_HOME="$work/config" lua -e "
|
||||
package.path = '$hypr_dir/?.lua;' .. package.path
|
||||
hl = {
|
||||
layer_rule = function() end,
|
||||
window_rule = function(rule)
|
||||
local fields = {}
|
||||
for key, value in pairs(rule) do
|
||||
if key ~= 'match' and key ~= 'name' then
|
||||
if type(value) == 'table' then
|
||||
value = tostring(value[1]) .. 'x' .. tostring(value[2])
|
||||
end
|
||||
fields[#fields + 1] = key .. '=' .. tostring(value)
|
||||
end
|
||||
end
|
||||
table.sort(fields)
|
||||
local class = (rule.match or {}).class
|
||||
print((rule.name or '-') .. ' ' .. tostring(class) .. ' ' .. table.concat(fields, ' '))
|
||||
end,
|
||||
}
|
||||
dofile('$rules')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
count() { grep -c . <<<"$1" || true; }
|
||||
|
||||
# ── The shipped baseline ─────────────────────────────────────────────────────
|
||||
#
|
||||
# Read rather than pinned as a number. Pinning one means every new shipped rule
|
||||
# fails this contract for no reason, and the property under test is the
|
||||
# DIFFERENCE the user's rules make, not how many rules Panama ships.
|
||||
|
||||
shipped="$(emit '{"windowRules":[]}')"
|
||||
shipped_count="$(count "$shipped")"
|
||||
(( shipped_count > 0 )) || note 'rules.lua emitted no window rules at all, which cannot be right'
|
||||
|
||||
absent="$(emit '{}')"
|
||||
(( "$(count "$absent")" == shipped_count )) \
|
||||
|| note 'with no windowRules key at all, the shipped rule count changes'
|
||||
|
||||
# ── Five rules, two of which must not be emitted ──────────────────────────────
|
||||
#
|
||||
# * Calculator -- valid, and the one whose escaping is checked
|
||||
# * qs-dock -- the shell's own surface, refused
|
||||
# * Steam -- workspace 42, which does not exist; skipped whole
|
||||
# * foo(bar -- a class that is not a valid regex, and must not become one
|
||||
# * mygame -- valid, with every remaining field set
|
||||
|
||||
USER_RULES='{"windowRules":[
|
||||
{"class":"org.gnome.Calculator","label":"Calculator","float":true,"center":true,"size":[400,600]},
|
||||
{"class":"qs-dock","label":"Dock","float":true},
|
||||
{"class":"Steam","label":"Steam","workspace":42},
|
||||
{"class":"foo(bar","label":"Odd","float":true,"pin":true},
|
||||
{"class":"mygame","label":"Game","game":true,"noAnim":true,"noDim":true,"workspace":9}
|
||||
]}'
|
||||
|
||||
applied="$(emit "$USER_RULES")"
|
||||
applied_count="$(count "$applied")"
|
||||
|
||||
(( applied_count == shipped_count + 3 )) \
|
||||
|| note "five user rules with two invalid emitted $(( applied_count - shipped_count )) rules, not 3"
|
||||
|
||||
user_lines="$(comm -13 <(sort <<<"$shipped") <(sort <<<"$applied"))"
|
||||
|
||||
# 1. The class is escaped and anchored, so it matches itself and nothing else.
|
||||
grep -qF '^org\.gnome\.Calculator$' <<<"$user_lines" \
|
||||
|| note 'the class is not regex-escaped, so a dotted application id matches classes the user did not name'
|
||||
grep -qF '^foo\(bar$' <<<"$user_lines" \
|
||||
|| note 'a class containing a regex metacharacter is not escaped, so it is a pattern rather than a name'
|
||||
|
||||
# 2. Invalid entries are gone, and gone whole.
|
||||
grep -q 'qs-dock' <<<"$user_lines" \
|
||||
&& note 'a rule matching the shell\047s own surfaces was emitted'
|
||||
grep -q 'Steam' <<<"$user_lines" \
|
||||
&& note 'a rule with an out-of-range workspace was emitted'
|
||||
grep -qE 'Steam.*float' <<<"$user_lines" \
|
||||
&& note 'an invalid rule was emitted with its bad field dropped rather than skipped whole'
|
||||
|
||||
# 3. The valid ones carry what they were given, under Hyprland's own names.
|
||||
grep -qE '\^org\\\.gnome\\\.Calculator\$.*center=true.*float=true.*size=400x600' <<<"$user_lines" \
|
||||
|| note 'the float/center/size rule did not survive translation: '"$(grep Calculator <<<"$user_lines")"
|
||||
grep -qE '\^mygame\$.*content=game.*no_anim=true.*no_dim=true.*workspace=9' <<<"$user_lines" \
|
||||
|| note 'the game rule did not survive translation: '"$(grep mygame <<<"$user_lines")"
|
||||
grep -qE '\^foo\\\(bar\$.*pin=true' <<<"$user_lines" \
|
||||
|| note 'pin did not survive translation'
|
||||
|
||||
# 4. Anonymous, and after the shipped rules. Named would outrank every
|
||||
# anonymous shipped rule; earlier would let a shipped rule win the last-match
|
||||
# tiebreak against the user's own.
|
||||
while IFS= read -r line; do
|
||||
[[ -n "$line" ]] || continue
|
||||
[[ "${line%% *}" == "-" ]] \
|
||||
|| note "a user rule was emitted with the name '${line%% *}', which makes it outrank the shipped rules"
|
||||
done <<<"$user_lines"
|
||||
|
||||
first_user="$(grep -n 'mygame' <<<"$applied" | head -1 | cut -d: -f1)"
|
||||
last_shipped="$(grep -c . <<<"$shipped")"
|
||||
[[ -n "$first_user" && "$first_user" -gt "$last_shipped" ]] \
|
||||
|| note 'user rules are not emitted after every shipped rule'
|
||||
|
||||
# ── Sizes and workspaces have bounds ─────────────────────────────────────────
|
||||
#
|
||||
# Not decoration: a 4-pixel window is unreachable with the pointer, and a
|
||||
# workspace number outside what the keybinds reach strands a window somewhere
|
||||
# there is no shortcut to.
|
||||
|
||||
for bad in \
|
||||
'{"class":"tiny","size":[4,4]}' \
|
||||
'{"class":"huge","size":[99999,99999]}' \
|
||||
'{"class":"half","size":[400]}' \
|
||||
'{"class":"zero","workspace":0}' \
|
||||
'{"class":"","float":true}' \
|
||||
'{"class":"quickshell","float":true}' \
|
||||
'{"class":"QS-Dock","float":true}'; do
|
||||
out="$(emit "{\"windowRules\":[$bad]}")"
|
||||
(( "$(count "$out")" == shipped_count )) \
|
||||
|| note "an invalid rule was emitted anyway: $bad"
|
||||
done
|
||||
|
||||
# A class of exactly the cap is fine; one past it is not.
|
||||
ok_class="$(printf 'a%.0s' $(seq 1 128))"
|
||||
long_class="$(printf 'a%.0s' $(seq 1 129))"
|
||||
(( "$(count "$(emit "{\"windowRules\":[{\"class\":\"$ok_class\"}]}")")" == shipped_count + 1 )) \
|
||||
|| note 'a class of exactly 128 characters is refused, so the cap is off by one'
|
||||
(( "$(count "$(emit "{\"windowRules\":[{\"class\":\"$long_class\"}]}")")" == shipped_count )) \
|
||||
|| note 'a class longer than 128 characters is emitted anyway'
|
||||
|
||||
# ── Nothing malformed can cost the compositor ────────────────────────────────
|
||||
#
|
||||
# The whole point of reading a hand-editable file at config time is that a bad
|
||||
# read costs the setting and never the desktop. A raise here aborts rules.lua,
|
||||
# and the desktop comes up with no window rules at all.
|
||||
|
||||
for hostile in \
|
||||
'{"windowRules":"not an array"}' \
|
||||
'{"windowRules":[null]}' \
|
||||
'{"windowRules":[42]}' \
|
||||
'{"windowRules":[{"class":123}]}' \
|
||||
'{"windowRules":[{"class":"ok","size":"400x600"}]}' \
|
||||
'{"windowRules":[{"class":"ok","workspace":"3"}]}'; do
|
||||
out="$(emit "$hostile")"
|
||||
(( "$(count "$out")" >= shipped_count )) \
|
||||
|| note "a malformed windowRules value cost the shipped rules: $hostile"
|
||||
done
|
||||
|
||||
# ── The preference cannot pretend to be an option ────────────────────────────
|
||||
|
||||
if grep -q 'key: "windowRules"' "$schema"; then
|
||||
block="$(sed -n '/key: "windowRules"/,/^ },/p' "$schema")"
|
||||
grep -q 'hypr:' <<<"$block" \
|
||||
&& note 'windowRules declares a hypr option, but window rules are not settable options'
|
||||
else
|
||||
note 'windowRules is not in the schema'
|
||||
fi
|
||||
|
||||
# ── Report ───────────────────────────────────────────────────────────────────
|
||||
|
||||
if (( ${#findings[@]} > 0 )); then
|
||||
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
|
||||
printf 'window rules contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||
printf ' - %s\n' "${findings[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'window rules contract: PASS (%d shipped rules, user rules validated and escaped)\n' "$shipped_count"
|
||||
@@ -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
|
||||
|
||||
@@ -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' \
|
||||
|
||||
@@ -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"(?<![\w.])command\s*=\s*([^,\n]+)", source):
|
||||
value = match.group(1).strip()
|
||||
if not value.startswith('"'):
|
||||
problems.append(f"a whitelist command is not a literal: {value[:60]}")
|
||||
|
||||
# exec_cmd is only ever handed a whitelist command or the launcher command
|
||||
# assembled from literals above it.
|
||||
for number, line in enumerate(lines, 1):
|
||||
m = re.search(r"exec_cmd\(([^)]*)\)", line)
|
||||
if m and m.group(1).strip() not in ("verb.command", "launch_command"):
|
||||
problems.append(f"line {number}: exec_cmd takes {m.group(1).strip()[:60]}")
|
||||
|
||||
if problems:
|
||||
print("\n".join(problems), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
# ── What the Lua actually emits ──────────────────────────────────────────────
|
||||
#
|
||||
# The static read above says the code is shaped right. This runs it, with a
|
||||
# stubbed `hl` and a synthetic settings file, so the validation is exercised
|
||||
# rather than trusted -- no compositor, no real preferences.
|
||||
|
||||
if command -v lua >/dev/null 2>&1; then
|
||||
lua_work="$(mktemp -d /tmp/panama-custom-binds.XXXXXX)"
|
||||
mkdir -p "$lua_work/config/panama" "$lua_work/state"
|
||||
|
||||
# Every bind keybinds.lua emits, as "<chord>\t<description>\t<action>".
|
||||
emit_binds() {
|
||||
printf '%s' "$1" >"$lua_work/config/panama/settings.json"
|
||||
XDG_CONFIG_HOME="$lua_work/config" XDG_STATE_HOME="$lua_work/state" lua -e "
|
||||
package.path = '$repo_dir/config/dot/hypr/?.lua;' .. package.path
|
||||
hl = {
|
||||
config = function() end,
|
||||
dispatch = function() end,
|
||||
bind = function(chord, action, opts)
|
||||
print(chord .. '\t' .. tostring((opts or {}).description) .. '\t' .. tostring(action))
|
||||
end,
|
||||
dsp = setmetatable({}, { __index = function(_, name)
|
||||
local function node(path)
|
||||
return setmetatable({}, {
|
||||
__index = function(_, key) return node(path .. '.' .. key) end,
|
||||
__call = function(_, argument)
|
||||
if type(argument) == 'string' then
|
||||
return path .. '(' .. argument .. ')'
|
||||
end
|
||||
return path .. '()'
|
||||
end,
|
||||
})
|
||||
end
|
||||
return node(name)
|
||||
end }),
|
||||
}
|
||||
dofile('$keybinds')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
baseline="$(emit_binds '{}' | wc -l)"
|
||||
(( baseline > 100 )) || fail "the shipped keymap emitted $baseline binds, which cannot be right"
|
||||
|
||||
# Two good entries, and seven ways of being wrong: an unknown shell verb, a
|
||||
# command in the target, a command in an app id, a workspace outside 1..10,
|
||||
# a kind nobody defined, an empty label, and a chord already taken by a
|
||||
# shipped bind.
|
||||
shipped_chord="$(emit_binds '{}' | cut -f1 | grep -Fx 'SUPER + T')"
|
||||
[[ -n "$shipped_chord" ]] || fail 'could not find a shipped chord to collide with'
|
||||
|
||||
custom="$(emit_binds '{"customBinds":[
|
||||
{"chord":"SUPER + SHIFT + F1","kind":"shell","target":"dnd-toggle","label":"Do Not Disturb"},
|
||||
{"chord":"SUPER + SHIFT + F2","kind":"app","target":"org.gnome.Nautilus","label":"Files"},
|
||||
{"chord":"SUPER + SHIFT + F3","kind":"window","target":"workspace:4","label":"Workspace 4"},
|
||||
{"chord":"SUPER + SHIFT + F4","kind":"shell","target":"reboot","label":"Unknown verb"},
|
||||
{"chord":"SUPER + SHIFT + F5","kind":"shell","target":"dnd-toggle; reboot","label":"Command"},
|
||||
{"chord":"SUPER + SHIFT + F6","kind":"app","target":"foo $(reboot)","label":"Command in an id"},
|
||||
{"chord":"SUPER + SHIFT + F7","kind":"window","target":"workspace:0","label":"No such workspace"},
|
||||
{"chord":"SUPER + SHIFT + F8","kind":"exec","target":"reboot","label":"Invented kind"},
|
||||
{"chord":"SUPER + SHIFT + F9","kind":"shell","target":"overview","label":""},
|
||||
{"chord":"SUPER + T","kind":"shell","target":"lock","label":"Steals the terminal key"}
|
||||
]}')"
|
||||
|
||||
added=$(( $(wc -l <<<"$custom") - baseline ))
|
||||
(( added == 3 )) || fail "ten custom binds with seven invalid added $added binds, not 3"
|
||||
|
||||
for chord in 'SUPER + SHIFT + F1' 'SUPER + SHIFT + F2' 'SUPER + SHIFT + F3'; do
|
||||
grep -Fq "$chord" <<<"$custom" || fail "the valid custom bind $chord was not emitted"
|
||||
done
|
||||
|
||||
# The shipped key kept its action. A custom bind that collides loses; the
|
||||
# alternative is two binds on one chord and whichever Hyprland reads last.
|
||||
terminal_line="$(grep -F "$shipped_chord"$'\t' <<<"$custom" | head -1)"
|
||||
grep -Fq 'Terminal' <<<"$terminal_line" \
|
||||
|| fail "a custom bind took over a shipped chord: $terminal_line"
|
||||
|
||||
# Every custom bind carries the label as its description, because a bind
|
||||
# with no description is invisible to the cheatsheet and to the page that
|
||||
# would let you change it.
|
||||
while IFS=$'\t' read -r chord description _; do
|
||||
[[ -n "$description" && "$description" != "nil" ]] \
|
||||
|| fail "the bind $chord has no description"
|
||||
done <<<"$custom"
|
||||
|
||||
# And the actions are only ever whitelist commands or a quoted launch.
|
||||
while IFS=$'\t' read -r _ _ action; do
|
||||
case "$action" in
|
||||
*reboot*) fail "a stored target reached a command: $action" ;;
|
||||
esac
|
||||
done <<<"$custom"
|
||||
|
||||
grep -Fq "panama-launch --class '^org\\.gnome\\.Nautilus\$' -- gtk-launch 'org.gnome.Nautilus'" <<<"$custom" \
|
||||
|| fail "the app target is not passed as a quoted argument to the launch-or-focus path: $(grep -F 'SUPER + SHIFT + F2' <<<"$custom")"
|
||||
|
||||
# Chords have a bound, and it is the same one overrides have. A 4 KB
|
||||
# "chord" is not a chord, it is a way to make hyprctl binds unreadable.
|
||||
long_chord="$(printf 'A%.0s' $(seq 1 65))"
|
||||
over="$(emit_binds "{\"customBinds\":[{\"chord\":\"$long_chord\",\"kind\":\"shell\",\"target\":\"lock\",\"label\":\"Long\"}]}" | wc -l)"
|
||||
(( over == baseline )) || fail 'a chord longer than 64 characters was bound anyway'
|
||||
|
||||
# A malformed file costs the customizations and never the keymap.
|
||||
for broken in '{"customBinds":"nope"}' '{"customBinds":[null]}' '{"customBinds":[{"chord":42}]}'; do
|
||||
(( "$(emit_binds "$broken" | wc -l)" == baseline )) \
|
||||
|| fail "a malformed customBinds value changed the shipped keymap: $broken"
|
||||
done
|
||||
|
||||
rm -rf "$lua_work"
|
||||
else
|
||||
fail 'lua is not installed, so what a custom shortcut becomes went unchecked'
|
||||
fi
|
||||
|
||||
if [[ "${PANAMA_KEYBINDS_STATIC_ONLY:-0}" == "1" ]]; then
|
||||
printf 'keybind rebind contract: PASS (static)\n'
|
||||
exit 0
|
||||
|
||||
@@ -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 >/dev/null 2>&1
|
||||
grep -Fq 'connection edit' "$state_dir/argv" \
|
||||
|| fail "an open hidden network was not created: $(log)"
|
||||
grep -Fq 'wireless-security' "$state_dir/stdin" \
|
||||
&& fail 'an open network was given a key-management setting'
|
||||
|
||||
: >"$state_dir/argv"
|
||||
[[ -n "$(runh join-hidden 'office-private' 'office-private' wep </dev/null 2>/dev/null \
|
||||
| jq -r '.error // ""')" ]] || fail 'an unknown hidden-network security was accepted'
|
||||
[[ ! -s "$state_dir/argv" ]] \
|
||||
|| fail 'an unknown hidden-network security still reached nmcli'
|
||||
[[ -n "$(runh join-hidden 'office-private' 'office-private' wpa-psk </dev/null 2>/dev/null \
|
||||
| jq -r '.error // ""')" ]] || fail 'a secured hidden network with no password was accepted'
|
||||
|
||||
# ── import-vpn picks its plugin from the extension ───────────────────────────
|
||||
|
||||
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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user