Status: historical design; includes unimplemented proposals. See the linked app reference for current API guidance.

Tree design and implementation history

A tree is a list that nests: rows at several depths, disclosure controls that open and close them, selection shared with whatever else shows the same data, and a drag that moves a row into another one rather than only above or below it. A drawing app’s layer panel, a file browser’s source list and an outliner’s document are all the same control.

Day has list for flat rows and nav_menu for a fixed sidebar of destinations. Neither nests, and neither can express “drag this node into that group”, so a tree needs its own piece: tree(source, row), kinds::TREE.

The UI snippets use generated res::str accessors from an app’s Fluent catalog; see the example messages and catalog setup.

The driving case: Day Sketch’s layer panel

Day Sketch keeps its scene in one table of nodes, each with a parent and a fractional z (docs/model.md). A group is a node whose children point at it. That is already a tree, and the app wants it on the leading edge of the window:

  • every node listed, groups nesting their members, in the canvas’s own back-to-front order;
  • selection synchronized both ways, because the canvas and the tree read one selection signal: click a row, the shape’s handles appear; shift-click two shapes, both rows highlight;
  • drag a row onto a group to reparent it, or between two rows to restack it, which is the same parent + z write the Arrange menu already makes, and one undo unit.

Nothing about that is specific to Day Sketch. It is what every tree does.

What each toolkit brings

This table distinguishes platform capabilities from Day’s implemented adapters. Native controls existing in a toolkit does not mean Day currently uses them. Android’s general tree, ArkUI and web-dom use the composed tree; Qt and XAML now use native tree controls. See the section disclosure research for the narrower two-level grouping used by navigation sidebars.

The question that decides the design is whether each platform’s tree widget can host a row that Day built. Day rows are real native subtrees bound into recycled cells (docs/list.md), so a tree that paints its rows through a delegate is a worse fit than a flat list that hosts child views.

ToolkitNative treeHosts Day-built rowsVerdict
AppKitNSOutlineViewyes — view-based rows, same makeView/viewFor path as NSTableViewnative, and close to a drop-in over Day’s existing table code
UIKitUICollectionView list, .sidebar appearanceyes — UICollectionViewListCell hosts a content viewnative, but a different widget from Day’s UITableView list: new realize, new data source
GTK 4GtkListView + GtkTreeListModel + GtkTreeExpanderyes — the list-item factory binds arbitrary widgetsnative; GtkTreeView is deprecated at the 4.10 API level Day targets, so the model-based path is also the current one
XAML (WinUI)TreeView / TreeViewNodeyes — content/templates can host UI elementsnative TreeViewNode hierarchy with Day Canvas content (Windows CI validation required)
QtQTreeView / QTreeWidgetdelegates for painted rows; index widgets for hosted content, with ownership and scaling costsnative QTreeWidget; Day cells are independent viewport children so model rebuilds cannot delete their anchors
AndroidExpandableListView for two levels, not arbitrary-depth treesgroup/child adapter viewscomposed for arbitrary depth; native ExpandableListView for TreeSections
ArkUIArkTS TreeView + TreeController (API 10+)documented title/icon fields; NodeParam.container is a context-menu builder, not a row-content slotcomposed today; native adapter needs further API investigation
web-domdetails / summary disclosure; no complete tree-view elementarbitrary DOM descendantscomposed today; native disclosure can serve sections, but does not provide full tree keyboard semantics
mocksimulatedyesdrives the tests, as it does for list

TreeSource is hierarchical, so native adapters can drive it directly while the shared flattener serves the composed adapters. Choosing that fallback is an implementation decision, not evidence that a toolkit lacks native disclosure controls.

An “emulated” verdict here means the tree semantics are Day’s; it does not mean no native widget. Android’s arbitrary-depth tree rides the native RecyclerView the list piece uses, ArkUI hosts native row nodes, and web-dom’s rows are real DOM. That matters for customization: there is always something real to tweak.

What the native trees give away for free

The emulation has to provide each of these itself:

  • Expand and collapse, animated, with the platform’s disclosure glyph and indent step.
  • Keyboard: left/right to close and open a row, arrows through the visible rows, type-select.
  • Accessibility: NSOutlineView reports rows with a disclosure level to VoiceOver; WinUI’s TreeView reports expand state to Narrator. A flat list of indented rows announces as a flat list of rows unless Day says otherwise; see Accessibility.
  • Drop targeting: NSOutlineView hands the app (parent item, child index) and a sentinel for “onto this row”, which is exactly the vocabulary a reparent needs. GTK and the emulation compute that themselves from the pointer’s position in the row.
  • Spring-loading: hovering a collapsed group during a drag opens it. It is native on AppKit, a timer everywhere else, and a v2 item.

Native collapsible sections research (2026-10)

Collapsible list sections need only a header and one level of children. They should not be ruled out because a toolkit lacks an arbitrary-depth, arbitrary-content tree. The official APIs provide these candidates:

ToolkitNative path for sectionsState and notifications
Qt WidgetsQTreeView with header parent indexes and leaf rows; QTreeWidget is the convenience alternativesetExpanded / isExpanded, expanded / collapsed signals; non-selectable parent flags
WinUIHierarchical NavigationViewItem for sidebars; TreeView for general hierarchy; Expander for a header/content disclosureIsExpanded, Expanding / Collapsed; SelectsOnInvoked=false prevents parent navigation selection
AndroidExpandableListView with a group/child adapterexpandGroup / collapseGroup / isGroupExpanded, group expand/collapse listeners
GTK 4GtkTreeListModel + GtkTreeExpander inside GtkListViewGtkTreeListRow.expanded property and change notification
web-domdetails containing summary and section contentopen property and toggle event
ArkUIArkTS TreeView has built-in disclosure; ListItemGroup has group headersReviewed public TreeView API does not document expansion setters or expansion-change callbacks; do not promise restorable native sections yet

Sources: Qt QTreeView, WinUI hierarchical navigation, WinUI Expander, Android ExpandableListView, GTK TreeExpander, HTML disclosure elements, ArkUI TreeView, ArkUI ListItemGroup.

WinUI’s NavigationViewItemHeader itself is static. A hierarchical parent must be styled as a section heading, have no destination or folder icon, and avoid header selection. Its default ancestor selection indicator also needs checking when a selected child is collapsed. For Qt, the sidebar’s text/icon/badge rows can use delegates; arbitrary Day widget hosting need not block a native sidebar implementation. Android’s native expandable list meets the two-level section shape but cannot replace Day’s general tree.

Collapsible sections

tree(...).section_headers() declares a two-level source: root branches are non-selectable section headings, root leaves remain ordinary rows, and section children must be leaves. This selects Cap::TreeSections independently of the arbitrary-depth Cap::Tree. Android therefore uses native groups without advertising an arbitrary-depth platform tree. DOM and ArkUI use the existing list flattener and Day disclosure controls for both modes.

.expanded(signal) restores app-owned expansion. .on_expansion(|key, expanded| ...) reports actual user or script changes once, after updating that signal. Repeated events and programmatic restoration do not echo callbacks. Native model rebuilds consult TreeSource.expanded, suppress notifications, restore selection by token, and recycle hidden row IDs. TreeSource.section_header prevents headings from becoming destinations.

AppKit section mode uses NSTableViewStyle::SourceList, isGroupItem, native table-row views, NSTableCellView.textField, and floatsGroupRows. AppKit supplies the trailing hover-revealed show/hide control, typography and floating group appearance. Generic trees retain their full-width style. TreeSource.layout_cell_size reports actual native cell bounds; this is essential because source-list group headers have different heights from leaf rows. The core remembers both dimensions through rebinding and reactive updates. UIKit uses sidebar list appearance and the header outline accessory style; GTK marks group list items unselectable.

