Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

window

An xdg_toplevel: an application window the compositor places, tiles, decorates and closes. Use it for a settings window or a dialog; use a panel for anything pinned to the desktop. Rules every role shares are in surfaces.

local open = state("settings_open", false)
local page = state("settings_page", "General")
local TABS = {
    { name = "General", icon = "dialog-information-symbolic" },
    { name = "Display", icon = "display-brightness-symbolic" },
    { name = "Sound", icon = "audio-volume-high-symbolic" },
    { name = "Power", icon = "system-shutdown-symbolic" },
}

local function tab(entry)
    local selected = page:map(function(current) return current == entry.name end)
    return row {
        width = "fill",
        padding = { left = 10, right = 12, top = 8, bottom = 8 },
        spacing = 10,
        radius = 8,
        background = selected:map(function(on) return on and "#89b4fa26" or "#00000000" end),
        on_click = function() page:set(entry.name) end,
        children = {
            icon { name = entry.icon, size = 16, align_v = "center",
                   foreground = selected:map(function(on) return on and "#89b4fa" or "#a6adc8" end) },
            text { content = entry.name, foreground = "#cdd6f4", align_v = "center" },
        },
    }
end

local function switch(label, detail, on)
    return row {
        width = "fill",
        padding = 12,
        spacing = 12,
        children = {
            column { width = "fill", spacing = 2, align_v = "center", children = {
                text { content = label, foreground = "#cdd6f4" },
                text { content = detail, font_size = 12, foreground = "#a6adc8" },
            } },
            rect {
                width = 36, height = 20, radius = 10, align_v = "center",
                background = on and "#89b4fa" or "#45475a",
                children = { rect { width = 14, height = 14, radius = 7, margin = { left = 3, right = 3 },
                    align_h = on and "end" or "start", align_v = "center", background = on and "#ffffff" or "#bac2de" } },
            },
        },
    }
end

