TreeView Specs

This document captures design and implementation notes for TreeView and TreeNode.

Overview

  • Status: Implemented
  • Primary purpose: Display hierarchical nodes with selection and expand/collapse interaction.
  • Key goals:
    • composable node headers (Visual)
    • optional right-aligned node visuals for indicators/actions
    • expandable/collapsible nodes with a visible expander glyph
    • selection as a visible-row index (SelectedIndex)
    • keyboard navigation and mouse selection
    • scroll integration via IScrollable + ScrollModel (vertical and horizontal offsets)
    • optional hierarchy guide lines (tree connectors)
  • Non-goals:
    • built-in data model or reflection-based tree construction (users build TreeNode structures)
    • full virtualization (TreeView tracks all expanded nodes; offscreen nodes are still part of the render list)
    • per-node enabled/disabled state in v1 (enable/disable is at the control level)

Implementation notes

  • Primary implementation:
    • src/XenoAtom.Terminal.UI/Controls/TreeView.cs
    • src/XenoAtom.Terminal.UI/Controls/TreeNode.cs
    • src/XenoAtom.Terminal.UI/Controls/TreeNodeRightVisual.cs
    • src/XenoAtom.Terminal.UI/Styling/TreeViewStyle.cs
  • Tests:
    • src/XenoAtom.Terminal.UI.Tests/TreeViewTests.cs
    • src/XenoAtom.Terminal.UI.Tests/TreeViewScrollViewerTests.cs
  • Demo:
    • samples/ControlsDemo/Demos/TreeViewDemo.cs

Public API surface

TreeView

  • TreeView : Visual, IScrollable (sealed)

Layout defaults:

  • Focusable = true
  • HorizontalAlignment = Align.Stretch
  • VerticalAlignment = Align.Stretch

Properties:

  • Roots : BindableList<TreeNode> (bindable getter)
    • Owned by the TreeView and uses onAdding/onRemoving hooks to attach/detach nodes.
  • SelectedIndex : int (bindable)
    • Index into the current "visible row" list.
    • This is a projection index over the flattened visible tree, not a root index and not a child index relative to any parent node.
    • The meaning of a given index can change when expansion/collapse changes the visible-row projection.
    • Coerced to -1 when the tree has no visible rows, otherwise clamped to [0 .. VisibleCount - 1].
  • SelectedNode : TreeNode? (bindable, read-only)
    • Direct reference to the selected visible node.
    • Updated whenever SelectedIndex changes or the visible-row projection changes.
  • Scroll : ScrollModel (non-bindable property from IScrollable)
    • Exposes scroll state for ScrollViewer integration.

Methods:

  • TrySelectNode(TreeNode node) : bool
    • Selects a currently visible node by reference.
  • IndexOfVisibleNode(TreeNode node) : int
    • Returns the current visible-row index for a node, or -1 when the node is not visible.

TreeNode

  • TreeNode : IVisualElement (sealed)

Properties:

  • Header : Visual (required)
  • Children : BindableList<TreeNode> (bindable getter)
    • When the node is attached to a TreeView, adding/removing children automatically attaches/detaches their headers.
  • RightVisuals : BindableList<TreeNodeRightVisual> (bindable getter)
    • Right-aligned visuals attached to the row end.
    • When the node is attached to a TreeView, adding/removing entries automatically attaches/detaches their visuals.
  • Parent : TreeNode? (set automatically when added as a child)
  • IsExpanded : bool (bindable)
  • Icon : Rune? (bindable)
    • Optional node-provided icon. The final icon is resolved by TreeViewStyle.ResolveIcon(...).
  • IconStyle : Style? (bindable)
    • Optional node-provided style for the icon glyph.
    • Merged over the row style so node-specific foreground/background can customize the icon without affecting the header.
  • Data : object? (bindable)
    • Optional data context used by TreeViewStyle.IconResolver.

Notes:

  • TreeNode is an IVisualElement because it owns a BindableList<TreeNode>, but it is not itself a Visual. The renderable surface is the Header visual.
  • Convenience fluent helpers:
    • AddRightVisual(Visual visual)
    • AddRightVisual(Visual visual, TreeNodeRightVisualVisibility visibility)

TreeNodeRightVisual

  • TreeNodeRightVisual : IVisualElement (sealed)

Properties:

  • Visual : Visual (required)
  • Visibility : TreeNodeRightVisualVisibility (bindable)
    • Always: visible whenever the node row is visible
    • Hover: visible only while the row is hovered