Qt owns native disclosure, selection and keyboard handling. Day row widgets remain children of its viewport, independent of model ownership, and bind only while visible. WinUI owns TreeViewNode disclosure and selection; Day Canvas content is detached before a model rebuild. System XAML observes its template’s ListViewBase selection (it lacks the WinUI-specific TreeView.SelectionChanged event). Both adapters retain cell hosts until tree teardown. Native tree drag-to-move remains AppKit-only. Qt/XAML use uniform row-height estimates (36 points for Automatic); XAML currently binds all expanded rows rather than virtualizing Day content. Android’s native section control supports single selection; multi-select section trees use the composed implementation.

tree_section_expansion_contract runs in the component conformance suite on every toolkit, including explicit WASM registration. The Qt C++ qt-tree test also verifies native signal suppression, 64-bit identity, non-selectable headings and cell lifetime across model rebuilds. Mock tests cover stable sidebar identity, absent-section retention and native header height through rebinding. See navigation persistence.

ArkUI: reaching an ArkTS component from the C node API

Day’s ArkUI backend speaks the C node API (ARKUI_NODE_*), whose list vocabulary has no tree in it, so this looked like a gap at first. TreeView lives one layer up, in the ArkTS advanced component set, and Day already crosses that layer twice: the app’s @Entry page mounts Day’s native tree through a NodeContent/ContentSlot, gives every pushed navigation page its own per-page NodeContent, and answers up-calls from Rust for things the C API cannot do at all (the file picker runs on the ArkTS side and hands bytes back).

The bridge can reach the component, but that alone does not establish a suitable adapter. The official API provides TreeController.addNode/buildDone, title and icon fields, and node click/edit/move listeners. Contrary to an earlier version of this plan, NodeParam.container builds the right-click menu; it is not a place to mount a Day row. The documented component accepts no child components, and the reviewed controller/listener surface does not expose expansion restoration or expansion-change notification. A native adapter therefore remains research, not a straightforward ContentSlot integration. Keep the composed path until a supported API meets row-content, selection and persistent expansion requirements.

The note on Qt

QTreeView provides native expansion, keyboard navigation and model/view rendering. For arbitrary Day-built widgets, setIndexWidget transfers widget ownership to the viewport; Qt recommends it for static content and recommends QStyledItemDelegate for dynamic content or editors (official documentation). This is a row-hosting/scaling concern, not a limitation of native expansion. Day now uses QTreeWidget for native expansion, indentation, keyboard handling and tree accessibility. Day-owned row widgets are independent viewport children, positioned from visualItemRect, rather than index widgets. This avoids native model rebuilds deleting adopted cell anchors. Visible rows bind lazily; previously created cell hosts remain until tree teardown. The Qt native test exercises this ownership boundary.

Why the tree is a core built-in

Day has two places a piece can live: crates/day-pieces with a kinds::… of its own, or a satellite crate that registers per-backend renderers through renderer! (docs/extending.md). Satellites are the default answer (the stepper, the color picker and the web view are all satellites), so the tree has to justify living in core.

It clears it on four counts, three of which a satellite cannot reach at all:

NeedsReachable from a satellite?
A pull source the backend calls synchronously (children_len, child_token, bind_row)yes, awkwardly; the props struct can carry Rc<dyn Fn…> closures, so a satellite could ship its own source struct without a Toolkit duty
Binding a Day row into a native cell (cell-anchor adoption, BuiltRow, scope ownership)no; Tree::install_list and the cell machinery are day-core’s, and install_tree has to sit beside it
Cap::Tree / Cap::TreeMove, Role::Tree / Role::TreeItemno; both enums live in day-spec, and a satellite cannot add variants
dayscript expand: / tree_move: stepsno; Step lives in day-script, and the walkthrough has to drive expansion and moves on every target

The source alone would not settle it. The cell machinery, the capability and a11y vocabulary, and the test steps do: three of the four are spec-and-core edits whatever crate the piece nominally lives in, and a satellite that needs three core edits to work is a core piece in all but name.

kinds::TREE therefore joins builtin_kinds! beside kinds::LIST, and the piece ships in day-pieces, with the same consequence every new builtin kind has: the backends whose realize matches are exhaustive stop compiling until each names the kind. That compile error is the checklist.

Authoring

The row builder is list’s: an ItemSlot/ModelSlot bound once per physical cell and rebound as cells recycle, so a ten-thousand-node tree builds only what it shows. What changes is the source, which is hierarchical, and the identity, which is a token, not a row index. A tree cannot key rows by position, because expanding one row renumbers everything below it, and every native API here agrees: NSOutlineView keys by item, diffable snapshots by identifier, GtkTreeListRow by item, TreeViewNode by content, TreeController by node id.

A closure-backed tree

branches(items, key, parent) is the tree counterpart of items(…): a tracked flat collection, a token per item, and a parent token per item (None = root). The flattener derives children by grouping, in the items’ own order.

#[derive(Clone, PartialEq)]
struct Entry { id: u64, parent: Option<u64>, name: String, folder: bool }

let entries = Signal::new(seed_entries());
let open    = Signal::new(HashSet::from([DOCS, DOCS_GUIDES]));   // expansion, app-owned
let picked  = Signal::new(Vec::<u64>::new());                    // selection, app-owned

tree(
    branches(move || entries.get(), |e| e.id, |e| e.parent),
    |row: ItemSlot<Entry, u64>| {
        row((
            vector(move || if row.field(|e| e.folder) { gv::folder } else { gv::doc })
                .frame(16.0, 16.0),
            label(move || row.field(|e| e.name.clone())),
        ))
        .spacing(6.0)
    },
)
.expanded(open)
.selected(move || picked.get())               // app state → native selection, echo-free
.on_selection(move |keys| picked.set(keys))   // native selection → app state
.on_activate(move |key| open_entry(key))      // double-click / Enter
.type_ahead(|slot| slot.field(|e| e.name.clone()))
.id(id!(library))

Both directions of selection point at one signal, so anything else reading picked stays in step with the tree for free. expanded works the same way: the user’s disclosure clicks update open, and the app writing open drives the native rows.

A store-backed tree

A day-model store passes through a tracked children projection (the tree counterpart of store.rows(projection)) mapping a parent key (None = root) to its ordered child keys:

// Day Sketch: the scene store IS the tree. children_of already exists for the canvas.
tree(model::nodes().tree(model::children_of), |slot: ModelSlot<Node>| {
    row((
        kind_glyph(slot),                                  // rect / oval / line / group
        label(move || slot.name().read()),                 // wakes only for THIS row
        spacer(),
        swatch(move || slot.fill().read()),                // the shape's fill, live
    ))
    .spacing(6.0)
})
.expanded(model::open_groups())
.selected(move || model::selection().get())
.on_selection(move |keys| model::selection().set(keys))
.movable(
    Move::new(|node, parent, index| model::reparent(node, parent, index))
        // The structural refusals (into itself, into a descendant, into a leaf) are built in;
        // this guard adds the app's own.
        .guard(|_node, parent, _| match parent {
            Some(p) if model::is_locked(*p) => MoveVerdict::Deny,
            _ => MoveVerdict::Allow,
        }),
)
.id(id!(layers))

ModelSlot’s costs land exactly as they do under list: a field edit patches the one control showing it, a change the projection reads re-runs only the projection, and a recycled cell leaves no observation claims behind.

Maintenance: the flows an app runs

Edits reload; expansion and selection survive. A store write (or a change the branches items closure reads) refreshes the snapshot and applies TreePatch::Reload; the expansion set and the selection are re-applied by token, so nothing collapses and nothing deselects unless its token is gone. v1 reload is whole-tree, as list’s is; the keyed splice is a named refinement (see Risks).

// Adding a node under a group: one store write. The tree reloads itself.
let id = model::place_shape_in(group, NodeKind::Rect, 40.0, 40.0);
model::selection().set(vec![id]);      // …and the new row is selected via the same signal
reveal.set(Some(id));                  // …and scrolled into view, ancestors expanded:

