Skip to content

Tiling Panes (PanelTree)

PanelTree tiles widgets into a tree of non-overlapping, resizable panes. It is the tiling counterpart to the WindowManager: instead of floating windows that overlap, a PanelTree always partitions the full area, so every pane gets a dedicated slot. Panes can be split, focused, closed, dragged to a new position, docked against another pane's edge, and resized by their dividers — all while still forwarding keyboard and mouse input to their content widgets.

When to use this

Use PanelTree when your UI is a set of panels that should tile rather than float:

  • IDE-style layouts: an editor next to a file tree and a terminal

  • Dashboards where each panel owns a fixed, non-overlapping region

  • Split views the user can rearrange, resize, and grow/shrink at runtime

If you want overlapping, desktop-style windows, use WindowManager instead. If you want a fixed, non-interactive arrangement, prefer the container-based layouts in layout.md.

Basic pieces

A PanelTree owns a tree of panes:

  • A PaneLeaf holds one piece of content and an optional title.

  • A PaneSplit arranges child panes along an axis with draggable dividers.

content is anything that renders — a widget with render(w, rect, buf) (and optionally handle_key! / handle_mouse!), or a Model (rendered via view, driven via update!). Construct a tree from a single starting pane:

julia
pt = Tachikoma.PanelTree(my_widget; title="main", chrome=:bars)
  • chrome=:bars draws a title bar on each pane; the focused pane is marked with a focus ring. Pass chrome=:none for borderless panes.

  • alive lets the tree prune panes whose content has exited (see prune!).

Tiling demo

Splitting the focused pane right (s) and down (v), cycling focus (f), then dragging the editor pane's title bar across to dock it on another pane's edge — the drop preview follows the cursor and the layout re-tiles on release:

julia
import Tachikoma
using Match

# Each pane just holds a scrollable list so the splits are easy to tell apart.
_pane(name) = Tachikoma.ScrollPane(["$name · line $i" for i in 1:12]; following=false)

@kwdef mutable struct _PTAnim <: Tachikoma.Model
    pt::Tachikoma.PanelTree = Tachikoma.PanelTree(_pane("editor"); title="editor")
    n::Int = 1
end

# Keys split / focus / close the panes; anything else falls through to the
# focused pane's own widget.
function Tachikoma.update!(m::_PTAnim, evt::Tachikoma.KeyEvent)
    @match (evt.key, evt.char) begin
        (:char, 's') => (m.n += 1; Tachikoma.split_pane!(m.pt, _pane("pane $(m.n)"); horizontal=true,  title="pane $(m.n)"))
        (:char, 'v') => (m.n += 1; Tachikoma.split_pane!(m.pt, _pane("pane $(m.n)"); horizontal=false, title="pane $(m.n)"))
        (:char, 'f') => Tachikoma.focus_next!(m.pt)
        (:char, 'w') => Tachikoma.close_pane!(m.pt)
        _            => Tachikoma.handle_key!(m.pt, evt)
    end
end

# Mouse drives move / dock / resize directly.
Tachikoma.update!(m::_PTAnim, evt::Tachikoma.MouseEvent) = Tachikoma.handle_mouse!(m.pt, evt)

Tachikoma.view(m::_PTAnim, f::Tachikoma.Frame) = Tachikoma.render(m.pt, f.area, f.buffer)

Tachikoma.app(_PTAnim())
panel_tree_demo example

Commands

PanelTree deliberately has no built-in key bindingshandle_key! forwards straight to the focused pane's content, so the application owns every command. Wire these into your update!:

julia
split_pane!(pt, content; horizontal=true)   # split focused pane; true = side-by-side, false = stacked
focus_next!(pt); focus_prev!(pt)            # cycle focus between panes
close_pane!(pt)                             # close the focused pane (never the last one)
focused_pane(pt); focused_content(pt)       # the focused leaf / its content
panes(pt); pane_count(pt)                   # all leaves (left→right, top→bottom) / their count

split_pane! inserts the new pane beside the focused one and focuses it, then returns the new PaneLeaf. close_pane! collapses the surrounding split so the remaining panes reclaim the space, and refuses to close the final pane.

Mouse: move, dock, and resize

handle_mouse! gives you direct manipulation with no extra wiring:

  • Move — drag a pane's title bar; a drop preview follows the cursor.

  • Dock — release over another pane's edge (left/right/top/bottom) to re-split and dock there, or over its center to swap the two panes' contents.

  • Resize — drag a divider between panes to grow one and shrink its neighbour.

  • Focus — click any pane to focus it.

is_moving(pt) reports whether a drag is in progress, and cancel_move!(pt) aborts it (e.g. on Escape).

Forwarding input

The integration is the same shape as any container widget — forward events in update! and render in view:

julia
function Tachikoma.update!(m::MyModel, evt::Tachikoma.Event)
    evt isa Tachikoma.KeyEvent   && Tachikoma.handle_key!(m.pt, evt)
    evt isa Tachikoma.MouseEvent && Tachikoma.handle_mouse!(m.pt, evt)
end

function Tachikoma.view(m::MyModel, f::Tachikoma.Frame)
    Tachikoma.render(m.pt, f.area, f.buffer)
end

Content widgets keep their own key and mouse bindings — PanelTree forwards events to the focused pane transparently.

API summary

  • PanelTree(content; title="", chrome=:bars, alive=nothing)

  • split_pane!(pt, content; horizontal=true, title="", after=true, at=focused_pane(pt))PaneLeaf

  • close_pane!(pt, leaf=focused_pane(pt))Bool

  • focus_next!(pt), focus_prev!(pt)

  • focused_pane(pt), focused_content(pt)

  • panes(pt), pane_count(pt)

  • prune!(pt) — drop panes whose content has exited (per alive); returns false when all are dead

  • is_moving(pt), cancel_move!(pt)

  • handle_key!(pt, evt), handle_mouse!(pt, evt), render(pt, area, buf)