Table of Contents
- Example: tiling pages
- What this config is trying to teach
- How to use it
- Validate it
- Run it in the nested backend
- Run it with one initial client
- Test it on tty with the wrapper script
- Controls
- The main idea
- The Lua-owned state tables
- Lock the camera to page-sized views
- Page geometry starts with small helpers
- Tiling layout: hover-split tree instead of master/stack
- Relayout is the center of the whole config
- Page switching is camera movement, not workspace magic
- Floating and fullscreen are Lua-owned
- Hover focus plus floating drag behavior
- The key hook owns most of the user-facing behavior
- Customization points
- When to use this example
Example: tiling pages
File:
examples/tiling.lua
This is the most advanced example config in the repository.
It is trying to feel like a tiling window manager without requiring a native workspace runtime. Instead, it builds that experience in Lua on top of the shared canvas model.
What this config is trying to teach
This example is teaching two big ideas at once:
- how to build a page/workspace-like UX in Lua
- how to build a tiling-style layout in Lua
It matters because it shows that evil can support a believable tiling workflow even though the runtime itself is still fundamentally a shared canvas compositor.
How to use it
Validate it
cargo run --bin evil -- --check-config --config examples/tiling.lua
Run it in the nested backend
cargo run --bin evil -- --backend winit --config examples/tiling.lua
Run it with one initial client
cargo run --bin evil -- --backend winit --config examples/tiling.lua --command foot
Test it on tty with the wrapper script
scripts/start-tty-tiling.sh
The config also autostarts the example terminal command, so you should normally get an initial tiled window automatically.
To really understand the example, open several windows and then try page switches, hover focus, floating mode, and fullscreen mode.
Controls
Keyboard
| Keys | Action |
|---|---|
Super+Return / Super+T / Super+W / Super+E / Super+D / Super+X |
Launch terminal, kitty, browser, file-manager, launcher, X11 test |
Super+Shift+S |
Screenshot helper |
Super+Q |
Close focused window |
Super+H / Super+L |
Previous / next page |
Super+1...Super+9 / Super+0 |
Jump to page 1...9 / 10 |
Super+Shift+H / Super+Shift+L |
Send focused window to previous / next page |
Super+Shift+1...Super+Shift+0 |
Send window directly to page 1...10 |
Super+J / Super+K |
Focus next / previous window on current page |
Super+Space |
Toggle floating mode for focused window |
Super+F |
Toggle fullscreen mode for focused window |
Mouse
| Action | Behavior |
|---|---|
| Hover over a window | Focus that window and follow its page |
| Click a tiled window | No action (hover already handles focus) |
| Click empty space | Keep current active window |
Super+left click on floating window |
Begin interactive move |
Super+right click on floating window |
Begin interactive resize |
The main idea
This config treats the canvas as:
- 10 fixed screen-sized pages
- laid out side by side horizontally
Each page behaves like a desktop/workspace. The camera moves between pages instead of freely panning. Windows are assigned to pages, tiled inside those pages, and can be moved between them.
The Lua-owned state tables
The example keeps several pieces of state in Lua tables:
local current_page = 1
local page_for_window = {}
local floating_for_window = {}
local floating_bounds = {}
local fullscreen_for_page = {}
local page_layout_roots = {}
What these are doing
current_pageremembers which page the camera is showingpage_for_windowremembers which page each window belongs tofloating_for_windowtracks the Lua-owned floating modefloating_boundsstores saved bounds for floating windowsfullscreen_for_pagetracks which window, if any, fills an entire pagepage_layout_rootsstores the per-page binary split trees for tiled windows
These tables are the heart of the example. They are how Lua remembers the policy state that the runtime does not store as a built-in workspace system.
Lock the camera to page-sized views
The config intentionally disables free camera behavior:
evil.config({
backend = "winit",
canvas = {
min_zoom = 1.0,
max_zoom = 1.0,
zoom_step = 1.0,
pan_step = 0,
allow_pointer_zoom = false,
allow_middle_click_pan = false,
allow_gesture_navigation = false,
},
...
})
Why this matters
This turns the shared canvas into something that behaves more like a strip of fixed pages:
- zoom is locked to
1.0 - built-in free pan/zoom paths are disabled
- page changes are now driven by Lua logic instead of generic canvas movement
Without this, the example would feel much less like a tiling/workspace profile.
Page geometry starts with small helpers
A lot of the file becomes easier to understand once you notice how much it relies on tiny helpers like these:
local function clamp_page(page)
if page < 1 then
return 1
end
if page > PAGE_COUNT then
return PAGE_COUNT
end
return page
end
local function page_origin_x(page, screen_w)
return (page - 1) * screen_w
end
local function page_rect(page, screen)
return {
x = page_origin_x(page, screen.screen_w),
y = 0,
w = screen.screen_w,
h = screen.screen_h,
}
end
local function page_inner_rect(page, screen)
local rect = page_rect(page, screen)
return {
x = rect.x + OUTER_GAP,
y = rect.y + OUTER_GAP,
w = math.max(1, rect.w - (OUTER_GAP * 2)),
h = math.max(1, rect.h - (OUTER_GAP * 2)),
}
end
Key insight
The example answers simple questions with small helpers instead of building a "workspace object": what page is valid? where does page 4 begin? what rectangle is one page? what inner rectangle remains after margins?
Tiling layout: hover-split tree instead of master/stack
The current layout no longer uses a master/stack model. It stores each page as a binary split tree and inserts new tiled windows by splitting the tiled window under the pointer.
The tree nodes look like this:
local function leaf_node(window_id)
return {
kind = "leaf",
window_id = window_id,
}
end
and the split direction is chosen from the target window's shape:
local function split_axis_for_bounds(bounds)
if bounds.w >= bounds.h then
return "vertical"
end
return "horizontal"
end
When a split is applied, the layout leaves a small gap between the two children:
local function split_bounds(bounds, axis)
if axis == "vertical" then
local available_w = math.max(1, bounds.w - TILE_GAP)
local first_w = math.max(1, math.floor(available_w / 2))
local second_w = math.max(1, bounds.w - first_w - TILE_GAP)
...
end
...
end
Key insight
Dynamic tiler behavior: look at the tiled window under the pointer, cut that tile in half, place the new window into the new half, with a visible gap between siblings and an outer margin.
Relayout is the center of the whole config
The most important orchestrating function is relayout(state):
local function relayout(state)
local screen = screen_metrics(state)
if not screen then
return false
end
for page = 1, PAGE_COUNT do
local fullscreen_id = fullscreen_for_page[page]
local fullscreen_window = fullscreen_id and window_for_id(state, fullscreen_id) or nil
if fullscreen_window and page_of_window(fullscreen_window.id) == page then
apply_fullscreen_layout(state, page, screen, fullscreen_window)
else
fullscreen_for_page[page] = nil
apply_tiled_layout(state, page, screen)
apply_floating_layout(state, page, screen)
end
end
return true
end
Why this matters
Almost every interesting operation eventually calls relayout(...).
That is the reason the config feels coherent:
- map a window → relayout
- unmap a window → relayout
- toggle floating → relayout
- toggle fullscreen → relayout
- move a window to another page → relayout
This is a strong example of using one "recompute the whole policy truth" function instead of scattering layout mutations everywhere.
Page switching is camera movement, not workspace magic
The helper that makes page switching feel real is show_page(state, page):
local function show_page(state, page)
local screen = screen_metrics(state)
if not screen then
return false
end
local target_page = clamp_page(page)
local target_x = page_origin_x(target_page, screen.screen_w)
local dx = target_x - screen.viewport_x
local dy = -screen.viewport_y
evil.canvas.pan(dx, dy)
local focus = page_focus_candidate(state, target_page)
if focus then
if state.focused_window_id ~= focus.id then
evil.window.focus(focus.id)
end
elseif state.focused_window_id ~= nil then
evil.window.clear_focus()
end
current_page = target_page
return true
end
Key insight
A "workspace switch" is just: compute target page origin → pan the camera → update focus → remember the page number in Lua state. Pure shared-canvas philosophy.
Floating and fullscreen are Lua-owned
Both floating and fullscreen are tracked in Lua tables (floating_for_window,
fullscreen_for_page) and interpreted by relayout(). The runtime does not
need native float/fullscreen toggles for Lua to implement usable modes.
The key pattern: toggle a Lua flag → call relayout() → everything recomputes
consistently. When a page is fullscreened, other windows on that page are
pushed outside the normal page strip instead of being unmapped.
Hover focus plus floating drag behavior
The custom focus hook now gives focus on hover instead of waiting for a click:
if ctx.reason == "pointer_motion" and ctx.window and not ctx.window.exclude_from_focus then
local target_page = page_of_window(ctx.window.id)
show_page(ctx.state, target_page)
if ctx.state.focused_window_id ~= ctx.window.id then
evil.window.focus(ctx.window.id)
end
return
end
Clicks are still used for floating-window interactions:
if ctx.reason == "pointer_button" and ctx.pressed then
if ctx.window and not ctx.window.exclude_from_focus then
local target_page = page_of_window(ctx.window.id)
show_page(ctx.state, target_page)
if floating_for_window[ctx.window.id] and ctx.modifiers and ctx.modifiers.super then
if ctx.button == BTN_LEFT then
evil.window.begin_move(ctx.window.id)
elseif ctx.button == BTN_RIGHT then
evil.window.begin_resize(ctx.window.id, resize_edges_for_floating_window(ctx))
end
end
return
end
return
end
Key insight
Tiled windows become active on hover. Floating windows use Super+mouse
drag/resize. Empty-space clicks do not clear focus. This keeps the tiling
tree from fighting with direct drag gestures.
The key hook owns most of the user-facing behavior
Although the file still declares normal bindings, the real meaning of many keys lives in evil.on.key:
evil.on.key = function(ctx)
if handle_page_number_shortcuts(ctx) then
return
end
if handle_page_cycle_shortcuts(ctx) then
return
end
if handle_focus_cycle_shortcuts(ctx) then
return
end
if handle_mode_shortcuts(ctx) then
return
end
handle_spawn_and_close_shortcuts(ctx)
end
Why this matters
This shows a common evil pattern:
- bindings make sure the runtime sees the key path
- the key hook decides the higher-level policy meaning
That gives the config a lot of flexibility while keeping the key handling organized into named helper families.
Customization points
| What | Where |
|---|---|
| Number of pages | PAGE_COUNT |
| Outer margin | OUTER_GAP |
| Gap between tiled windows | TILE_GAP |
| Default floating size | default_floating_bounds |
| Focus order rules | compare_windows_for_focus |
| Page key behavior | handle_page_*_shortcuts |
When to use this example
Use this if you want:
- the most advanced Lua policy example in the repo
- a real example of workspace-like behavior built on the canvas model
- a hover-focused, split-the-tile-under-the-pointer tiling profile
- a demonstration of how much policy can live in Lua without changing Rust first
evil wiki
- Home
- Getting started
- Architecture and philosophy
- Configuration guide
- Writing your first config
- Lua basics for evil users
- Lua API guide
- Lua API cheat sheet
- Hooks guide
- Hook payload summary
- Queries, commands, and actions
- Drawing and visuals
- Example configs overview
- Example shared helpers and rules
- IPC protocol
- Testing and debugging
- Contributor validation matrix
- Feature status and limitations
- Feature and support matrix
- Recipes