Reveal. .reveal(Signal<Option<K>>) is the tree form of list’s scroll_to_row: setting it expands every ancestor of the token (through the same expansion signal, so the app sees the change), then scrolls the row into view. “Find in canvas → show in layers” is one signal write.

Programmatic expansion. The expansion signal is plain state, so bulk operations are plain code, and persistence is the app’s choice of where to put the set:

expand_all.action(move || open.set(model::all_group_keys()));
collapse_all.action(move || open.set(HashSet::new()));
// survive relaunch: persist it like any other pref
watch(move || open.get(), |set| day::prefs::set("layers.open", &encode(set)));

Programmatic moves go through the same commit the drag uses, so dayscript, an app’s own “Move to group” menu item, and the drop gesture are one code path:

menu_item(res::str::move_to_group()).action(move || {
    for id in model::selection().get_untracked() {
        model::reparent(id, Some(group), None); // None = append
    }
});

TreeSource: how the backend pulls the hierarchy

Recycling trees pull, exactly as recycling lists do, so the tree extends the same synchronous source list established with ListSource (docs/list.md):

pub struct TreeSource {
    /// How many children `parent` has (`None` = the root).
    pub children_len: Rc<dyn Fn(Option<u64>) -> usize>,
    /// The i-th child of `parent` — the stable token every backend keys its row by.
    pub child_token: Rc<dyn Fn(Option<u64>, usize) -> u64>,
    /// Whether this token can hold children at all — what draws (or omits) the disclosure.
    pub expandable: Rc<dyn Fn(u64) -> bool>,
    /// Build-or-rebind the row for `token` into this native cell.
    pub bind_row: Rc<dyn Fn(u64, RawHandle)>,
    pub recycle: Rc<dyn Fn(RawHandle)>,
    /// The row's type-ahead string (see Keyboard below).
    pub type_select_text: Rc<dyn Fn(u64) -> String>,
    /// The drag half, present only when the tree carries a `Move`.
    pub moves: Option<TreeMoves>,
}

pub struct TreeMoves {
    /// The live verdict, called inside the platform's drag callback: may this node land
    /// under `parent` at `index`? `index: None` means "onto the parent" (append).
    pub can_move: Rc<dyn Fn(u64, Option<u64>, Option<usize>) -> MoveVerdict>,
    /// The commit, after the drop is accepted. Rewrites Day's snapshot before returning and
    /// defers the app's `Move` handler through the event queue, as `list`'s reorder does.
    pub move_node: Rc<dyn Fn(u64, Option<u64>, Option<usize>)>,
}

index: Option<usize> expresses every drop: Some(i) drops between rows, None drops onto the parent. It maps to NSOutlineViewDropOnItemIndex without translation, to a diffable snapshot’s append(to:), to GtkTreeListRow’s child model, to a TreeViewNode’s Children.Insert, and to TreeController’s move callback.

New spec surface, all additive: kinds::TREE, TreeProps { multi_select, indent, row_height }, TreePatch::{Reload, Expand(u64, bool), Selected(Vec<u64>), Reveal(u64)}, Event::TreeExpanded { token, expanded }, Event::TreeMove { node, parent, index }, Toolkit::attach_tree, Cap::Tree / Cap::TreeMove, Role::Tree / Role::TreeItem, and Subcontrol (below).

One flattener for the rest

day-core turns a TreeSource plus the expansion set into the flat row sequence the emulated backends render: a walk from the root that descends only into open rows, producing (token, depth) pairs. The emulated backends then reuse the list cell machinery unchanged and wrap each row in an indent plus a disclosure control built from ordinary Day pieces.

That is one implementation of expansion, indentation, keyboard handling and drop targeting, shared by Qt, Android and web-dom, exercised by the mock backend’s tests. A bug fixed in the flattener is fixed on three platforms. The flattener is written kind-agnostic (rows-with-depth over any token tree), because it is the seed of the shared composed tier described in Stepping back.

Keyboard and type-ahead

A tree is a keyboard control before it is a mouse one, and the platforms disagree about how much of that they hand over.

BehaviorAppKitUIKitGTKXAMLArkUIemulated (Qt · Android · web)
Arrow up/down through visible rowsnativenative (hardware kbd)nativenativenativeDay
Left/right to collapse and opennativenativenativenativenativeDay
Home/End, page up/downnativenativenativenativenativeDay
Type-ahead to a rownative, via typeSelectStringForTableColumn:row:——native—Day

So Day writes the keyboard once, in day-core, and each backend opts out of the parts its widget already does. The emulated handler is a focus-scoped key reader on the tree node:

  • Up/Down move the cursor within the flattened visible rows; Shift extends the selection.
  • Left collapses an open row, or moves to the parent when the row is already closed, the behavior every tree has for climbing out of a group.
  • Right opens a closed row, or moves to its first child.
  • Home/End jump to the first and last visible row; Page keys move by the viewport.
  • Enter activates (on_activate), Space toggles selection under multi_select.

Type-ahead needs a string per row, which only the app knows, so the source carries type_select_text and the piece fills it from .type_ahead(|slot| …), defaulting to the row’s first label. AppKit and XAML answer their own type-select callbacks from that closure; everywhere else day-core keeps a small buffer that appends printable keys, resets after ~800 ms of silence, and selects the first visible row whose text starts with it, wrapping from the cursor. One closure supplies the text; two implementations handle the mechanics.

Customization: three layers, and what each one owns

Tree views are among the most configurable controls on every desktop toolkit, and no portable API is going to span NSOutlineView’s style, row-size, group-row, autosave and disclosure options plus GTK’s factories plus WinUI’s node templates. Trying produces the worst of both: an API too wide to implement everywhere and still too narrow for anyone who cares. So the tree splits its surface into three layers.

LayerWhat belongs thereReaches
Portable APIwhat every tree has and an app would otherwise build by hand: expansion, selection, moves, indent, row height, keyboard, type-aheadall nine targets
Hooksthe per-row decisions native trees express as delegate callbacks: may this row expand, may it be selected, how tall is it, is it a group rowall nine, mapped to each toolkit’s callback or run by the emulation
Tweakseverything else — the platform’s vocabulary, on the real widgetone toolkit at a time, no-op elsewhere

The dividing rule: if a knob changes what the tree means, it is portable; if it changes how one platform draws it, it is a tweak. Row height changes meaning (rows overlap or clip if a backend ignores it), so it is portable. NSTableViewStyle::SourceList changes appearance, so it is a tweak, and an app that wants it on Windows asks WinUI for its own equivalent.

Why hooks exist as their own layer

A tweak reaches the widget. It cannot reach the widget’s delegate, because Day owns that object: day-appkit’s sidebar already installs a DayNavMenuData as both NSOutlineViewDataSource and NSOutlineViewDelegate, and an app that assigned its own would tear the tree’s data out from under Day. Yet the delegate is exactly where AppKit puts outlineView(_:heightOfRowByItem:), shouldExpandItem:, shouldSelectItem: and isGroupItem:.

So the tree names that surface itself, and each backend routes it to its own mechanism:

tree(source, row)
    .row_height_for(|n: RowInfo| if n.expandable { 28.0 } else { 22.0 })
    .can_expand(|n| n.token != LOCKED_FOLDER)
    .can_select(|n| !n.expandable)          // folders are containers, not selections
    .is_group_row(|n| n.depth == 0)

AppKit answers its delegate methods from these; GTK sets the row widget’s height request and sensitive; UIKit’s list configuration reads them per item; WinUI applies them to the node’s container; and the emulation consults them in the flattener. That is one vocabulary with five native implementations and one fallback; an app that sets none of them gets a plain tree.

What the tweak system reaches today, and what it does not

