Skip to main content

Custom Layouts

A layout in kiln is not an object or a class. It is one function:

function my_layout(clients, area, tag)
-- declare cells for the clients
end

The runtime calls it every frame with the tiled clients of the visible tag, in stacking order, inside a workarea container it has already opened. The function declares ui containers and one kiln.widgets.client(c) leaf per client; the solver turns those declarations into geometry and configures every client to its cell. There is no arrange pass, no geometry storage, and nothing to invalidate: the function just runs again whenever the screen redraws.

1. The smallest layout

A vertical stack: one column, every client a growing cell.

local kiln = require("kiln")
local ui = kiln.ui

local function stack(cs, area, t)
if #cs == 0 then
return
end
local gap = (t and t.gap) or kiln.theme.gap
ui.column({ w = "grow", h = "grow", gap = gap }, function()
for _, c in ipairs(cs) do
kiln.widgets.client(c)
end
end)
end

Two things every layout should copy from this:

  • Guard the empty case. An empty "grow" container is not invisible to the solver: it is a sibling that takes its share of space. Declaring one next to real cells hands half the screen to a hole.
  • Read the gap from the tag with a theme fallback, so per-tag t.gap overrides work.

kiln.widgets.client(c) declares the full composite (border, titlebar, surface). Sizing accepts numbers (pixels), "grow", "fit", and percent strings like "60%".

2. A two-column layout

Split the client list in half, one column each side:

local function twocol(cs, area, t)
if #cs == 0 then
return
end
local gap = (t and t.gap) or kiln.theme.gap
local half = math.ceil(#cs / 2)
ui.row({ w = "grow", h = "grow", gap = gap }, function()
ui.column({ w = "grow", h = "grow", gap = gap }, function()
for i = 1, half do
kiln.widgets.client(cs[i])
end
end)
if #cs > half then
ui.column({ w = "grow", h = "grow", gap = gap }, function()
for i = half + 1, #cs do
kiln.widgets.client(cs[i])
end
end)
end
end)
end

The second column is only declared when it has clients, for the empty-container reason above. With one client, the layout degrades to a single full column automatically.

3. Read the tag's parameters

Layouts take their knobs from plain tag properties. The stock layouts read master_width_factor (default 0.5), master_count (1), column_count (1), gap (theme default), and carousel_width (0.5). Writing any of them redraws the screen, so a keybinding that nudges master_width_factor reshapes your layout live with no extra wiring.

To make the left column's width configurable, size it with a percent string derived from the factor, the same way the stock tile does:

local function twocol(cs, area, t)
if #cs == 0 then
return
end
local f = (t and t.master_width_factor) or 0.5
local gap = (t and t.gap) or kiln.theme.gap
local half = math.ceil(#cs / 2)
-- The percent form only makes sense when both columns exist.
local left_w = #cs > half and (f * 100) .. "%" or "grow"
ui.row({ w = "grow", h = "grow", gap = gap }, function()
ui.column({ w = left_w, h = "grow", gap = gap }, function()
for i = 1, half do
kiln.widgets.client(cs[i])
end
end)
if #cs > half then
ui.column({ w = "grow", h = "grow", gap = gap }, function()
for i = half + 1, #cs do
kiln.widgets.client(cs[i])
end
end)
end
end)
end

Now mod bindings that adjust t.master_width_factor (see the keybindings tutorial) drive your layout too.

4. The model: how the stock tile works

The stock tile is the same two moves at a slightly larger scale, and it is worth internalizing as the idiom. Simplified to its shape:

local function tile(cs, area, t)
if #cs == 0 then
return
end
local f = (t and t.master_width_factor) or 0.5
local n = math.min((t and t.master_count) or 1, #cs)
local gap = (t and t.gap) or kiln.theme.gap
local stacked = #cs > n
ui.row({ w = "grow", h = "grow", gap = gap }, function()
-- The master run: a percent-width column while a stack exists,
-- the whole area when it does not.
ui.column({ w = stacked and (f * 100) .. "%" or "grow",
h = "grow", gap = gap }, function()
for i = 1, n do
kiln.widgets.client(cs[i])
end
end)
-- The stack, only when non-empty.
if stacked then
ui.column({ w = "grow", h = "grow", gap = gap }, function()
for i = n + 1, #cs do
kiln.widgets.client(cs[i])
end
end)
end
end)
end

Open a container, fill it with runs of grow cells, never declare an empty run. Everything else (the split axis, variants, column counts) is arrangement of the same parts.

5. Register it

Assign the function to a tag:

tag.new { name = "code", screen = s, layout = twocol }

or at runtime:

screen.focused.selected_tag.layout = twocol

To include it in the layout cycle (kiln.layout.next, the layoutbox press, mod+space style bindings), set the cycle list; it is yours to override:

kiln.layout.list = {
kiln.layout.tile,
twocol,
kiln.layout.max,
kiln.layout.floating,
}
note

kiln.layout.name(fn) only knows the built-in families, so the stock kiln.widgets.layoutbox shows ? for a custom layout, and kiln.widgets.layoutlist has no label for it. If you want a name in your bar, render your own indicator, for example t.layout == twocol and "twocol" or kiln.layout.name(t.layout).

6. When you need area, and when you do not

Most layouts never touch the area argument: sizing with "grow" and percent strings lets the solver do all division, and that is the recommended default. area is { x, y, width, height }, the box your layout's container fills, for the cases that genuinely need pixel arithmetic:

  • fixed pixel column widths (the stock carousel computes its strip offset from area.width)
  • centered floats sized relative to the workarea (the stock magnifier sizes its focused float as area.width * f)

area comes from the previous frame's solve, so it is one frame stale after a bar or gap change; the first frame falls back to the whole screen. Treat it as a good approximation, not a promise, and prefer solver-driven sizing wherever the design allows.

Complete example

local kiln = require("kiln")
local ui = kiln.ui

local function twocol(cs, area, t)
if #cs == 0 then
return
end
local f = (t and t.master_width_factor) or 0.5
local gap = (t and t.gap) or kiln.theme.gap
local half = math.ceil(#cs / 2)
local left_w = #cs > half and (f * 100) .. "%" or "grow"
ui.row({ w = "grow", h = "grow", gap = gap }, function()
ui.column({ w = left_w, h = "grow", gap = gap }, function()
for i = 1, half do
kiln.widgets.client(cs[i])
end
end)
if #cs > half then
ui.column({ w = "grow", h = "grow", gap = gap }, function()
for i = half + 1, #cs do
kiln.widgets.client(cs[i])
end
end)
end
end)
end

screen.on("added", function(s)
tag.new { name = "main", screen = s, layout = twocol }
s.tags[1]:view()
end)

See also