-- ───────────────────────────────────────────────────────────────────────────── -- Keybindings -- -- Ported 1:1 from the GNOME + Forge setup this replaces. Where Hyprland has no -- equivalent, the deviation is called out in a comment rather than silently -- dropped. -- -- The mental model, unchanged from Forge: -- SUPER -> act on windows (focus / move / swap / resize) -- ALT -> act on workspaces -- SUPER + CTRL -> change layout structure (split, float, swap) -- ───────────────────────────────────────────────────────────────────────────── local prefs = require("prefs") local mod = "SUPER" -- Programs, matched to the GNOME custom keybindings and media-key settings. local terminal = "kitty" local editor = "kitty nvim ." local browser = "helium-browser-bin" local files = "nautilus --new-window" local calculator = "gnome-calculator" local mail = "flatpak run org.mozilla.thunderbird_esr" -- Panama owns the Hyprland and shell controls. GNOME Settings remains installed -- and searchable in Vicinae for its hardware/account panels. local settings = "qs ipc call settings toggle" local sysmonitor = "flatpak run io.missioncenter.MissionCenter" -- Vicinae is the Raycast-style launcher. `vicinae toggle` shows/hides the -- window against the already-running server (started in autostart.lua). local launcher = "vicinae toggle" local osd = function(action) return "$HOME/.config/quickshell/scripts/panama-osd " .. action end local dictate = function(action) return "$HOME/.config/quickshell/scripts/panama-dictate " .. action end -- Quickshell IPC targets. See quickshell/shell.qml for the handlers. local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end -- ── Applications ──────────────────────────────────────────────────────────── -- ── User rebinding ────────────────────────────────────────────────────────── -- Every bind below goes through `bind` rather than `hl.bind` directly, so a -- chord can be replaced from Panama Settings without this file changing. -- -- Overrides are keyed by the bind's SHIPPED chord, and ONLY the chord is taken -- from settings -- the action is always the Lua value written here. A stored -- override can therefore move a shortcut but can never make one do something -- else, which is the property that makes reading them from a JSON file the -- user can edit safe. -- -- Keyed by chord rather than by description because chords are unique and -- descriptions are not: "Calculator" is both SUPER+C and the XF86Calculator -- hardware key, and keying by description moved both of them onto the same new -- chord, silently costing the hardware key. -- -- An override whose chord is not a plausible chord is ignored and the shipped -- one is used, so a hand-edited settings file cannot cost you a keymap. local overrides = prefs.get("keybindOverrides", {}) local function valid_chord(chord) if type(chord) ~= "string" or chord == "" or #chord > 64 then return false end -- "SUPER + SHIFT + K", "XF86AudioPlay", "Print", "SUPER + mouse:272" return chord:match("^[%w_+%s:]+$") ~= nil end -- ── Categories ────────────────────────────────────────────────────────────── -- What a bind is FOR, as opposed to what it does. -- -- The cheatsheet groups by this, and the Shortcuts settings page uses it too. -- It is recorded here rather than guessed from the description, which is what -- Keybinds.qml used to do: matching substrings put "Close window" and "Close -- the notification list" in the same group and left anything phrased unusually -- in whichever bucket matched first. -- -- The sections of this file already ARE the categories, so a section sets one -- and every bind below it inherits it. That keeps the annotation to one line -- per section instead of one per bind, and makes the grouping impossible to -- forget: a new bind lands in the category of the section it was written in. local categories = {} local current_category = "Other" local function category(name) current_category = name end local function bind(chord, action, opts) local override = overrides[chord] if valid_chord(override) then chord = override end -- Keyed by the chord actually bound, so the shell can join on what -- hyprctl reports without having to know about overrides. categories[chord] = current_category return hl.bind(chord, action, opts) end -- Written where the shell can read it. Hyprland reports a Lua bind's -- dispatcher as `__lua` with a bytecode offset, so there is no way to attach -- anything to a bind that survives into `hyprctl binds` -- the manifest is -- how this side of the desktop tells the other what these binds are for. -- -- Never raises. A read-only or missing state directory costs the categories, -- which the shell falls back from, and must never cost the keymap. local function write_categories() local state_home = os.getenv("XDG_STATE_HOME") if state_home == nil or state_home == "" then local home = os.getenv("HOME") if home == nil or home == "" then return end state_home = home .. "/.local/state" end local parts = {} for chord, name in pairs(categories) do -- Chords and category names are both from this file, so the only -- escaping that can matter is the quote character itself. parts[#parts + 1] = string.format('%q:%q', chord, name) end table.sort(parts) local path = state_home .. "/panama/keybind-categories.json" os.execute("mkdir -p " .. string.format("%q", state_home .. "/panama")) local file = io.open(path, "w") if file == nil then return end file:write("{" .. table.concat(parts, ",") .. "}\n") file:close() end -- Focus it if it is already open, start it if it is not -- which is what the -- application keys do on macOS and Windows, and what pressing the browser key -- twice ought to do. bin/panama-launch matches on window class, narrowed by -- title where the class alone cannot tell two things apart: the terminal and -- the editor are both kitty here, so only the title distinguishes them. -- -- Patterns are regular expressions and are anchored. An unanchored "mail" -- would match gmail-notifier, and the mail key would raise somebody's notifier -- instead. Single-quoted for the shell so a backslash reaches the matcher -- rather than being eaten on the way. local function shell_quote(value) return "'" .. value:gsub("'", "'\\''") .. "'" end local launcher_bin = "$HOME/.local/share/Panama/bin/panama-launch" local function launch_or_focus(class, command, title) local parts = { launcher_bin, "--class", shell_quote(class) } if title then parts[#parts + 1] = "--title" parts[#parts + 1] = shell_quote(title) end parts[#parts + 1] = "--" parts[#parts + 1] = command return table.concat(parts, " ") end category("Applications") bind(mod .. " + T", hl.dsp.exec_cmd(launch_or_focus("^kitty$", terminal)), { description = "Terminal" }) bind(mod .. " + N", hl.dsp.exec_cmd(launch_or_focus("^kitty$", editor, "nvim")), { description = "Neovim" }) bind(mod .. " + W", hl.dsp.exec_cmd(launch_or_focus("^helium", browser)), { description = "Browser" }) bind(mod .. " + F", hl.dsp.exec_cmd(launch_or_focus("^org\\.gnome\\.Nautilus$", files)), { description = "Files" }) bind(mod .. " + C", hl.dsp.exec_cmd(launch_or_focus("^org\\.gnome\\.Calculator$", calculator)), { description = "Calculator" }) bind(mod .. " + E", hl.dsp.exec_cmd(launch_or_focus("^org\\.mozilla\\.thunderbird", mail)), { description = "Mail" }) bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" }) bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" }) -- ── Launcher ──────────────────────────────────────────────────────────────── category("Applications") -- All three keys open the same launcher, on purpose: SUPER+A and SUPER+R were -- the GNOME app-grid and run-dialog shortcuts, and SUPER+SPACE is here as a -- third option to settle on. Vicinae covers apps, calculator, files, clipboard, -- emoji and window switching, so a separate run dialog and app grid are gone. bind(mod .. " + A", hl.dsp.exec_cmd(launcher), { description = "Launcher" }) bind(mod .. " + R", hl.dsp.exec_cmd(launcher), { description = "Launcher" }) bind(mod .. " + Space", hl.dsp.exec_cmd(launcher), { description = "Launcher" }) -- Emergency fallback launcher. Vicinae runs as a systemd user service and the -- bar is Quickshell; if either fails to come up, this is how you start an -- application without dropping to a TTY. Depends on nothing but wofi itself. bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" }) -- Every shortcut, on one key. Slash because "what are the keys" is a question, -- and because it is the one punctuation key no other bind wants. bind(mod .. " + slash", hl.dsp.exec_cmd(qs("cheatsheet", "toggle")), { description = "Keyboard shortcuts" }) -- Clipboard history and emoji, straight into the relevant launcher view. -- Deeplink form is the one from vicinae's own Hyprland quickstart. bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"), { description = "Clipboard history" }) bind(mod .. " + Period", hl.dsp.exec_cmd("vicinae vicinae://launch/emoji/search"), { description = "Emoji picker" }) -- ── Shell surfaces (Quickshell) ───────────────────────────────────────────── category("Shell") -- SUPER+S was GNOME's quick settings; kept. bind(mod .. " + S", hl.dsp.exec_cmd(qs("quicksettings", "toggle")), { description = "Quick settings" }) -- Start a 45-minute focus session on the current workspace, or reveal its -- Signal Glass controls if one is already running. bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { description = "Focus session" }) -- Workspace overview. GNOME put this on a bare SUPER tap; tap-detection on a -- modifier misfires when you're fast with SUPER+key combos, so it lives on a -- real chord instead. SUPER+grave was Forge's "cycle windows of same app", -- which Hyprland has no equivalent for. bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" }) -- Notification center. bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" }) -- Screenshot / screen record. One key, then pick screen / window / region and -- whether to capture or record -- reproducing GNOME's Print-screen UI. bind("Print", hl.dsp.exec_cmd(qs("capture", "open")), { description = "Screenshot / record" }) -- The GNOME direct-capture variants, kept as shortcuts past the picker. bind("SHIFT + Print", hl.dsp.exec_cmd(qs("capture", "screenNow")), { description = "Screenshot: whole screen" }) bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description = "Screenshot: window" }) -- Local OCR and QR/barcode recognition through the same region picker. This -- opens directly in Selection + Read mode; Print still exposes every mode. bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")), { description = "Screen Intelligence" }) -- Color picker: copies the hex under the cursor to the clipboard. bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Color picker" }) -- ── Window management ─────────────────────────────────────────────────────── category("Windows") bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" }) bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" }) -- Forge: window-toggle-float / window-toggle-always-float. -- "Always float" has no Hyprland equivalent (it wrote a persistent rule); pin -- is the nearest useful thing -- the window floats above every workspace. bind(mod .. " + CTRL + C", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle float" }) bind(mod .. " + CTRL + SHIFT + C", hl.dsp.window.pin({ action = "toggle" }), { description = "Pin window" }) -- Forge: con-split-layout-toggle / con-split-horizontal / con-split-vertical. bind(mod .. " + CTRL + G", hl.dsp.layout("togglesplit"), { description = "Toggle split direction" }) bind(mod .. " + CTRL + Z", hl.dsp.layout("preselect r"), { description = "Next window splits right" }) bind(mod .. " + CTRL + V", hl.dsp.layout("preselect d"), { description = "Next window splits down" }) -- Forge: window-shrink / window-expand / window-reset-sizes. bind(mod .. " + bracketleft", hl.dsp.layout("splitratio -0.05"), { repeating = true, description = "Shrink" }) bind(mod .. " + bracketright", hl.dsp.layout("splitratio +0.05"), { repeating = true, description = "Expand" }) bind(mod .. " + equal", hl.dsp.layout("splitratio exact 0.5"), { description = "Reset split" }) -- Focus (Forge: window-focus-*). Both vim keys and arrows, as in Forge. bind(mod .. " + H", hl.dsp.focus({ direction = "l" }), { description = "Focus left" }) bind(mod .. " + J", hl.dsp.focus({ direction = "d" }), { description = "Focus down" }) bind(mod .. " + K", hl.dsp.focus({ direction = "u" }), { description = "Focus up" }) bind(mod .. " + L", hl.dsp.focus({ direction = "r" }), { description = "Focus right" }) bind(mod .. " + left", hl.dsp.focus({ direction = "l" }), { description = "Focus left" }) bind(mod .. " + down", hl.dsp.focus({ direction = "d" }), { description = "Focus down" }) bind(mod .. " + up", hl.dsp.focus({ direction = "u" }), { description = "Focus up" }) bind(mod .. " + right", hl.dsp.focus({ direction = "r" }), { description = "Focus right" }) -- Move (Forge: window-move-*). bind(mod .. " + SHIFT + H", hl.dsp.window.move({ direction = "l" }), { description = "Move window left" }) bind(mod .. " + SHIFT + J", hl.dsp.window.move({ direction = "d" }), { description = "Move window down" }) bind(mod .. " + SHIFT + K", hl.dsp.window.move({ direction = "u" }), { description = "Move window up" }) bind(mod .. " + SHIFT + L", hl.dsp.window.move({ direction = "r" }), { description = "Move window right" }) -- Swap (Forge: window-swap-*). bind(mod .. " + CTRL + H", hl.dsp.window.swap({ direction = "l" }), { description = "Swap left" }) bind(mod .. " + CTRL + J", hl.dsp.window.swap({ direction = "d" }), { description = "Swap down" }) bind(mod .. " + CTRL + K", hl.dsp.window.swap({ direction = "u" }), { description = "Swap up" }) bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { description = "Swap right" }) -- Resize (Forge: window-resize--). -- -- Forge resized one named EDGE at a time: its resize() grows the window for a -- positive amount in every direction, and the edge only decides which side -- moves -- Y grew leftward, O grew rightward, and so on. Hyprland resizes along -- an axis and lets the layout choose the border, so those eight distinct -- behaviours collapse onto four and the direction is simply not expressible. -- -- Because of that the sizes are deliberately INVERTED from Forge's naming. -- Carried over faithfully, "increase" grew and "decrease" shrank, which was -- correct on paper and wrong under the fingers: with the edge gone, the keys -- that used to pull a window open from one side now push it from the other. -- Gabriel uses these daily and reads Y/O as shrink and B/M as grow, so that is -- what they do. Faithfulness to a mapping nobody can feel is not worth much. local step = 60 bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" }) bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" }) bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" }) bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" }) bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" }) bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" }) -- SUPER+SHIFT+P was double-bound with the color picker above; moved to -- Comma, which continues the bottom-row cluster (B/M/N) this axis already -- uses rather than landing on an arbitrary free key. bind(mod .. " + SHIFT + Comma", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" }) bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" }) -- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay -- showing what you are choosing between. -- -- The gesture needs three binds, not two. Tab steps the selection, and the -- switch is only COMMITTED when the modifier is released -- which is the sole -- way the compositor can tell the gesture is finished. That release bind is on -- the bare modifier, so it fires on EVERY Super release in the session; the -- handler returns immediately when no switch is open, which is why this is -- affordable. -- -- The release bind carries no description on purpose: it is not a shortcut -- anyone would look up or rebind, and the Shortcuts page lists what it finds. bind(mod .. " + Tab", hl.dsp.exec_cmd(qs("switcher", "next")), { description = "Next window" }) bind(mod .. " + SHIFT + Tab", hl.dsp.exec_cmd(qs("switcher", "previous")), { description = "Previous window" }) bind(mod, hl.dsp.exec_cmd(qs("switcher", "commit")), { release = true, description = "Commit window switch" }) -- Jump back to the previously focused window. bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" }) -- Mouse: drag to move, right-drag to resize. bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window with pointer" }) bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window with pointer" }) -- ── Workspaces ────────────────────────────────────────────────────────────── category("Workspaces") -- ALT is the workspace modifier, matching the GNOME setup. -- -- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces: -- moving right past the last workspace creates a new one, and moving left from -- the first clamps instead of wrapping. -- Behavior for the relative/cyclic binds below. These are Hyprland's own -- `binds:` options -- not part of general/dwindle -- and have no other home -- in the config, so they are read here rather than in looks.lua. hl.config({ binds = { workspace_back_and_forth = prefs.get("workspaceBackAndForth", false), allow_workspace_cycles = prefs.get("allowWorkspaceCycles", false), }, }) bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" }) bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" }) bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" }) bind("ALT + SHIFT + L", hl.dsp.window.move({ workspace = "+1" }), { description = "Move window to workspace right" }) -- GNOME also had these on CTRL+ALT+Up/Down. bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" }) bind("CTRL + ALT + down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" }) -- Direct jump. ALT+0 is workspace 10. for i = 1, 10 do local key = i % 10 bind("ALT + " .. key, hl.dsp.focus({ workspace = i }), { description = "Workspace " .. i }) bind("ALT + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }), { description = "Move window to workspace " .. i }) end -- Scroll the mouse wheel over the desktop with SUPER held to change workspace. -- (Scrolling the workspace indicator in the bar does the same; that's handled -- in quickshell/modules/bar/Workspaces.qml.) bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" }) bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" }) -- Minimize, as far as Hyprland has one. -- -- Hyprland has no minimize: it receives the request (the binary has -- setSetMinimized handlers for xdg, XWayland and foreign-toplevel) but exposes -- no dispatcher, no config option and not even an event to hook, so titlebar -- minimize buttons are inert and cannot be made to work. A tiling WM has no -- iconified state and no taskbar to restore from. -- -- The scratchpad is the honest equivalent: the window goes away, and the same -- key brings it back. Bound to X to match the muscle memory it replaces. bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimized)" }) bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimize to scratchpad" }) -- ── Session ───────────────────────────────────────────────────────────────── category("Session") -- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to -- CTRL+ALT+L -- the other binding most people already have in muscle memory. bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" }) bind("SUPER + Backspace", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" }) bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { description = "Power menu" }) -- ── Media and volume ──────────────────────────────────────────────────────── category("Media & hardware") -- locked = true keeps these working on the lock screen, as they do in GNOME. -- 6% steps match the GNOME volume-step setting. bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 6")), { locked = true, repeating = true , description = "Volume up" }) bind("XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 6")), { locked = true, repeating = true , description = "Volume down" }) bind("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , description = "Mute" }) bind("XF86AudioMicMute", hl.dsp.exec_cmd(osd("microphone toggle")), { locked = true , description = "Mute microphone" }) -- ── Dictation ─────────────────────────────────────────────────────────────── category("Shell") -- -- Hold to talk, exactly like push-to-talk anywhere else: the mic is open only -- while the key is down, so it cannot be left listening by forgetting about it. -- Two binds on one chord, the second flagged `release`. -- -- No `repeating`: holding a key normally repeats the press, which would restart -- the recording several times a second. The daemon refuses a second start while -- one is running, so a repeat would be harmless -- but not asking for it is -- better than relying on being refused. bind(mod .. " + D", hl.dsp.exec_cmd(dictate("start")), { description = "Dictate (hold to talk)" }) bind(mod .. " + D", hl.dsp.exec_cmd(dictate("stop")), { release = true, description = "Dictate (transcribe on release)" }) -- Escape out of a recording without transcribing it. Bound to the same modifier -- so it can be reached with the dictation key still held. bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd(dictate("cancel")), { description = "Cancel dictation" }) -- Back to media: the dictation binds sit here for historical reasons, and the -- category has to be set again or everything below inherits theirs. category("Media & hardware") -- Fine-grained steps, matching GNOME's shift/alt volume modifiers. bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 1")), { locked = true, repeating = true , description = "Volume up (fine)" }) bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 1")), { locked = true, repeating = true , description = "Volume down (fine)" }) bind("XF86AudioPlay", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" }) bind("XF86AudioPause", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" }) bind("XF86AudioNext", hl.dsp.exec_cmd(osd("media next")), { locked = true , description = "Next track" }) bind("XF86AudioPrev", hl.dsp.exec_cmd(osd("media previous")), { locked = true , description = "Previous track" }) bind("XF86AudioStop", hl.dsp.exec_cmd(osd("media stop")), { locked = true , description = "Stop playback" }) bind("XF86MonBrightnessUp", hl.dsp.exec_cmd(osd("brightness up 5")), { locked = true, repeating = true , description = "Brightness up" }) bind("XF86MonBrightnessDown", hl.dsp.exec_cmd(osd("brightness down 5")), { locked = true, repeating = true , description = "Brightness down" }) category("Applications") -- Hardware keys GNOME mapped that have obvious equivalents. bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" }) bind("XF86Calculator", hl.dsp.exec_cmd(calculator), { description = "Calculator" }) bind("XF86Explorer", hl.dsp.exec_cmd(files), { description = "Files" }) 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" }) write_categories() return true