Tweaks hand a closure the node’s native handle plus its concrete class, per toolkit, typed on AppKit/UIKit/GTK/Android and raw on Qt/XAML/ArkUI. For a tree that covers most of the interesting surface immediately, because most of NSOutlineView’s configuration is properties. It does not cover four things, all of which the tree makes acute:

  1. A composite backing exposes only its outer handle. Day’s sidebar realizes an NSOutlineView inside an NSScrollView and returns the scroll view as the node’s handle, so with_native hands a tweak the scroller, and reaching the tree means guessing documentView(). The tweaks doc’s own rule (match the class, do not assume it) cannot be followed when the class you want is not the one you are given. This is not new to the tree: list and text_area are composite on the same backends today.
  2. No handle for a row. Rows are native cells the backend creates (NSTableRowView, UICollectionViewListCell, GtkListItem), and Day builds the row’s content inside them. A tweak inside the row builder reaches the content widgets; nothing reaches the cell.
  3. No participation in delegate decisions. Covered by hooks above, and stated as a rule in docs/tweaks.md: a tweak must never install its own data source or delegate on a widget Day drives.
  4. .tweak runs once at mount. Correct for a widget that lives as long as the node, wrong for anything that must be re-applied per row bind.

The three additions this plan makes

Native subcontrols (day-spec, each backend’s ext). A subcontrol is one addressable widget within a composite backing. The word is Qt’s own name for this concept (QStyle::SubControl), chosen here because the natural word, “part”, already means a headless platform-service package in Day (parts/day-part-*, DESIGN.md §15). A kind whose backing is composite reports its subcontrols, and the accessors take one:

day_appkit::with_native_subcontrol(node, Subcontrol::Content, |view, class, mtm| …)
button(res::str::sample_label()).appkit_subcontrol(Subcontrol::Content, |…| …)    // the Decorate form

Subcontrol::Host is today’s behavior and stays the default; Subcontrol::Content is the widget inside the scroller; Subcontrol::Header is the header view where one exists. Each kind documents its subcontrols per toolkit in the same table that documents its native class, so the mapping is reported rather than guessed. This lands with the tree and retrofits list and text_area in the same change.

Row tweaks (day-pieces, day-core). The tree and the list gain .row_tweak(|native_row, class, RowInfo|), invoked when a cell is bound: after the row’s content exists, with the cell’s own handle and the row’s token, depth, expansion and selection state. It runs on every bind, so it stays correct for recycled cells.

web-dom joins the tweak system. The tweaks table stops at seven toolkits; the browser is missing. The emulated tree makes that a real gap, and CSS is the web’s native customization language, so day-dom gains a minimal accessor and two helpers over new shim calls:

day_dom::with_element(node, Subcontrol::Host, |el| {
    el.add_class("layers-panel");
    el.set_style("scrollbar-width", "thin");
});

Deep customization per toolkit

What follows tests the design: for each toolkit, the platform-specific configuration its tree users reach for, written against this plan’s API. Every snippet compiles only under its own backend’s feature and is a silent no-op everywhere else; delete all of them and the tree still works on all nine targets, only plainer.

AppKit — NSOutlineView in an NSScrollView

Subcontrols: Host = NSScrollView, Content = NSOutlineView, Header = the NSTableHeaderView (absent by default; the tree ships headerless). Rows are NSTableRowView.

use day_appkit::{AppKitExt, Subcontrol};
use objc2_app_kit::{NSOutlineView, NSScrollView, NSTableRowView, NSTableViewRowSizeStyle,
                    NSTableViewSelectionHighlightStyle, NSTableViewStyle};
use objc2_foundation::{NSSize, ns_string};

tree(layers, row)
    .appkit_subcontrol(Subcontrol::Content, |view, class, _mtm| {
        // `class` is "NSOutlineView"; the Host subcontrol would have handed us NSScrollView.
        let Some(ov) = view.downcast_ref::<NSOutlineView>() else { return };
        unsafe {
            // The Finder-sidebar treatment, which is most of what people want from a
            // macOS tree and none of which belongs in a portable API:
            ov.setStyle(NSTableViewStyle::SourceList);
            ov.setSelectionHighlightStyle(NSTableViewSelectionHighlightStyle::SourceList);
            ov.setRowSizeStyle(NSTableViewRowSizeStyle::Medium);
            ov.setIndentationPerLevel(13.0);
            ov.setIndentationMarkerFollowsCell(true);
            ov.setFloatsGroupRows(true);
            ov.setIntercellSpacing(NSSize { width: 0.0, height: 2.0 });
            // AppKit persists expansion itself, keyed per window — an app can lean on this
            // INSTEAD of persisting Day's expansion signal, but not both:
            ov.setAutosaveName(Some(ns_string!("layers")));
            ov.setAutosaveExpandedItems(true);
        }
    })
    .appkit(|view, _class, _mtm| {                      // Subcontrol::Host — the scroller
        if let Some(sv) = view.downcast_ref::<NSScrollView>() {
            unsafe {
                sv.setDrawsBackground(false);
                sv.setAutomaticallyAdjustsContentInsets(true);
            }
        }
    })
    .row_tweak(|row, class, info| {
        // The cell itself, on every bind — the thing plain tweaks could never reach.
        if class == "NSTableRowView" && info.is_group {
            if let Some(rv) = row.downcast_ref::<NSTableRowView>() {
                unsafe { rv.setEmphasized(false) };
            }
        }
    })

UIKit — UICollectionView sidebar list

Subcontrols: Host = the UICollectionView itself (it is its own scroller). Rows are UICollectionViewListCell. One UIKit-specific rule: the list configuration (sidebar appearance, separators, swipe providers) is consumed when the layout is created, so those choices are build-time and ride TreeProps hints and the packaged style tweak below. Post-mount tweaks get everything that is a live property:

use day_uikit::{Subcontrol, UikitExt};
use objc2_ui_kit::{UICollectionView, UICollectionViewListCell};

tree(src, row)
    .uikit(|view, _class, _mtm| {                       // Host — the collection view
        if let Some(cv) = view.downcast_ref::<UICollectionView>() {
            unsafe {
                cv.setDragInteractionEnabled(true);      // drags on iPhone, not just iPad
                cv.setKeyboardDismissMode(
                    objc2_ui_kit::UIScrollViewKeyboardDismissMode::OnDrag,
                );
            }
        }
    })
    .row_tweak(|cell, class, info| {
        // `class` is "UICollectionViewListCell"; runs on every bind and rebind.
        let Some(c) = cell.downcast_ref::<UICollectionViewListCell>() else { return };
        unsafe {
            c.setIndentationWidth(18.0);
            c.setIndentsAccessories(true);
            // Group rows read as headers: clear background, no reorder accessory.
            if info.is_group {
                let mut bg = c.defaultBackgroundConfiguration();
                bg.setBackgroundColor(None);
                c.setBackgroundConfiguration(&bg);
            }
        }
    })

GTK — GtkListView + GtkTreeListModel in a GtkScrolledWindow

Subcontrols: Host = GtkScrolledWindow, Content = GtkListView. The row handle .row_tweak receives is the GtkTreeExpander the factory wrapped the Day content in, which is the widget GTK’s own tree options live on.

use day_gtk::{GtkExt, Subcontrol};

tree(src, row)
    .gtk_subcontrol(Subcontrol::Content, |w, class| {
        // `class` is "GtkListView".
        if let Some(lv) = w.downcast_ref::<gtk4::ListView>() {
            lv.add_css_class("navigation-sidebar");     // the GNOME sidebar treatment
            lv.set_show_separators(false);
            lv.set_single_click_activate(false);
            lv.set_enable_rubberband(true);             // marquee multi-select
        }
    })
    .gtk(|w, _class| {                                  // Host — the scrolled window
        if let Some(sw) = w.downcast_ref::<gtk4::ScrolledWindow>() {
            sw.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic);
            sw.set_overlay_scrolling(true);
        }
    })
    .row_tweak(|row, class, info| {
        // `class` is "GtkTreeExpander".
        if let Some(exp) = row.downcast_ref::<gtk4::TreeExpander>() {
            exp.set_indent_for_icon(true);
            exp.set_hide_expander(!info.expandable);    // leaves get no chevron gutter
        }
    })

