227 lines
11 KiB
Lua
227 lines
11 KiB
Lua
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- 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
|