Skip to content
CyanideX edited this page Aug 5, 2026 · 1 revision

Multi-Panel Splits

Layout with any number of panels separated by independently draggable dividers. Supports fixed, flex, and percentage-based sizing, plus collapsible edge toggle panels.

Quick Start

local wu = GetMod("WindowUtils")
local split = wu.Splitter

-- 3-panel IDE layout: file tree (fixed) + editor (flex 2x) + properties (flex 1x)
split.multi("ide", {
    { width = 200, minWidth = 150, maxWidth = 350, content = drawFileTree },
    { flex = 2, content = drawEditor },
    { flex = 1, minWidth = 180, content = drawProperties },
})

multi(id, panels, opts?)

Layout with any number of panels separated by independently draggable dividers. Each divider only affects its two adjacent panels.

Parameter Type Default Description
id string - Unique splitter ID
panels table - Array of panel definitions (see below)
opts.direction string "horizontal" "horizontal" or "vertical"
opts.grabWidth number ItemSpacing.x Divider width in pixels
opts.minPct number 0.05 Minimum panel fraction (fallback)
opts.defaultPcts table|nil nil Array of initial fractions per panel

Returns: nothing. Read the current layout with getMultiBreakpoints(id) if you need it.

multi() renders nothing and returns immediately when panels is nil or has fewer than 2 entries, when every panel is an edge toggle (no core panel left), or when the available space is too small for the core panels.

Alias: split.m(...)

Option Lifetime

opts.direction is re-read every frame, so a layout can be flipped between horizontal and vertical at runtime.

opts.grabWidth, opts.minPct, and opts.defaultPcts are read only on the first call for a given id (more precisely: on the call that creates the state). Changing them later is silently ignored.

The state is discarded and rebuilt when the number of core panels changes, so adding or removing a core panel does re-apply grabWidth, minPct, and defaultPcts. Adding or removing an edge toggle changes the core count too. Calling splitter.destroy(id) forces a rebuild explicitly.

Panel Definition Fields

Field Type Default Description Read
content function - Renders panel content every frame
width number|string|nil nil Fixed width: pixels or "25%" (horizontal) first call
height number|string|nil nil Fixed height: pixels or "25%" (vertical) first call
flex number 1 Proportional weight for remaining space first call
minWidth number|string|function|nil auto Minimum width: pixels, percentage, or a function returning one every frame
minHeight number|string|function|nil auto Minimum height: pixels, percentage, or a function returning one every frame
maxWidth number|string|nil nil Maximum width: pixels or percentage first call
maxHeight number|string|nil nil Maximum height: pixels or percentage first call
autoMin boolean true Auto-detect minimum from icon button width every frame
buttonRowIds table|nil nil Array of controls.ButtonRow ids inside this panel; their measured minimum widths are folded into the panel minimum every frame
anchored boolean false Maintain pixel size on window resize first call
elementId string|nil nil Tutorial elementId for the divider after this panel first call

Size specs accept pixels (200), percentages ("25%"), or nil (flex). Panels with an explicit width/height are "fixed" - they get their requested size first. Remaining space is distributed to flex panels proportionally by flex weight.

width, height, and flex only seed the initial breakpoints, so changing them later has no effect: the user's divider positions (or the restored cache) own the layout from then on. maxWidth/maxHeight are read from the panel array captured on the first call, so later changes to those two are also ignored; the min fields and content are re-read from the array you pass each frame.

When autoMin is true (default) and no explicit min is set, panels automatically get a minimum width equal to one icon button plus window padding - preventing panels from collapsing to zero. opts.minPct is only used as the minimum when a panel sets autoMin = false and gives no explicit min.

When anchored is true, the panel keeps its current pixel size when the window is resized. Non-anchored (flex) panels absorb the resize delta proportionally. Manual divider dragging still works normally on anchored panels, and the post-drag size becomes the new anchor reference. If all panels in a layout are anchored, proportional scaling applies to all of them (same behavior as no anchoring).

Automatic Persistence

All multi-splitter breakpoints automatically persist across sessions when the consumer mod calls Update(windowName). No extra code is needed. The layout cache saves breakpoint positions when the user releases a divider (and when a layout is reset), and restores them on next load.

Update(windowName) is what supplies the window context used to derive the cache key, so a splitter rendered by a mod that never calls Update is not persisted.

Restore is skipped when the cached array length does not match the current divider count, so changing the number of core panels in a new version of your mod discards the old positions instead of applying them wrongly.

To reset a user's persisted layout, delete the corresponding file in WindowUtils/data/cache/.