XAML (WinUI) — TreeView / TreeViewNode

These are proposed native-adapter examples, not the current composed implementation.

XAML tweaks are all raw tier: the accessor hands the borrowed ABI pointer and the class, and the app’s own C++/WinRT does the work (compiled by the crate’s build.rs, as day-tweak-slider-tickmarks does). Subcontrols: Host = the TreeView. Rows are TreeViewItems.

tree(src, row).xaml_raw(|abi, class| {
    let cls = std::ffi::CString::new(class).unwrap();
    unsafe { layers_tree_style(abi, cls.as_ptr()) };
})
#include <cstring>
extern "C" void layers_tree_style(void* abi, const char* cls) {
    if (!cls || std::strcmp(cls, "TreeView") != 0) return;
    WUX::UIElement e{ nullptr };
    winrt::copy_from_abi(e, abi);                       // AddRef for this call's duration
    auto tv = e.try_as<WUXC::TreeView>();
    if (!tv) return;
    tv.SelectionMode(WUXC::TreeViewSelectionMode::Multiple);
    tv.CanDragItems(true);
    tv.CanReorderItems(true);
}
// row_tweak's raw form receives the TreeViewItem — the glyph knobs live there:
extern "C" void layers_row_style(void* abi, const char* cls, int is_group) {
    if (!cls || std::strcmp(cls, "TreeViewItem") != 0) return;
    WUX::UIElement e{ nullptr };
    winrt::copy_from_abi(e, abi);
    auto item = e.try_as<WUXC::TreeViewItem>();
    if (!item) return;
    item.GlyphSize(10.0);
    item.CollapsedGlyph(L"");                     // Segoe Fluent chevrons
    item.ExpandedGlyph(L"");
    if (is_group) item.GlyphOpacity(0.0);
}

WinUI’s deeper theming (TreeViewItemBackgroundSelected and the other lightweight-styling resource keys) stays available through the app’s own resource dictionary, outside Day entirely.

ArkUI — the ArkTS TreeView

The component’s public surface is small (TreeView { treeController } plus NodeParam per node), so deep customization on HarmonyOS is mostly per-node, and it crosses the bridge rather than a widget pointer. The arkui extension exposes that:

// The NodeParam fields Day does not consume itself, per token, re-sent on reload:
day_arkui::tree_ext::node_params(node, |token| NodeParamExtras {
    icon: Some("shape-rect.svg".into()),
    selected_icon: Some("shape-rect-filled.svg".into()),
    secondary_title: Some(count_label(token)),   // the little trailing count TreeView draws
    ..Default::default()
});

.arkui_raw still works, but it reaches the row’s mounted Day subtree (the ContentSlot host), not the ArkTS component; the component has no C-node handle to hand out. An app that needs more than NodeParam offers replaces the ArkTS component wholesale through the piece extension mechanism ([package.metadata.day.ohos], docs/extending.md), the platform’s extension point.

The emulated targets: Qt, Android, web-dom

The tree logic is Day’s here, which moves customization into the app’s own code: the disclosure, the indent and the row chrome are ordinary Day pieces, so the row builder and the piece’s own options are the deep-customization surface, and nothing the emulation draws needs FFI. What remains native is the host, and the host is still tweakable:

Qt. Rows are real QWidgets Day built; the host is the emulated scroller. Host-level polish goes through qt_raw; row-level polish is plain Rust in the row builder, with row_tweak available for the row container (per-depth stylesheets, for instance):

tree(src, row)
    .qt_raw(|w, class| {
        let cls = std::ffi::CString::new(class).unwrap();
        unsafe { kinetic_scroll(w, cls.as_ptr()) };      // QScroller::grabGesture, 4 lines of C++
    })
    .row_tweak(|row_w, _class, info| {
        // The row container QWidget: zebra-stripe by depth with a stylesheet.
        day_qt::ext::set_style_sheet(row_w, if info.depth % 2 == 0 {
            "background: palette(base)"
        } else {
            "background: palette(alternate-base)"
        });
    })

If the QTreeView spike lands, Subcontrol::Content starts answering with the QTreeView and the native knobs (setAnimated, setRootIsDecorated, setUniformRowHeights, ::branch stylesheets) become reachable as on AppKit, because subcontrols are named by role rather than by class.

Android. The tree flattens onto the same native RecyclerView the list piece uses, so host tweaks are ordinary JNI tweaks and rows are Day-built ViewGroups:

tree(src, row)
    .android(|view, class, env| {
        // `class` is "androidx.recyclerview.widget.RecyclerView".
        let _ = env.call_method(view, "setOverScrollMode", "(I)V", &[2.into()]); // never
        let _ = env.call_method(view, "setVerticalScrollBarEnabled", "(Z)V", &[true.into()]);
    })
    .row_tweak(|row_view, _class, info| {
        day_android::ext::set_elevation(row_view, if info.is_group { 2.0 } else { 0.0 });
    })

web-dom. The emitted structure carries stable hooks (day-tree on the host, day-tree-row with data-depth per row, and the full ARIA tree attributes), so a stylesheet in resource/assets restyles everything CSS can reach, and the new with_element accessor covers the dynamic remainder:

tree(src, row).dom(|el| {
    el.add_class("layers-panel");
})
/* resource/assets/app.css — the web's native customization language */
.layers-panel { scrollbar-width: thin; }
.layers-panel .day-tree-row[data-depth="0"] { font-weight: 600; }
.layers-panel .day-tree-row[aria-expanded="true"] > .day-tree-disclosure { rotate: 90deg; }
.layers-panel .day-tree-row.day-drop-into { outline: 2px solid var(--accent); }

Packaged: day-tweak-tree-style

Anything reusable becomes a crate, as the tweaks doc prescribes. .tree_style(SourceList | Plain | Inset) maps to NSTableViewStyle on AppKit, the sidebar list configuration on UIKit (a build-time hint, per the note above), .navigation-sidebar on GTK, TreeViewItem template resources on XAML, a stylesheet on Qt, a class on web-dom, and a documented no-op on Android and ArkUI.

Selection

.on_selection(Fn(Vec<K>)) reports the full selected set on every change; .selected(Fn() -> Vec<K>) writes app state back into the native selection without an echo. Point both at one signal and selection is two-way; that is how Day Sketch’s canvas and layer panel stay in step without either knowing about the other.

Expansion

The app owns it: .expanded(Signal<HashSet<K>>). The user’s disclosure clicks arrive as Event::TreeExpanded and update the signal; the app writing the signal expands or collapses the native rows through TreePatch::Expand. An app that wants expansion to survive a relaunch persists that set and nothing else changes.

A reload must not silently collapse the tree. Where the backend keys expansion by a stable item (AppKit, diffable snapshots) it survives on its own; elsewhere Day re-applies the set after the reload.

Moving nodes

A tree is movable exactly when it carries a Move value: .movable(Move::new(moved)) turns on the platform’s drag, .guard(..) on the value answers while the drag is live, so the affordance reflects the verdict before the user lets go, and .enabled(..) takes a constant or a reactive bool read when a drag may begin. That is the same contract as list’s Reorder::guard, and the reason a guard must stay pure. The structural refusals every tree needs (a node into itself, into its own descendant, or into a leaf) are built into the driver; the guard adds the app’s own.

Move::new(moved) is the commit, handed (key, parent, index). In Day Sketch it writes parent and a fractional z between the new neighbors, in one undo unit, which is the same write the Arrange menu makes.

Accessibility

The emulated path has to say what the native trees say for themselves, so this ships with the piece rather than after it: Role::Tree and Role::TreeItem join the a11y vocabulary, carrying level, position in set, set size, and expanded state (aria-level/aria-expanded on web, AccessibilityNodeInfo collection-item info plus expand/collapse actions on Android, ARKUI_ACCESSIBILITY attributes on ArkUI, QAccessible::Tree on Qt). a11y_audit then holds every backend to the same expectation.