Visible rows and child attachment model

TreeView maintains:

  • _headers: a VisualList<Visual> containing the header visuals for all nodes currently in the tree
  • _rightVisuals: a VisualList<Visual> containing right-aligned visuals for all nodes currently in the tree
  • _visible: a list of visible rows derived from expanded nodes

Node visuals are attached once and reused:

  • when a node is added (directly or via child list changes), its header and right visuals are attached to the TreeView visual tree
  • when a node is removed, its header and right visuals are detached

The visible row list is recomputed during PrepareChildren:

  • it traverses Roots recursively and includes children only when IsExpanded is true
  • it also computes hierarchy line metadata (continuation mask and last-sibling flag)

Example:

  • if the visible rows are [Root, ChildA, ChildB, Other], then SelectedIndex = 2 means ChildB
  • if Root collapses, the visible rows become [Root, Other]
  • TreeView then preserves selection by node when possible; otherwise selection clamps to the nearest visible ancestor/index

To avoid allocating temporary sets when toggling visibility, PrepareChildren:

  • sets all headers IsVisible = false
  • sets all right visuals IsVisible = false
  • then sets IsVisible = true for headers/right visuals that belong to rows in _visible

Hover-only right visuals remain attached and measurable, but TreeView collapses them to zero-width rectangles when the row is not hovered so they stay out of rendering and hit testing without losing their measured size.

Important: this is not viewport virtualization. All expanded nodes are part of _visible, even if scrolled out of view. Viewport virtualization could be a future improvement.

Scrolling integration

TreeView exposes a ScrollModel via IScrollable.Scroll.

TreeView updates scroll state during arrange:

  • Scroll.SetViewport(viewportWidth, viewportHeight)
  • Scroll.SetExtent(extentWidth, extentHeight)

Offsets:

  • Scroll.OffsetY selects which visible row is rendered at the top.
  • Scroll.OffsetX is used to horizontally shift the row chrome and headers.

Selection and scroll:

  • when SelectedIndex changes, TreeView schedules "ensure selected visible"
  • if a viewport is already known (Scroll.ViewportHeight > 0), it updates the scroll offset immediately
  • otherwise it performs the ensure-visible step during the next arrange
  • SelectedNode is synchronized from the selected visible row so code can react directly to the selected TreeNode
  • when the selected node becomes hidden because an ancestor collapses, TreeView moves selection to the nearest visible ancestor

Integration with ScrollViewer:

  • ScrollViewer can drive the offsets by setting its own offsets; TreeView reflects those through its scroll model.
  • TreeView handles mouse wheel events itself (scroll by 1 row), and this works when hosted in a ScrollViewer because the ScrollViewer reads offsets from the scroll model.

Binding model loop avoidance (ScrollVersion)

ScrollModel.Version is a bindable property that changes when viewport/extent/offset change.

TreeView both:

  • reads scroll state during layout/render
  • and writes scroll state during arrange (SetViewport/SetExtent/SetOffset)

To avoid "read then write the same bindable in one tracking context" errors, TreeView uses the standard pattern:

  • in PrepareChildren, copy Scroll.Version into a private bindable ScrollVersion
  • in ArrangeCore and RenderOverride, read ScrollVersion (not Scroll.Version)

This decouples the binding dependency read from the writes performed during arrange.

TreeView also stores MeasuredContentWidth as a private bindable so arrange can re-run when content width changes.

Layout and rendering

Measure

TreeView measures based on the visible row list and header desired widths:

  • each visible node header is measured with infinite max width and height 1
  • each right visual is also measured with infinite max width and height 1
  • the control computes a "prefix width" per row:
    • selection marker glyph width (FocusMarkerGlyph)
    • indentation (depends on depth and whether hierarchy lines are enabled)
    • expander glyph width (or a space when the node has no children)
    • a fixed 1-cell gap
    • icon glyph width
    • SpaceBetweenGlyphAndText gap
  • the max row width is prefix + header.DesiredSize.Width + allRightVisualWidths

The measured content width is stored in MeasuredContentWidth.

Height:

  • desired height is max(1, VisibleCount) (one row per visible node)

Vertical scrollbar width reservation:

  • if the height is bounded and the desired height exceeds the available height, TreeView adds a "reserved width" equal to ScrollViewerStyle.ScrollBarThickness.
  • This is intended to reduce layout shifts when a TreeView is hosted in a ScrollViewer that will display a vertical scrollbar.