local function group(title, rows)
    local divided = {}
    for index, item in ipairs(rows) do
        if index > 1 then divided[#divided + 1] = rect { width = "fill", height = 1, margin = { left = 12, right = 12 }, background = "#313244" } end
        divided[#divided + 1] = item
    end
    return column { width = "fill", spacing = 10, children = {
        text { content = title, font_size = 12, font_weight = 700, foreground = "#89b4fa", margin = { left = 4 } },
        column { width = "fill", radius = 10, background = "#181825", children = divided },
    } }
end

local tabs = { text { content = "Settings", font_size = 15, font_weight = 700, foreground = "#cdd6f4", margin = { left = 10, top = 6, bottom = 10 } } }
for _, entry in ipairs(TABS) do tabs[#tabs + 1] = tab(entry) end

local settings = window {
    id = "settings",
    title = page:map(function(name) return "Settings: " .. name end),
    app_id = "org.example.settings",
    min_size = { width = 480, height = 360 },
    visible = open,
    on_close_request = function() open:set(false) end,
    child = row {
        width = "fill",
        height = "fill",
        background = "#1e1e2e",
        children = {
            column { width = 180, height = "fill", padding = 10, spacing = 2, background = "#181825", children = tabs },
            column { width = "fill", padding = 24, spacing = 18, children = {
                text { content = page, font_size = 22, font_weight = 700, foreground = "#cdd6f4" },
                group("Appearance", {
                    switch("Dark style", "Use dark colours in every window", true),
                    switch("Animations", "Ease panels and popups in and out", true),
                }),
                group("Clock", {
                    switch("Seconds", "Show seconds in the bar clock", false),
                    switch("24-hour time", "Show 13:00 instead of 1:00 PM", true),
                }),
            } },
        },
    },
}

return { settings }

mantle toggle settings_open opens it; the compositor’s close button or keybind closes it through on_close_request. The title follows the selected tab.

Properties

Beyond the shared properties. Every field but id and on_close_request takes a signal and updates the open window in place; a change while it is closed applies when it next opens.

PropertyTypeDefaultBehaviour
idstringRequiredThe surface’s identity across reloads, unique among surfaces. A panel’s or lock’s per-output instances are "{id}@{output}"; output = "active" keeps the bare id
titlestring|Bound""The window title
app_idstring|Bound"mantle-{id}"What compositor window rules match
min_size{ width: number, height: number }|Bound, [0, 8192]NoneAdvisory hint to the compositor; layout does not enforce it. Both keys required, 0 leaves that axis unconstrained. Also the opening size on an axis the compositor leaves to the client (size)
max_size{ width: number, height: number }|Bound, [0, 8192]NoneAdvisory, as min_size. A non-zero axis below min_size’s is refused; also clamps the opening size
decorations"server"|"client"|Bound"server"Who draws the window’s frame: "server" asks the compositor for its decorations, "client" leaves the frame to the app. A compositor without zxdg_decoration_manager_v1 always leaves it to the app; toplevel(id):state().decoration says which was chosen
geometry_insetnumber|Edges|Bound, [0, 256]0Room around the frame for a client-drawn shadow; the compositor sizes and tiles by the frame alone. A number sets all four edges (client-side decoration)
on_close_requestfun()NoneThe user asked to close. The window stays open until the config sets visible = false; without a handler a close request does nothing
visibleboolean|BoundtrueOpens and closes the window; state and id survive
widthLength|Bound, [0, 8192]Fill the windowThe root’s size inside the window, not the window’s (size)
heightLength|Bound, [0, 8192]Fill the windowAs width
on_escapefun()NoneEscape pressed while this surface or a popup under it has the keyboard and no focused field took it: a field with text to clear or an on_cancel keeps its own Escape. Once per press; the innermost shown popup declaring it wins, with no order promised among sibling popups. Never on a surface without keyboard_interactivity
reset_on_close(StateSignal<any>|ScrollSignal)[]{}state and scroll handles written back when the surface stops being shown: a state to its initial, a scroll to the top (reset on close)
childNode|BoundNoneThe one root node; a function child is refused

By default the engine requests server-side decorations and draws none itself. decorations = "client" asks for none, and a compositor without zxdg_decoration_manager_v1 (or one that insists on its choice) decides regardless: the mode it chose is toplevel(id):state().decoration. Changing the property on a reload asks again.

Size

The window’s size is the compositor’s configure. The window node fills it on each axis where it has no width/height of its own; a set one sizes the node inside the window. Its child sizes to its content unless it sets "fill". A geometry_inset grows the surface past that size on each side (client-side decoration).

CompositorOpening size
Tiling (niri, a tiled Hyprland window)The tile the compositor sends
Floating, leaving an axis to the clientA non-zero min_size on that axis, else 640×480, clamped by a non-zero max_size

To set a floating window’s size or position, use compositor rules on app_id, or min_size for the opening size.

How do I…

TaskAnswer
Open a settings window from a keybindBind visible to named state, as in the example; mantle toggle settings_open
Close it when the user clicks the close buttonon_close_request = function() open:set(false) end
Ask before closingConfirm before closing
Make it float, or place itA Hyprland windowrule or niri window-rule matching app_id
Give it a starting sizemin_size, or a compositor rule
Scroll content taller than the windowA column { height = "fill", scroll = scroll("name") } (scroll)
Close it from a button inside itSet its visible state to false from on_click
Draw my own title barCustom title bar
Draw a shadow and rounded corners around my own frameClient-side decoration
Open a menu from itA popup with parent set to the window’s id

Custom title bar

An app that draws its own frame asks the compositor to run the pointer: toplevel(id) takes a window’s id and has three pointer methods, called from an on_press (or an on_drag "start"), three state methods, and :state() (window state).

MethodDoes
:move()Starts an interactive move, as dragging a title bar does
:resize(edge)Starts a resize from "top", "bottom", "left", "right", "top_left", "top_right", "bottom_left" or "bottom_right"; any other value raises
:show_menu()Opens the compositor’s window menu at the press position
:set_maximized(on)Asks for maximize, or restore with false; on must be a boolean
:set_minimized()Asks for minimize; nothing asks it back
:set_fullscreen(on)Asks for fullscreen, or restore with false; on must be a boolean

Compositors honour the pointer methods only for the serial of the press that started them, so a call from on_click, a timer or any other callback, on a hidden window, or on a press that landed on a different surface logs a warning and sends nothing. After a request the compositor owns the pointer and may send no release: the engine drops the drag and the armed click, so on_drag gets no "end" and on_click does not fire.

The state methods need no press and work from any callback. A request for a window that is not mapped yet is dropped; do not pair one with move() in the same press, as the move grab starts first.

local frame = toplevel("main")
local GRIP = 6

local function grip(edge, props)
    props.on_press = function(_, button)
        if button == "left" then frame:resize(edge) end
    end
    return rect(props)
end

return {
    window {
        id = "main",
        title = "Notes",
        child = column {
            width = "fill", height = "fill", background = "#1e1e2e",
            children = {
                grip("top", { width = "fill", height = GRIP, cursor = "n-resize" }),
                row {
                    width = "fill", height = 32, padding = { left = 12, right = 12 }, background = "#181825",
                    on_press = function(_, button)
                        if button == "left" then frame:move() else frame:show_menu() end
                    end,
                    children = { text { content = "Notes", foreground = "#cdd6f4", align_v = "center" } },
                },
                grip("bottom_right", { width = GRIP, height = GRIP, align_h = "end", cursor = "se-resize" }),
            },
        },
    },
}

Window state

toplevel(id):state() is a read-only signal of what the compositor last configured the window to be, so the app’s own frame can follow it. It is rewritten only when a configure changes a value. Before the first configure and after the window closes it holds the default: every flag false, no bounds, every capability true, decoration = "client". Reading it never requests a change; :set_maximized, :set_minimized and :set_fullscreen do.

KeyTypeMeaning
activated, maximized, fullscreen, resizingbooleanThe xdg_toplevel state flags
tiled{ left, right, top, bottom }The edges a tiling compositor tiled the window against
bounds{ width, height } or nilThe most room the compositor suggests, in logical pixels
capabilities{ window_menu, maximize, fullscreen, minimize }What the compositor supports; all true when it never says
decoration"server" or "client"The mode the compositor chose
local focused = toplevel("main"):state():map(function(s) return s.activated end)

return {
    window {
        id = "main",
        title = "Notes",
        decorations = "client",
        child = rect {
            width = "fill", height = "fill",
            background = focused:map(function(on) return on and "#1e1e2e" or "#313244" end),
        },
    },
}

Client-side decoration

A window that draws its own frame can also draw its shadow and rounded corners outside it. geometry_inset adds a band around the window’s frame: the buffer is the frame plus the band, the window node fills the whole buffer, and the compositor sizes, tiles and snaps by the frame alone (xdg_surface.set_window_geometry). Configure sizes, min_size and max_size are all the frame’s. The child fills the buffer too if it says "fill"; give the frame a margin of the band, and its shadows and radius draw into the band.

Compositors expect no shadow on a maximized, fullscreen or tiled edge. The engine does not guess: bind geometry_inset to the window state and zero those edges.

local frame = toplevel("main")
local SHADOW = 24

local function band(s)
    if s.maximized or s.fullscreen then return 0 end
    return {
        left = s.tiled.left and 0 or SHADOW, right = s.tiled.right and 0 or SHADOW,
        top = s.tiled.top and 0 or SHADOW, bottom = s.tiled.bottom and 0 or SHADOW,
    }
end
local inset = frame:state():map(band)
local floating = frame:state():map(function(s)
    local t = s.tiled
    return not (s.maximized or s.fullscreen or t.left or t.right or t.top or t.bottom)
end)

local GRIP = 8
local function grip(edge, cursor, props)
    props.background = "#00000000"
    props.cursor = cursor
    props.visible = floating
    props.on_press = function(_, button)
        if button == "left" then frame:resize(edge) end
    end
    return rect(props)
end

return {
    window {
        id = "main",
        title = "Notes",
        decorations = "client",
        geometry_inset = inset,
        child = rect {
            width = "fill", height = "fill",
            children = {
                column {
                    width = "fill", height = "fill", margin = inset, background = "#1e1e2e",
                    radius = floating:map(function(on) return on and 10 or 0 end),
                    shadows = { { color = "#00000080", blur = 18, offset = { x = 0, y = 4 } } },
                    children = {
                        row {
                            width = "fill", height = 32, padding = { left = 12 }, background = "#181825",
                            on_press = function(_, button)
                                if button == "left" then frame:move() else frame:show_menu() end
                            end,
                            children = { text { content = "Notes", foreground = "#cdd6f4", align_v = "center" } },
                        },
                    },
                },
                grip("top", "n-resize", { width = "fill", height = GRIP }),
                grip("bottom", "s-resize", { width = "fill", height = GRIP, align_v = "end" }),
                grip("left", "w-resize", { width = GRIP, height = "fill" }),
                grip("right", "e-resize", { width = GRIP, height = "fill", align_h = "end" }),
            },
        },
    },
}

The rect root stacks its children, so the grips lie over the band’s edges: a transparent background makes each one claim input (input region), and visible = floating removes them when the window is maximized, fullscreen or tiled. The shadow itself is not content and takes no pointer input. Corner grips are more of the same, sized GRIP * 2 and listed after the edges.

Confirm before closing

on_close_request is a request, so it can open a question instead of closing:

local open = state("editor_open", true)
local confirming = state("editor_confirm", false)

local editor = window {
    id = "editor",
    title = "Editor",
    visible = open,
    on_close_request = function() confirming:set(true) end,
    child = column {
        width = "fill", height = "fill", padding = 16, spacing = 8, background = "#1e1e2e",
        children = {
            text { content = "Unsaved changes", foreground = "#cdd6f4" },
            row {
                spacing = 8,
                visible = confirming,
                children = {
                    rect { padding = 8, background = "#f38ba8",
                        on_click = function() confirming:set(false); open:set(false) end,
                        children = { text { content = "Discard" } } },
                    rect { padding = 8, background = "#313244",
                        on_click = function() confirming:set(false) end,
                        children = { text { content = "Cancel", foreground = "#cdd6f4" } } },
                },
            },
        },
    },
}

return { editor }

Gotchas

TrapFix
The close button does nothingAdd on_close_request and set visible to false in it
min_size doesn’t stop the root shrinkingIt is advisory to the compositor; layout does not enforce it
min_size = { width = 400 } is refusedName both axes; 0 leaves one unconstrained
max_size below min_size is refusedKeep every non-zero max_size axis at or above min_size’s, or 0
width = 600 on the window doesn’t resize itThat sizes the window node inside the window; the compositor owns the window’s size
A click on the window’s empty background reaches the window behind itPut the background on a "fill" child, not the window (input region)
No title bar under a compositor without server-side decorationsThe engine draws none; draw your own row (custom title bar), or use compositor rules
decorations = "server" still leaves a bare windowThe compositor has no zxdg_decoration_manager_v1 or chose client-side; check toplevel(id):state().decoration
toplevel("main"):move() warns and does nothingCall it from on_press, not on_click; the press serial is gone by release

See also: surfaces, popup, nodes, signals.

Source: window spec, window, root size.