Driving it from dayscript

  • expand: { id, key, expanded }: open or close a row.
  • tree_move: { id, key, parent, index }: guard, then commit, with no native gesture; a denied move fails the step, the way reorder: does.
  • type_ahead: { id, text }: feed the buffer and assert where the cursor lands, since the native type-select callbacks are not reachable from a synthetic key event on every backend.
  • Rows carry ids, so tap, assert_text and assert_missing already work on them, and key: { key: ArrowDown } drives the keyboard on the emulated path.

Implementation plan

Mock-first, as list was: the driver is tested headlessly before a single native tree exists, so every backend after the first is a rendering problem rather than a semantics problem.

M0 — spec, piece, driver, mock (SHIPPED 2026-08)

day-spec: kinds::TREE in builtin_kinds!; TreeProps; TreePatch; TreeSource + TreeMoves + MoveVerdict; Event::{TreeExpanded, TreeMove}; Cap::{Tree, TreeMove}; Role::{Tree, TreeItem} and their Role::for_kind arm; Toolkit::attach_tree defaulting to a no-op; Subcontrol. The Builtin::ALL length test moves by one, and the backends with exhaustive realize matches gain a fallthrough arm in the same commit so the workspace keeps compiling.

day-core (src/tree_driver.rs, beside list.rs): TreeDriver (children_len, child_token, expandable, build(token, RNode) -> BuiltRow, type_select_text, optional moves) plus install_tree, tree_reload, tree_set_expanded, tree_set_selected, tree_reveal, and tree_try_move for dayscript and the mock. The flattener lives here, kind-agnostic and memoised per (reload, expansion) generation. The cell-anchor half of list.rs (BoundCell, CellStep, scope ownership) is lifted into a shared module both drivers use rather than copied, and gains the row-bind hook .row_tweak rides.

Tweak surface (day-spec + every backend’s ext): Subcontrol, with_native_subcontrol, and the …_subcontrol decorator on each toolkit’s extension trait, with Subcontrol::Host preserving today’s behavior. list and text_area declare their subcontrols in the same change, since they have been composite all along.

day-pieces (src/tree.rs): tree(source, row), the NodeSource trait with two implementations (branches(items, key, parent) and the store adapter Store::tree(children_projection)), and the builder: .expanded, .selected, .on_selection, .multi_select, .movable(Move), .on_activate, .type_ahead, .reveal, .indent, plus the hooks (.row_height_for, .can_expand, .can_select, .is_group_row) and .row_tweak.

day-mock: a simulated viewport plus MockProbe::{tree_rows, tree_expand, tree_can_move, tree_move, tree_type_ahead}.

Done when these pass headlessly: only visible rows build; collapsing a row disposes its descendants’ scopes; recycling rebinds by slot-write rather than rebuilding; a move rewrites the snapshot before returning and defers the Move handler to the next drain; a guard denial leaves the tree untouched; expansion and selection survive a reload by token; reveal expands ancestors before scrolling; type-ahead selects the right row and resets on timeout; every hook is consulted by the flattener; .row_tweak fires on each bind and rebind with the right RowInfo.

M1 — AppKit, the reference native (SHIPPED 2026-08)

NSOutlineView over the existing view-based row path, so makeView/viewFor reach bind_row unchanged. outlineView(_:child:ofItem:) / isItemExpandable / numberOfChildrenOfItem map one-to-one onto TreeSource; expandItem/collapseItem apply TreePatch::Expand, and the ItemDidExpand/ItemDidCollapse notifications emit Event::TreeExpanded. Drag reuses the table’s pasteboard pipeline, with validateDrop(proposedItem:proposedChildIndex:) answering from can_move, including NSOutlineViewDropOnItemIndex for a drop onto a row, which is what index: None means. Type-select answers from type_select_text.

This is also where the tweak additions first matter: the node’s handle is the NSScrollView, so Subcontrol::Content hands a tweak the NSOutlineView, and .row_tweak receives the NSTableRowView. The four delegate hooks land here first, since AppKit is the backend with the richest delegate to route them to.

Done when the Showcase page (M5) drives expansion, multi-select, keyboard and a drag-reparent on macOS; a11y_audit reports tree rows with disclosure levels; and the source-list tweak from the AppKit example compiles and visibly changes the rendering.

M2 — the shared emulation, on web-dom and Qt (SHIPPED 2026-08 — see the as-built notes)

Both already emulate list, so they are the cheapest check that one flattener serves three backends. Rows come from the flattener; each row’s Day subtree gets an indent and a disclosure control built from ordinary pieces.

As built (2026-08). The emulation landed one level higher than planned: in the piece, not in each backend. TreePiece::build branches on capability(Cap::Tree): Native takes the attach path above, anything else takes build_composed, which flattens the connection’s visible rows (a DFS descending only into expanded rows, tracked against the shape read and the expansion signal) onto the existing [list] piece. So the composed tree costs a backend nothing: web-dom and the qt toolkit answer Cap::Tree Emulated, and the same code would render on any backend with the list machinery. Per row:

  • Indent is a layout (TreeIndent) reading the row’s depth from a Cell the rebind watch writes before the cell’s relayout, so a recycled cell re-indents without rebuilding.
  • Disclosure is a chevron label (▸/▾, tracked against the expansion state) with an on_tap that flips the app’s .expanded signal (or the piece’s internal set). The dayscript expand: step emits the same Event::TreeExpanded the native backends do, and the handler routes it into the same signal, one echo-free path for both.
  • Selection rides the list’s own machinery (.selected_rows / .on_selection with tokens translated to keys), so multi-select, shift/ctrl clicks and the painted highlight come from the list backend.
  • The row shell is a native transparent container, not a layout-only node: the row’s dayscript id lands as a real a11y identifier, and .row_context_menu (lowered onto the ordinary summon-time .context_menu(|p: Point| ..) decorator, key read at summon) needs an element to arm its listener on (docs/menus.md “Dynamic context menus”).
  • The driver still installs on the returned node: expand:/tree_move: resolve rows and route moves through the same guard → commit path, so one tree.yaml passes verbatim on native and composed backends alike.

Pool cleanup: an emulated list that shrank hides pooled cells in place, so ListSource::recycle now clears a hidden cell’s element ids (list_recycle_cell, the list twin of the tree’s recycle rule); web-dom and qt call it as they hide. Qt also honors .edge(PaneEdge::Leading) now (the splitter’s panel pane goes first); the layers pane was the first leading inspector a qt target ever showed.

Known deltas, still open: a disclosure click also selects its row (the tap reaches the cell-click machinery too); keyboard navigation and type-ahead do not exist on composed trees; the ARIA tree pattern (role="tree", aria-level, …) and the day-tree/data-depth class hooks are not emitted yet; “day-dom joins the tweak system” (with_element, add_class, set_style) was not taken in this pass; and there is no drag-to-move (Cap::TreeMove stays Unsupported, with tree_move: driving TreeSource synthetically). Verified by tree.yaml 87/87 on web-dom and macos-qt plus a real-pointer browser suite (chevron clicks, row and canvas right-clicks through the composed menu, drag-move) against the live server.

M3 — GTK and UIKit (SHIPPED 2026-08 — native drag pending on both; see the status alert)

GTK: GtkListView over a GtkTreeListModel whose create_model_func pulls children lazily from TreeSource, each row wrapped in a GtkTreeExpander; selection through GtkMultiSelection; drag on the existing GtkDragSource/GtkDropTarget rows, with Day computing into-versus-between from the pointer.

UIKit: UICollectionView with UICollectionLayoutListConfiguration(appearance: .sidebar), an NSDiffableDataSourceSectionSnapshot built from TreeSource, .outlineDisclosure accessories, and sectionSnapshotHandlers.willExpandItem/willCollapseItem for the expansion events. Reorder rides the collection’s drag and drop delegates. The list-configuration options are build-time on this backend (TreeProps hints, per the customization note).