-- 3-panel IDE layout: file tree (fixed) + editor (flex 2x) + properties (flex 1x)
split.multi("ide", {
    { width = 200, minWidth = 150, maxWidth = 350, content = drawFileTree },
    { flex = 2, content = drawEditor },
    { flex = 1, minWidth = 180, content = drawProperties },
})

Edge Toggle Panels

The first and/or last panel in a multi() call can be a toggle panel - a collapsible sidebar that slides in/out with a clickable bar. Set toggle = true on the panel definition.

Field Type Default Description Read
toggle boolean false Makes this panel a collapsible edge toggle every frame
content function - Renders panel content every frame
size number|string 0 Expanded size (pixels or percentage) every frame
defaultOpen boolean true Initial open state first call
speed number 6.0 Animation speed multiplier first call
animate boolean true Enable the slide animation first call
barWidth number ItemSpacing.x Toggle bar thickness first call
barBg table|nil transparent Idle bar background {r,g,b,a} first call
persist string|boolean|nil nil Explicit persistence mode, same values as splitter.toggle. Needs windowName first call
windowName string|nil nil Window name for explicit persistence first call
elementId string|nil nil Tutorial elementId for the toggle bar first call

Edge toggle panels get their state through the same constructor as splitter.toggle, so everything except toggle, content, and size is captured on the first call for that panel and ignored afterwards. The derived state ids are <id>_tgl_lead and <id>_tgl_trail, which is what splitter.getToggle, splitter.setToggle, and the other toggle state functions expect.

Omitting size is not an error: the panel resolves to 0 pixels and only the bar is drawn.

An edge toggle bar always toggles on a single click. toggleOnClick is accepted by the state constructor but has no effect here, and edge toggle panels cannot use expand mode.

Toggle panels can only be placed at the edges (first or last panel). toggle = true on any other panel is ignored and the panel is treated as a normal draggable core panel. Core panels between the edges remain draggable as normal.

-- Sidebar that collapses + main content + inspector that collapses
split.multi("app", {
    { toggle = true, size = 220, content = drawSidebar },         -- left edge toggle
    { content = drawMainContent },                                 -- core (flex)
    { toggle = true, size = 280, defaultOpen = false, content = drawInspector },  -- right edge toggle
})

resetMulti(id)

Reset a multi-splitter to its default breakpoints (the ones computed on the first call, before any persistence was applied). Cancels divider animations and writes the reset positions to the layout cache. No-op if the id has not rendered yet.

getMultiBreakpoints(id)

Query the current breakpoints array for a multi-splitter. Returns the live breakpoints if the splitter has rendered, or cached breakpoints from disk if it hasn't rendered yet (lifecycle-safe).

Parameter Type Description
id string Multi-splitter identifier

Returns: table|nil - array of breakpoint fractions, or nil if not found

The array has one entry per divider, so #result is the core panel count minus one. Each entry is a cumulative fraction (0-1) measured from the leading edge of the core area.

Treat the result as read-only. This returns the library's live internal table, not a copy. The same table is also handed to the layout cache, so writing to it corrupts the running layout and whatever gets flushed to disk. Copy it before you keep or modify it:

local bps = {}
for i, v in ipairs(split.getMultiBreakpoints("ide") or {}) do bps[i] = v end

Examples

3-Panel IDE Layout

split.multi("ide", {
    { width = 200, minWidth = 120, content = function()
        ImGui.Text("File Tree")
    end },
    { flex = 2, content = function()
        ImGui.Text("Editor")
    end },
    { flex = 1, minWidth = 150, content = function()
        ImGui.Text("Properties")
    end },
})

Edge Toggles with Multi-Panel Core

split.multi("fullApp", {
    { toggle = true, size = 240, content = drawNavigation },
    { flex = 2, content = drawWorkspace },
    { flex = 1, minWidth = 200, content = drawDetails },
    { toggle = true, size = 300, defaultOpen = false, content = drawHelp },
})

Anchored Panels (Fixed on Resize)

-- Sidebar and detail panel stay fixed; main content absorbs resize
split.multi("my_layout", {
    { content = drawSidebar, width = 200, minWidth = 120, anchored = true },
    { content = drawMain, flex = 1 },
    { content = drawDetails, width = 150, minWidth = 100, anchored = true },
}, { direction = "horizontal" })

When the window resizes, the sidebar stays at 200px and the detail panel stays at 150px. The main flex panel grows or shrinks to fill whatever space remains. If the flex panel hits its minimum size, the anchored panels shrink proportionally to accommodate.

Clone this wiki locally