Documentation
¶
Overview ¶
Package tree renders a hierarchy as an outline: one row per visible node, indented by depth, with a disclosure control on any node that has children (ADR-0176).
The package is UI-free at this level. This file declares the input model, its validation, and the caller-owned view state; layout.go flattens a (Tree, State) pair into the row sequence a renderer draws, and answers nothing about pixels. The split mirrors icicle (ADR-0160) and sankey (ADR-0159): everything worth testing is decided before a binding is imported.
Why not egui_ltreeview ¶
imzero2 had a tree binding before this one, and nothing adopted it. Its node commands went into a process-global register drained by a separate Tree() call, so emission was decoupled from placement and two trees in one frame had to alternate; expansion state lived in Rust, so Go re-marshalled every node every frame even when the whole tree was collapsed; and a row could carry a label and nothing else. See ADR-0176 for the full accounting.
Index ¶
- Variables
- func RowOf(rows []Row, node int32) int
- type Column
- type Input
- type Result
- type Row
- type State
- func (s *State) Bind(t Tree)
- func (s *State) ClearSelection()
- func (s *State) CollapseAll()
- func (s *State) Cursor() int32
- func (s *State) ExpandAll()
- func (s *State) ExpandAncestors(t Tree, node int32)
- func (s *State) IsExpanded(node int32) bool
- func (s *State) IsSelected(node int32) bool
- func (s *State) PendingReveal() (node int32)
- func (s *State) Reveal(node int32)
- func (s *State) SelectOnly(node int32)
- func (s *State) Selection(dst []int32) []int32
- func (s *State) SelectionLen() int
- func (s *State) SetCursor(node int32)
- func (s *State) SetDefaultExpanded(open bool)
- func (s *State) SetExpanded(node int32, open bool)
- func (s *State) SetSelected(node int32, on bool)
- func (s *State) ToggleExpanded(node int32) (open bool)
- type Tree
Constants ¶
This section is empty.
Variables ¶
var PackageProps = packageprops.Props{ WASMWASI: packageprops.WASMBlocked, WASMJS: packageprops.WASMBlocked, WASMFreestanding: packageprops.WASMBlocked, }
PackageProps records this package's curated properties (ADR-0080). The model and the flatten are UI-free, but render.go's egui2 bindings dependency blocks all WASM targets, as for every widget package.
Functions ¶
Types ¶
type Column ¶
type Column struct {
// Header is the column title. A tree whose columns are all untitled shows
// no header row at all — a one-column outline with a header bar over it
// reads as a table that forgot its other columns.
Header string
// Width is the column's width in points; 0 takes a default. It is a floor
// as well as a starting point — the column grows to fit wider content and
// to a drag, but never shrinks below it. See pushColumns for the etable
// sizing pass that makes the floor necessary rather than merely tidy.
Width float32
// Resizable lets the user drag this column's right edge.
Resizable bool
// Cell draws the column's content for one row, inside a padded cell Ui.
// On [Input.Outline] it replaces the plain label and runs after the indent
// and the disclosure control, so a host can put a badge, a count or a
// secondary tint on the label without giving up the outline chrome.
//
// A nil Cell on a host column emits nothing for that column; a nil Cell on
// the outline draws the node's [Tree.Labels] entry.
//
// It takes the whole [Row] rather than the node, because a cell that
// varies on expansion — a count of what a closed section hides, an indent
// guide, a different glyph on a leaf — otherwise has to reach back into
// the [State] it passed in, during the widget's own render pass, to ask
// something the renderer is already holding.
//
// # Three things about this that bite
//
// Interactive widgets are allowed and win the pointer over the row's own
// click sense — that is the arbitration in this file's header comment, and
// it means a per-row control works, at the price of that control's rect no
// longer selecting the row. Plain labels must be emitted Selectable(false)
// or they swallow the row click; the default label does.
//
// And a TRUNCATING label takes the whole width it is offered, so anything
// emitted after it in the same cell is pushed out of view. A count, a
// chip or a glyph that has to survive a long label belongs in a column of
// its own, whose width is reserved before the label is laid out — not
// after the label in this one.
//
// The third is a HEIGHT BUDGET, and it is the one with no diagnostic.
// Content is centred on the row by egui_table's own cell layout, but only
// while it fits [Input.RowHeight]: something taller is pushed down to the
// row's top edge and hangs off the bottom, under the next row and through
// the selection outline, silently (see paddedCell for the egui clamp that
// does it). At the 22-point default a line of body text fits and a DEFAULT
// Button does not — a button is its text plus button_padding.y twice. A
// per-row control therefore wants `.Small()`, which drops that padding and
// the interact_size floor with it, the way disclose draws the disclosure
// control. A host that wants full-size controls in its rows should raise
// [Input.RowHeight] instead.
Cell func(r Row)
}
Column configures one etable column. The first column is always the outline itself and is described by Input.Outline; Input.Columns adds the rest to its right, and their content is entirely the host's.
type Input ¶
type Input struct {
// Ids is the host's widget id stack. Render opens its own IdScope under
// it, so two trees in one frame need only differ in ScopeKey.
Ids *c.WidgetIdStack
// ScopeKey names this tree within the host's id space; empty uses "tree".
// Two trees sharing a host and a ScopeKey share widget ids, which shows up
// as one of them never seeing a click.
ScopeKey string
// Tree is the hierarchy. Validated every frame — the flatten refuses a
// broken one rather than drawing a plausible outline with arbitrary
// subtrees missing.
Tree Tree
// State is the host-owned expansion, selection and cursor. Required: a
// tree with nowhere to record what is open cannot be drawn. Render binds
// it to Tree before reading anything out of it, so it survives a rebuild
// exactly as far as [Tree.Keys] lets it.
State *State
// Outline configures the first column — the one carrying the indent, the
// disclosure control and the label. Its Cell overrides the label.
Outline Column
// Columns are the host's own columns, drawn to the right of the outline.
Columns []Column
// RowHeight is the fixed height of every row; 0 takes defaultRowHeight.
RowHeight float32
// MaxHeight caps the vertical extent the table claims. Feed it the pane's
// measured height, from c.CapturePaneSize, with a constant to fall back on
// for the first frame — the probe answers one frame late and not at all on
// the first. That is what every in-repo adopter does.
//
// Left at 0 the table falls back to endETable's auto-fit heuristic, capped
// by ETABLE_AUTOFIT_CAP_PX, which a tree of any length overruns. That is
// only the right answer in a host that already bounds the tree tightly and
// knows it stays short.
MaxHeight float32
// Indent is the horizontal step per depth level; 0 takes defaultIndent.
Indent float32
// Striped tints odd rows. Off by default: an indent already gives the eye
// a per-row landmark, and a zebra competes with the selection fill for the
// same signal. The stripe is painted by the row block, not by etable's own
// Striped — etable paints its zebra in cell_ui, which runs after the row
// block and would cover it.
Striped bool
// WidthEpoch, when non-zero, is handed to the table as its apply
// generation (ADR-0151): the binding writes the column widths into the
// crate's state only when it changes, so a host that resolves widths from
// stored overrides bumps it when they change and leaves the reader's live
// drag alone in between. Zero keeps the crate's own state, as before.
WidthEpoch uint32
// MinColumnWidth, when positive, is the drag floor for every column in
// place of each column's seed width; MaxColumnWidth, when positive, the
// ceiling. A host persisting widths passes the bounds it stores, so a
// column cannot be dragged below what will come back on the next load.
MinColumnWidth float32
MaxColumnWidth float32
}
Input is the per-frame render request.
type Result ¶
type Result struct {
// Rows is the row sequence drawn this frame, borrowed from the State's
// scratch: valid until the next Render on that State, and not to be
// retained. Useful for "how many rows are showing" and for mapping a node
// to its row without a second flatten.
Rows []Row
// Clicked is the node whose row was clicked. The selection has already
// been updated for it, honouring ctrl (toggle) and shift (extend from the
// cursor).
Clicked int32
// Activated is the node whose row was double-clicked — "open this", by the
// convention every file manager uses. An interior node is also toggled;
// the host decides what a leaf activation means.
Activated int32
// Toggled is the node whose expansion changed, whether from its disclosure
// control or from a double-click.
Toggled int32
// Err is the flatten's, on a structurally broken Tree. The widget draws
// the message in place of the outline rather than drawing nothing, because
// the failures it reports — a dangling parent index, a cycle — are
// programming errors in the host and silence makes them look like an empty
// result set.
Err error
// Widths is what the table reported its columns to be, outline column
// first, when last frame's report was available; nil otherwise. A host
// persisting widths feeds it to its resolver (ADR-0151).
Widths []float32
}
Result reports what this frame's pointer interaction did. Every node field is -1 for "nothing". The State changes are already applied; the fields exist so a host can react — open a detail pane, load a subtree, run a command.
func Render ¶
Render draws the tree and applies this frame's pointer interaction to Input.State.
Responses arrive one frame late, as everywhere in imzero2: the click handled here is the click the user made on the previous frame's geometry. State changes are collected during the pass and applied after it — mutating expansion mid-pass would leave the row slice being iterated describing a tree that no longer exists.
An empty Tree draws nothing at all. A host that wants an empty-state message owns it, since what to say there ("no matches", "not connected", "loading") is the host's fact and not the widget's.
type Row ¶
type Row struct {
// Node indexes [Tree.Labels].
Node int32
// Depth is 0 for a root and one more than its parent's otherwise. It is
// the indent level, not a pixel count — the renderer multiplies.
Depth int32
// HasChildren is whether this node has any children at all, which is what
// decides if a disclosure control is drawn. It is independent of Expanded:
// a collapsed interior node has children and shows a closed control.
HasChildren bool
// Expanded is whether this node's children follow it in the row sequence.
// Always false for a leaf.
Expanded bool
// IsLastChild is whether this node is the final one among its siblings —
// including at depth 0, where the siblings are the roots.
//
// Carried from M1 although nothing reads it yet: it is exactly what indent
// guides need (the vertical line from a parent stops at its last child),
// and ADR-0176 SD9 defers the guides rather than the field, so adding them
// later needs no change here or in any caller.
IsLastChild bool
}
Row is one visible line of the outline: a node, how deep it sits, and the three facts a renderer needs to draw its disclosure control and its indent guides without walking the tree again.
A Row is what the renderer iterates, and — because the rows are a dense slice — what a virtualised host indexes into. That is the whole reason flattening is a separate step: once a hierarchy is a row sequence, showing only rows 40..60 is a slice expression rather than a traversal, which is what lets the renderer gate on egui_table's visible range (ADR-0176 SD4).
func Flatten ¶
Flatten walks t in depth-first pre-order and appends one Row per visible node to dst, descending only into nodes st reports as expanded. It returns the extended slice.
Pass dst as a retained slice's dst[:0] to reuse its backing array across frames; the row count is bounded by the node count, so a host that flattens every frame settles on one allocation.
Roots appear in input order, and so do siblings. No ordering of t itself is required — a parent may appear after its child — because the child lists are built in one pass before the walk.
A nil st is treated as fully collapsed, which makes "show me just the roots" a call with no state to construct.
It binds st to t (State.Bind) before reading anything, so a host may rebuild its columns and flatten in the same breath.
The error is Tree.Validate's: on a structurally broken tree Flatten returns dst unextended rather than a partial outline, because the failure modes it rejects — a dangling parent index, a parent cycle — are the ones that would otherwise produce a plausible-looking tree missing arbitrary subtrees, or hang the walk.
type State ¶
type State struct {
// contains filtered or unexported fields
}
State is the view state the host owns: which nodes are open, which are selected, and where the keyboard cursor sits. The widget reads and mutates it; it keeps no hidden per-frame state of its own and leaves no authority in the renderer (ADR-0176 SD2).
Host-owned is what makes expansion persistable, restorable and settable from code — the single largest thing the egui_ltreeview binding could not do, because its expansion lived in Rust and Go could only observe it one frame late.
What a node is filed under ¶
Every entry is filed under a node's identity, and which identity that is depends on the input. A Tree carrying a Keys column files under the key; a Tree without one files under the node's index in Tree.Labels, which is the only identity a columnar input has on its own and which is stable exactly as long as the host's node ordering is. For a host that filters or re-parses, that is one keystroke — so a State that has to survive a rebuild wants Keys, and every in-repo adopter supplies them.
The binding is taken from the Tree that Flatten — and therefore Render — is called with. A host that rebuilds its Tree and then mutates the State by node index before the next render should call State.Bind first, or the write lands under the key the previous build gave that index.
A node with no record follows the default ¶
Expansion is three-valued: open, closed, or no record at all. A node with no record is drawn per State.SetDefaultExpanded, which starts false — so the zero value is a fully collapsed tree, and a host that never touches the default sees exactly the behaviour it always had.
The default is what a default-OPEN host needs, and it is not expressible without the third value: "absent means collapsed" makes "the reader closed this" and "this node is new" the same entry, so a section closed before a filter keystroke and a section that has never been seen cannot be told apart. Three of the four adopters are default-open, which is why it is here.
The zero value is usable: nothing expanded, nothing selected, no cursor.
func (*State) Bind ¶
Bind points the State at t's identity column, which is what the entries filed after it are keyed on. Flatten and Render do it themselves every frame; a host needs it only when it rebuilds its Tree and then writes to the State by node index before the next render, since until it is called an index still means whatever the previous build called it.
The first bind of a State that has entries filed by index moves them over to the matching keys. Without that, a host which seeds a fresh State — expand this node, select that one — before its first render would write by index, and the entries would be invisible from the moment the binding arrived: a silent loss on the one frame a host is most likely to get this wrong. It is only ever the FIRST bind, because a bound State has nothing filed by index.
It is otherwise cheap: it borrows t's Keys slice and reads nothing.
func (*State) ClearSelection ¶
func (s *State) ClearSelection()
ClearSelection deselects everything.
func (*State) CollapseAll ¶
func (s *State) CollapseAll()
CollapseAll closes everything, leaving only the roots visible: the default goes closed and every record is dropped. The mirror of State.ExpandAll, with the same caveat about nodes that arrive later.
func (*State) Cursor ¶
Cursor is the node the keyboard is on, or -1 for none — including when the node it was put on is no longer in the tree.
func (*State) ExpandAll ¶
func (s *State) ExpandAll()
ExpandAll opens everything: it sets the default open and drops every record, so nodes that do not exist yet are open too when they arrive.
That last part is the difference from a loop over the current nodes, and it is usually what "expand all" means — a filter that widens, or a document that gains a section, should not bring back rows the reader has just finished opening. A host that means "open what is on screen now, and leave the default alone" writes the loop over State.SetExpanded instead.
func (*State) ExpandAncestors ¶
ExpandAncestors opens every ancestor of node so that node itself becomes visible, without touching node's own open state. This is what "reveal this row" means — after a search hit, or restoring a selection — and doing it by hand needs the parent walk the host would rather not write. It binds to t, so a host may call it straight after a rebuild.
func (*State) IsExpanded ¶
IsExpanded reports whether node is open: its recorded state if it has one, and State.SetDefaultExpanded's value otherwise. Leaves are never drawn open; the renderer asks this only of nodes that have children.
func (*State) IsSelected ¶
IsSelected reports whether node is selected.
func (*State) PendingReveal ¶
PendingReveal is the node a State.Reveal is waiting for, or -1 when none is pending or the node has left the tree. The next Render consumes it.
It exists so that a host — or its test — can see that a reveal was asked for. Without it the request is write-only from outside the package, and asserting on it means threading a return value through the host's own code to say what it just told the widget.
func (*State) Reveal ¶
Reveal asks the next render to bring node into view: open its ancestors and scroll its row to the middle of the viewport. It is a one-shot request the render consumes, not a persistent setting, so a host sets it on an event — a search hit, a restored selection, a jump from another panel — and does not have to remember to clear it.
A request rather than a pair of calls the host makes itself because both halves are the renderer's: the ancestors must open *before* the frame's flatten, and the scroll needs the row index that same flatten produces. A negative node cancels a pending reveal.
Only one reveal can be pending; a second call replaces the first. Revealing a node that is not in the tree opens nothing and scrolls nowhere.
It opens the ancestors in THIS State, which a host that owns expansion overwrites ¶
The half that opens the ancestors writes into this State, and a host that keeps its own expansion map and rewrites the State from it each frame — the shape every adopter had before Keys — undoes that write on the very next frame. Each half is correct on its own and the composition silently is not: the reveal opens the path, the sync closes it again, and the scroll lands on a row that is no longer there.
A keyed State does not have the problem, because it IS the host's store and there is nothing to rewrite. A host that keeps its own map anyway should open the path in that map instead, which State.ExpandAncestors is exported for.
func (*State) SelectOnly ¶
SelectOnly replaces the whole selection with node — the plain-click behaviour, as against the ctrl-click State.SetSelected models.
func (*State) Selection ¶
Selection appends the selected nodes to dst, in ascending node order, and returns it. Appending to a caller slice keeps a per-frame read allocation-free.
Ordered rather than map order because a host that renders the selection as a line of text gets a readout that changes between frames with nothing having changed otherwise — which reads as a flickering widget, and which no polling assertion in a headless scene can wait on.
On a keyed State a selected node whose key is not in the bound tree has no index to report and is skipped; see State.SelectionLen.
func (*State) SelectionLen ¶
SelectionLen is how many nodes are selected — including, on a keyed State, nodes the bound tree does not currently have. A selection outliving the rows it was made on is the point of a key: a filtered-out row comes back selected. State.Selection yields only the ones that are there.
func (*State) SetCursor ¶
SetCursor moves the keyboard cursor; any negative node clears it. It does not change the selection — see the cursor field's documentation for why the two are separate.
func (*State) SetDefaultExpanded ¶
SetDefaultExpanded sets how a node with no record of its own is drawn. It starts false, which is a fully collapsed tree.
It is a default and not a seed: it moves every node the reader has not touched, at any point, and changing it back moves them back. A host whose outline should start open — the usual case for a document, a schema, a config registry — sets it true once and then stores only what was closed.
It does not clear anything. State.ExpandAll and State.CollapseAll are the two that do.
func (*State) SetExpanded ¶
SetExpanded opens or closes node. Expanding a leaf is harmless and has no effect on the flattened rows, so a host driving expansion from a search result does not have to check for children first.
Setting a node to the current default drops its record rather than storing it, which keeps the map bounded by what the reader actually changed and leaves a later State.SetDefaultExpanded free to move it.
func (*State) SetSelected ¶
SetSelected adds or removes node from the selection.
func (*State) ToggleExpanded ¶
ToggleExpanded flips node's open state and returns the new one.
type Tree ¶
type Tree struct {
// Labels is what the renderer draws for each node.
Labels []string
// Parents indexes each node's parent, or -1 for a root. Several roots are
// allowed and are laid out one after another at depth 0 — a forest is the
// natural shape of a schema's tables or a filesystem's mount points, and
// inventing a virtual root would state a containment nothing in the data
// supports.
//
// No ordering is required: a parent may appear after its child. Siblings
// are drawn in the order they appear here.
Parents []int32
// Keys optionally identifies each node across rebuilds, and is what lets a
// [State] outlive one. Leave it nil and the State files everything under
// node indices, which are stable only as long as the host's ordering is —
// for a host that filters or re-parses, one keystroke.
//
// A key is the host's own name for the thing the row shows: a section id,
// a category name, an index path, a slug. It has to be unique, because it
// IS the identity — two nodes sharing a key share one expansion entry and
// one selection entry, and read as one node to everything in State. That
// is not checked: the check is a map build of every key on every frame,
// which is a per-frame cost for a host bug that shows up the first time
// the two rows are opened.
//
// When present it must have one entry per node; [Tree.Validate] rejects a
// short or long column rather than filing part of the tree by key and the
// rest by index.
Keys []string
}
Tree is the input hierarchy in columnar form: two parallel slices, one entry per node. It is deliberately not a pointer tree — the data this widget renders arrives flat (a recursive query's rows, a schema's fields, a profile's stacks), and demanding a pointer tree would make every producer build one first (ADR-0176 SD1). It is the same shape [icicle.Tree] and play's hierarchy contract already use, minus the value column a tree has no use for.
func (Tree) Validate ¶
Validate reports the first structural problem with t, or nil. Flatten calls it, so calling it separately is only useful to check input before laying out.
An empty tree is valid and flattens to no rows. A tree widget with nothing in it is an ordinary state — an unfiltered search, a schema with no tables — and making the host special-case it would put an `if len(...) == 0` in front of every call site.