M4 — XAML and ArkUI (both COMPOSED 2026-08: ArkUI verified locally, XAML CI-pending — see the as-built notes)

XAML: WinUI TreeView with TreeViewNodes mirrored from TreeSource, CanReorderItems for the drag, native type-ahead, the raw subcontrol/row tweak channel from the examples above.

ArkUI: the proposed ArkTS TreeView integration needs revision after the API review above. The earlier plan incorrectly treated NodeParam.container as row content. Expansion state and arbitrary row hosting remain unresolved; the composed implementation below is still the supported path.

As built (2026-08, the XAML half — CI-pending). XAML also joined through the composed tree: Cap::Tree answers Emulated over its emulated list (which already honored multi_select + ListPatch::Selected), list_populate clears hidden pooled cells’ ids through ListSource::recycle (the same shape qt took), and day_xaml_inspector_new gained a leading flag mapping .edge(PaneEdge::Leading) to SplitViewPanePlacement::Left, the same ignored-edge bug qt had, fixed by inspection. Every edit mirrors a qt twin the walkthrough covers, and parses (edition-aware rustfmt), but nothing here is verified: this Mac cannot type-check the crate (the cppwinrt build script needs the Windows SDK), so the WinUI TreeView native form stays open work, and the CI walkthrough is what checks the composed form (dayscript/tree.yaml joined Day Sketch’s CI script list in the same change, which also closes M1’s “tree.yaml not wired into CI” leftover across all eight targets).

As built (2026-08, the ArkUI half). ArkUI joined through the composed tree instead of the ArkTS TreeView: Cap::Tree answers Emulated and the M2 piece runs unchanged; the ArkTS-native form above stays open work. What the NodeAdapter list machinery had to gain (each found by the walkthrough, each a general list fix):

  • Reloads re-bind for real. SetTotalNodeCount alone diffs by item id (the index), so a list that changed above its tail kept every untouched cell’s old binding, and one that shrank to empty stopped firing ADD at all. list::reload now bumps a per-list generation salted into GET_NODE_ID and calls ReloadAllItems: every reload renames the rows, the adapter re-adds them, and the pool + day-core’s cell cache make that cheap rebinds. The reload is posted out of the day-core borrow (the M1 deferred-mutation rule: a bind pulled under the borrow skips and never retries) and coalesced per drain.
  • Selection: cells report taps as SelectionChanged (resolved through the adapter’s row map, since cells carry no day node id) and ListPatch::Selected paints the accent at 20% alpha, at bind and on sync, echo-free.
  • Recycle: REMOVE_NODE_FROM_ADAPTER clears the pooled cell’s dayscript ids.
  • Programmatic-set echo cells (§4.4): ArkUI fires onChange for programmatic text/slider sets, and the echoes were re-writing app state: on Day Sketch, every selection change sealed phantom “style” undo units, so the second undo of any pair popped a phantom unit instead of history. Text/slider sets now record the value; a matching event is swallowed as the echo.
  • day-arkui also gained a hilog sink (day::arkui::start installs it); the log facade previously went nowhere on OHOS.

Verified on the local OHOS emulator: tree.yaml 89/89, real uitest uiInput row taps moving the selection tree→canvas with the painted highlight following. demo.yaml runs 315/321 there: the six failures are all the Edit ▸ Paste chain, because this image denies ohos.permission.READ_PASTEBOARD (USER_GRANT, SYSTEM_BASIC), a day-part-clipboard platform matter unrelated to trees. Known issue, open: the disclosure chevron’s label never renders on ArkUI (its reserved 18vp box lays out, siblings in the same cell bind and update correctly, and the closure computes the right glyph, but this one label’s text never reaches the native node after pooled-cell rebinds), so disclosure is currently reachable through selection-driven reveals and TreeSource but not by a finger on the chevron; it needs its own session. The chevron glyphs are now ▶/▼ everywhere, because HarmonyOS Sans ships no glyph for the small ▸/▾ forms. The Showcase tree demo (M5) surfaced a second symptom of the same pooled-rebind defect: a recycled cell’s name label keeps a stale narrow measure, wrapping short file names mid-word after expand/collapse cycles; freshly bound rows are fine. Both belong to the one rebind-apply investigation.

M5 — Android, the Showcase page, and the style crate (SHIPPED 2026-08)

Android joins the emulation (the flattener over the existing RecyclerView list machinery, ItemTouchHelper for the drag, AccessibilityNodeInfo collection-item info plus expand/collapse actions). The Showcase gains its page (see below), and day-tweak-tree-style ships with whatever coverage the backends built so far support.

As built (2026-08, the Android half). Because M2 landed the emulation in the piece, Android’s cost was the list gaps rather than a tree: Cap::Tree answers Emulated and the composed build runs unchanged. What the RecyclerView machinery gained with it:

  • ListPatch::Selected: recorded per list, painted onto the visible holders (the theme accent at 20% alpha as the cell background, under the ripple foreground) and inherited by newly bound holders, with no selection-event echo. Taps still report single selection (the touch idiom), and the round trip through the app’s signal highlights the row.
  • onViewRecycled → ListSource::recycle: a pooled holder’s day content keeps its views but sheds its dayscript ids (the same rule every other backend follows), keyed by the per-cell GlobalRef nativeListBind binds with.

Day Sketch grew the phone ergonomics in the same change: a compact window starts with the layers pane closed (the canvas needs the room), the tool row carries a Layers toggle, and tree.yaml opens the pane up front on the phone targets (89 steps; the two open-toggle steps are only_on: [uikit, mdc]). Verified on the emulator: tree.yaml 89/89 and demo.yaml 321/321, a cold-start check of the compact default, and real adb input taps: a row tap moving the selection tree→canvas, and a chevron tap collapsing and re-expanding (dayscript injects events, so only real taps exercise the recognizers; the inner chevron listener wins over the cell’s click, so a disclosure tap does not re-target the selection, whereas on web-dom the click bubbles to the cell and does). Still open on Android: the ItemTouchHelper drag half (Cap::TreeMove stays Unsupported), the AccessibilityNodeInfo expand/collapse actions, and row context menus (no set_context_menu_fn wiring; a long-press summon would need the composed presenter’s Event::ContextMenu).

M6 — Day Sketch (leading pane, layer panel and walkthrough SHIPPED 2026-08)

The leading pane and the layer panel, covered below.

The Showcase page

A new Section::Tree beside Section::List: a routes! variant, a source_file() arm, a Dest { … page: tree_page }, src/pages/tree.rs, a nav vector icon, and strings in all four locales the Showcase ships (en, fr, ar, zh-CN, with no raw literals).

The page shows one tree deep enough to be interesting and small enough to read: a mock project with folders, files and a nested folder, each row a disclosure plus an icon plus a name, and below it a live readout of the current selection, expansion set and last move. Controls beside it exercise the API: a multi-select toggle, an “expand all” and “collapse all” pair driving the expansion signal, a Move::guard switch that refuses drops into one particular folder (so the denied affordance is visible on every platform), and a reveal field.

The page also demonstrates the three customization layers in one place, since that is the part of this design an app author has to understand: the portable options drive the controls, one hook (can_select on folders) is toggleable, and a day-tweak-tree-style line sits in the source under a comment explaining that it changes the macOS and Windows rendering and no-ops on the rest, the Showcase’s usual job of being the worked example.

The walkthrough leg (dayscript/tree.yaml, joining the per-target list in the Showcase’s CI) asserts what no screenshot can: expanding a folder reveals exactly its children, collapsing hides their ids, tree_move reparents and the readout agrees, a guarded move fails the step, type-ahead lands on the expected row, and assert_no_placeholders holds on every target.

