1
0
Fork 0
9 Example Tiling
Agent edited this page 2026-05-26 05:18:08 -04:00

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:

  1. how to build a page/workspace-like UX in Lua
  2. 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_page remembers which page the camera is showing
  • page_for_window remembers which page each window belongs to
  • floating_for_window tracks the Lua-owned floating mode
  • floating_bounds stores saved bounds for floating windows
  • fullscreen_for_page tracks which window, if any, fills an entire page
  • page_layout_roots stores 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