Measure returns flexible hints (grow/shrink enabled) so it can be used as a resizable panel.

Arrange

Arrange:

  • sets the scroll viewport to the arranged size
  • sets the scroll extent to:
    • height: visible row count
    • width: max(arranged width, MeasuredContentWidth)
  • clamps offsets to valid range
  • applies ensure-selected-visible if needed

Each visible header is arranged to a 1-row rectangle at:

  • y = Bounds.Y + (rowIndex - Scroll.OffsetY)
  • x = Bounds.X + prefixWidth - Scroll.OffsetX

Right visuals are arranged against the viewport right edge for the row:

  • always-visible visuals occupy the far-right slots
  • hover-only visuals (when active) are inserted immediately to the left of the always-visible group
  • inactive hover-only visuals are arranged with width 0

Render

TreeView renders the row chrome (not the header content):

  • fills the whole bounds with spaces (Style.None baseline)
  • then for each viewport row:
    • fills the entire row width with the resolved row style
    • draws the selection marker glyph in the first cell
    • optionally draws hierarchy guide lines using HierarchyLines (when enabled and indent size >= 2)
    • draws the expander glyph and icon glyph
    • leaves remaining row cells for the header visuals to render (children render after the parent)

Hierarchy line rendering uses a continuation mask to decide which ancestor levels draw a vertical connector.

Input behavior

TreeView does not register commands yet; interactions are implemented directly in key/pointer handlers.

Keyboard

  • Navigation: Up/Down, Home/End, PageUp/PageDown
  • Expansion:
    • Left: collapse selected node (if it has children)
    • Right: expand selected node (if it has children)
    • Enter/Space: toggle expand/collapse (if it has children)

There is no type-to-search in v1.

Mouse

  • Left click selects the clicked row.
  • Clicking on the expander glyph cell toggles expand/collapse.
    • The expander hit test is based on the computed prefix position and includes horizontal scrolling (OffsetX).
  • Pointer move tracks a hovered row index.
    • This drives hover-only right visuals.
    • A click on a right-aligned interactive visual still routes to that visual because it is a normal child visual of the TreeView.

Wheel

Mouse wheel scrolls the tree by 1 row without changing selection.

Styling

TreeViewStyle

TreeViewStyle controls:

  • indentation and spacing:
    • IndentSize
    • SpaceBetweenGlyphAndText
  • hierarchy lines:
    • HierarchyLines : LineGlyphs? (default: Single; null disables)
    • HierarchyLineStyle : Style? (default: theme border style with dim)
    • convenience variants: NoLines, HeavyLines, DoubleLines
  • glyphs:
    • ExpandedGlyph, CollapsedGlyph (non-ASCII defaults; override if needed)
    • FocusMarkerGlyph (non-ASCII default; this is the selection marker)
  • icons:
    • IconResolver : Func<object?, Rune?, Rune>?
    • IconStyle : Style?
    • node icon fallback uses TreeNodeIcons.DocumentGlyph
      • defaults are emoji glyphs (e.g. folder/file/document); see TreeNodeIcons for the code points
    • per-node TreeNode.IconStyle overrides can further customize the rendered icon
  • row styles:
    • Item, SelectedFocused, SelectedUnfocused, Disabled
    • ResolveItemStyle(theme, enabled, selected, focused) provides theme-driven defaults when overrides are not set.

Tests and demos

  • TreeViewTests cover:
    • expanding nodes and showing children
    • hierarchy line rendering (default and disabled)
    • root sibling connector rendering
    • right-aligned visuals (always visible, hover-only, interactive button click)
    • scrolling to keep selection visible when hosted in a ScrollViewer
  • TreeViewScrollViewerTests cover:
    • ScrollViewer driving the tree scroll offsets
    • mouse wheel scrolling
    • horizontal extent reporting for long content (horizontal scrolling)
  • ControlsDemo shows:
    • style variants for hierarchy lines
    • SelectedNode / TrySelectNode(...) usage
    • right-aligned status indicators and hover-only row actions
    • a larger tree hosted in a ScrollViewer

Future / v2 ideas

  • Viewport virtualization (only arrange/measure headers in the viewport plus overscan).
  • Type-to-search or incremental search on node labels.
  • Per-node enabled state and callbacks (activate node, selection changed events).
  • Lazy child loading (async-friendly) as a separate model layer or helper API.