As built (2026-08). src/pages/tree.rs: a nine-node mock project behind one Signal<Vec<FileNode>>, branches(items, key, parent) deriving the hierarchy, and every portable option driven from real controls: Expand/Collapse All writing the app-owned expansion set, a Reveal button (.reveal targeting a leaf under two collapsed ancestors), Add File into the selected folder, a Multi-select toggle that rebuilds the tree with the other flag (a build-time option, swapped through when(...).otherwise(...)), a Lock toggle arming the Move::guard (docs/ refuses drops while locked, showing the native no-drop cursor on macOS), .type_ahead from the names, and summon-time .row_context_menu menus (folders: New File; files: Duplicate; both: Move Up/Down through sibling swaps, Delete of the subtree). Readouts mirror on_selection, the node count, and exactly what the Move handler was handed. Deltas from the plan above: the toggleable hook shipped as the Lock/Move::guard switch (can_select does not exist yet; see M1’s notes); the reveal “field” is a button with a fixed deep target; the walkthrough cannot assert a denied move or type-ahead (dayscript has no assert-this-step-fails form and no type_ahead: step; the deny verdict and type-select stay pointer checks); and the tweak line ships as .tree_style(TreeStyle::sidebar()) from tweaks/day-tweak-tree-style, AppKit clearing the scroll/outline backgrounds via Subcontrol::Content (plus an alternating_rows option), GTK adding Adwaita’s navigation-sidebar class, a documented no-op elsewhere. One trap to remember: assert_text against argumented Fluent messages must go through key:+args: (the runner formats them with the same bidi isolates the label carries). (A second trap of the time, .id after .height tagging the wrapper so expand:/tree_move: steps found no tree, is gone: .id names the tree’s own node wherever it sits in the chain.) Verified locally: tree.yaml 53/53 on macos-appkit, macos-gtk, macos-qt, web-dom, android-mdc, ios-uikit and harmony-arkui; the walkthrough.yaml tree block in the appkit full run (705/705); real right-click context menus, a real Duplicate commit and real chevron disclosure on web-dom via a headless browser; light + dark screenshot review (ArkUI shows the pooled-rebind label defect noted under M4).

Day Sketch: the layer panel

The pane. Day has no leading utility pane: inspector is the trailing one and nav(Sidebar) is a navigation split. InspectorProps grows an edge: Edge (defaulting to Trailing, so no existing app moves), and the four backends that realize the inspector map it: NSSplitViewItem.sidebar rather than .inspector, the GTK GtkPaned’s start child, the first pane of the QSplitter, WinUI’s SplitView pane. On phones the leading pane presents as the same sheet the inspector already uses. Day Sketch then reads: layers on the leading edge, canvas in the middle, inspector on the trailing edge, each independently collapsible.

The source. Day Sketch’s scene is already a tree: children_of(parent) ordered by z is the children projection, expandable is kind == NodeKind::Group, and the row is a kind glyph, the node’s name and a fill swatch, the store-backed example above, verbatim.

Selection. The tree binds .selected(|| model::selection().get()) and .on_selection(|keys| model::selection().set(keys)), the same signal the canvas reads, so the two stay in step without either knowing the other exists, and undo’s transient selection restoration already flows to both.

Moving. The Move handler (node, parent, index) writes parent and a fractional z between the new neighbors in one undo unit labeled move, which is the write the Arrange menu already makes. The driver refuses a node into itself or a descendant, and a drop into a leaf; Move::guard adds the app’s own refusals.

Keyboard and type-ahead come with the piece: arrows walk the tree, left and right climb and open, type-ahead jumps by node name.

Walkthrough. The demo script gains a section that expands the group made earlier in the run, asserts its two children appear, selects a row and asserts the canvas frame readout changes, drags a shape into the group with tree_move and asserts the count and the group’s bounds, then undoes it, showing that canvas and layer panel share one model.

Stepping back: what the tree stresses in Day’s architecture

Designing this piece also tested the framework, and the findings are recorded here because they outlive the piece.

Tweaks are shallow, and that keeps them safe. The tweak model assumes one node ↔ one widget, behavior-in-properties, and mount-once lifetime. A tree breaks all three, and each addition above patches exactly one: subcontrols address the composite backing, row tweaks address cells Day does not own, hooks address behavior that lives in a delegate rather than in properties. The first two are still tweak-shaped. The hooks are not tweaks at all, and calling them “deep customization via tweaks” would misfile them. Depth comes from TreeSource and the hooks; the tweak hatch stays shallow so it stays safe.

The policy rung already existed, unnamed. list grew Reorder::guard and Delete::guard; nav grew on_back; this piece needs can_expand, can_select, row_height_for, Move::guard. All have the same shape: pure, synchronous, called inside a native callback, with Day answering the platform’s question by asking the app. The extension ladder in docs/tweaks.md documents styling / tweaks / native pieces; the fourth rung, policy, has been growing piecemeal since list shipped. When the tree lands, the ladder should name it.

Tokens are the corrected identity contract. ListSource addresses rows by index (bind_row(usize, …), guards over (from, to)); the tree cannot, and every native tree API agrees with the tree. That leaves two contracts in the codebase. This plan does not migrate list, but the mismatch is a known debt, and new collection kinds should address rows by token.

The emulations are converging on one composed tier. web-dom and Qt each carry their own emulated list; the flattener here is kind-agnostic so it can become shared substrate rather than a third copy. The composed colorpicker and stepper show the wider pattern: for complex kinds, one Rust reference implementation as the guaranteed floor, native upgrades where a platform offers more. That inversion (a working composed fallback instead of a ⟨kind⟩ placeholder) is right for containers and wrong for buttons, and the boundary between those is a decision Day should make once.

None of this reopens the macro-architecture. The retained tree, capability reporting, mock-first drivers and the per-toolkit asset pipeline all held under this design, and the platforms’ independent agreement on the (parent, index) drop vocabulary supports the portable semantic core. The corrections are all one level down, in the collection middle layer (subcontrols, hooks, row tweaks, the shared flattener), and this piece is the first step through them.

Risks worth deciding early

  • Token stability becomes a requirement. list tolerates index churn; a tree does not. Sources whose keys move break expansion and selection, so the docs must say it and the mock tests must check it.
  • Reload granularity. v1 reloads the whole tree on a data change, as list does. Large trees under frequent edits will want the keyed diff (TreePatch::Splice) sooner than lists did, because a reload also disturbs expansion.
  • Recycling is not universal. AppKit, UIKit, GTK and XAML reuse cells; ArkUI’s TreeController does not, and Qt’s index-widget path would not either. Cap::ListRecycling has to answer accurately per backend, and the docs have to say which trees stay small.
  • Two natives cannot guard a drag live. ArkUI reports a move after the fact; the emulated path can be as live as Day makes it. Move::guard’s contract must therefore be “consulted as early as the platform allows”, with the per-backend table saying where that is, the same shape list’s reorder guard already documents.
  • Two identity contracts until list migrates. The tree is token-addressed; list is index-addressed. Both are correct alone, and the pair is a wart: shared machinery has to speak both, and apps that use both pieces learn two vocabularies. The migration is out of scope here and should not be forgotten.
  • Subcontrols widen the contract. Naming Subcontrol::Content promises a kind keeps having a content widget. That is a weaker promise than a class name (the tweaks doc already refuses to freeze those), but it is still a promise, so subcontrols are declared per kind in the docs and an unknown subcontrol must resolve to None rather than to the host.
  • UIKit’s build-time configuration. The sidebar list’s appearance knobs are consumed when the layout is created, so style choices there ride TreeProps hints rather than post-mount tweaks. The packaged style crate has to front both channels, and the docs have to say which knob rides which.
  • What v1 leaves out. Dragging a multi-row selection as one unit; spring-loading a collapsed row under a hovering drag (native on AppKit, a timer elsewhere); columns beside the disclosure, which is where a tree becomes a tree table and wants NSOutlineView’s and GtkColumnView’s column machinery; and lazy children that arrive asynchronously, which TreeSource’s synchronous children_len cannot express and which a file browser eventually needs.