Skip to main content

Hotkeys Popup

kiln keeps a registry of every binding your config declares, with the description and group you gave it. The built-in cheat sheet is a reader of that registry, and so is any replacement you write.

Snippets assume the standard config preamble:

local kiln = require("kiln")
local ui, key = kiln.ui, kiln.key
local th = kiln.theme

The built-in sheet

The stdlib ships a cheat sheet that reads that registry at declare, so it lists whatever your config binds, always current:

key { mods = { "mod" }, key = "s", desc = "show help", group = "kiln",
press = kiln.hotkeys.toggle }

That is the whole wiring, and it is exactly what the default config does. The API is three functions and one field:

SymbolMeaning
kiln.hotkeys.show(s?)open the sheet on screen s (default: focused)
kiln.hotkeys.close()close it
kiln.hotkeys.toggle(s?)one or the other
kiln.hotkeys.openthe screen the sheet is open on, or nil

The stock sheet:

  • groups bindings by their group field, in first-seen declaration order, and deals the groups into three columns of roughly equal height
  • shows each binding's resolved chord (super+s, not mod+s) next to its desc
  • floats centered in the overlay band over a screen-sized scrim
  • holds the keyboard while open: any key press dismisses it, and nothing leaks to the binding underneath. A press outside the sheet dismisses too.

A range binding (key = "1-9") is one row reading "1-9", not nine expanded rows. There is no update path to manage: the registry is the model and the declare is the render, so a binding added at any time is on the sheet the next time it opens.

Rolling your own

The rest of this page replaces the stock sheet with your own layout. The same registry powers it: kiln.key.all() returns a copy of every binding as written.

Step 1: read the registry

for _, k in ipairs(kiln.key.all()) do
-- k.mods, k.key, k.desc, k.group, k.label
end

k.label is the chord as the user reads it, resolved through kiln.modkey at bind time ("super+s"). k.mods keeps the compact form you wrote, with "mod" unresolved, if you want to format chords yourself. Rebinding a chord replaces its registry row in place, so labels stay unique.

Step 2: group

Bucket the registry by group, keeping first-seen order so the sheet reads in declaration order rather than hash order:

local function grouped_keys()
local groups, order = {}, {}
for _, k in ipairs(kiln.key.all()) do
local g = k.group or "other"
if groups[g] == nil then
groups[g] = {}
table.insert(order, g)
end
table.insert(groups[g], k)
end
return groups, order
end

Step 3: declare the overlay

The popup is ordinary nodes in the screen's tree, declared while a boolean is set and not declared at all when it is not. Two floats in the overlay band: a screen-sized scrim that dismisses on any press outside, and the sheet one z above it:

local hotkeys_open = false

local function declare_hotkeys(s)
if not hotkeys_open then return end
ui.box({
id = "my-hotkeys-scrim",
float = { to = "root", band = "overlay" },
w = s.width, h = s.height,
color = "#00000080",
on_press = function()
hotkeys_open = false
kiln.dirty(s.name)
end,
})
local groups, order = grouped_keys()
ui.column({
id = "my-hotkeys",
float = { to = "root", anchor = "center", band = "overlay", z = 1 },
color = th.bg, radius = 8, pad = 16, gap = 12,
border = { width = 1, color = th.accent },
}, function()
ui.row({ gap = 24, align = { y = "top" } }, function()
for _, g in ipairs(order) do
ui.column({ gap = 6 }, function()
ui.text(g, { size = 12, color = th.accent })
for _, k in ipairs(groups[g]) do
ui.row({ gap = 8, align = { y = "center" } }, function()
ui.box({ color = th.bg2, radius = 3, pad = { x = 6 } },
function() ui.text(k.label, { size = 12 }) end)
ui.text(k.desc or "", { size = 12, color = th.muted })
end)
end
end)
end
end)
end)
end

Press dispatch is innermost-first, so a press on the sheet hits the sheet and never reaches the scrim; a press anywhere else closes.

warning

If you add ids to the chord rows for scripting, use plain strings ("hkkey:" .. k.label). A table id's second element must be numeric (a client handle, a tag index); a string there is not valid.

Step 4: wire it in and toggle it

Call declare_hotkeys(s) at the end of your bar function so the sheet rides every frame while open. Then bind a toggle, and put the binding in the same registry so the sheet lists its own summon key:

ui.bar(s, { edge = "top", color = th.bg }, function()
-- kiln.widgets.taglist, tasklist, clock ...
declare_hotkeys(s)
end)

key { mods = { "mod" }, key = "s", desc = "show keys", group = "system",
press = function()
hotkeys_open = not hotkeys_open
kiln.dirty(screen.focused.name)
end }

kiln.dirty forces the redraw: the boolean is plain Lua state, and changing it does not redraw anything on its own.

Step 5 (optional): dismiss on any key

The scrim closes on a press. To match the stock sheet and also close on any keystroke, take the keyboard with kiln.keygrabber while the sheet is open, and route both dismissal paths through one function so the grab is always released:

local hotkeys_grab = nil

local function close_hotkeys()
hotkeys_open = false
if hotkeys_grab ~= nil then
hotkeys_grab:stop()
hotkeys_grab = nil
end
kiln.dirty()
end

local function open_hotkeys(s)
hotkeys_open = true
hotkeys_grab = kiln.keygrabber {
key = function(ev)
if ev.pressed then close_hotkeys() end
end,
}
kiln.dirty(s.name)
end

key { mods = { "mod" }, key = "s", desc = "show keys", group = "system",
press = function()
if hotkeys_open then close_hotkeys()
else open_hotkeys(screen.focused) end
end }

Use close_hotkeys in the scrim's on_press too. While the grab holds, no binding fires, which is fine: the first key press closes the sheet and releases the keyboard.

See also