Tree (plan)

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 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

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 — items are content controlsnative; a better fit than ListView was, since content hosting is the thing WinUI’s tree does well
QtQTreeViewawkwardly; rows are painted by delegates, and arbitrary widgets need setIndexWidget per row, which defeats virtualizationemulated to start, but see the note on Qt; Day’s Qt list already declines to virtualize
Androidnoneemulated; Material has no tree, and the platform idiom is a flat RecyclerView with indentation and a chevron
ArkUITreeView + TreeController (@ohos.arkui.advanced, API 10+)yes — NodeParam.container is a builder slot, which can hold a ContentSlot Day mounts a row intonative, through Day’s existing ArkTS bridge; see below
web-domnoneemulated; the browser has no tree element, only role="tree" and the ARIA pattern
mocksimulatedyesdrives the tests, as it does for list

Five toolkits carry a real tree that will host Day’s rows. Two have no tree at all, and one has a tree whose cell model does not host arbitrary widgets. That split decides the architecture. TreeSource is hierarchical, so the native trees drive it directly, and one flattener in day-core turns the same source into indented rows for the rest.

An “emulated” verdict here means the tree semantics are Day’s; it does not mean no native widget. Qt’s emulated tree still scrolls a real Qt container of real Qt row widgets, Android’s rides the same native RecyclerView the list piece uses, 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.

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).

A tree uses the same two mechanisms:

  • Structure comes from TreeController (addNode(NodeParam { parentNodeId, currentNodeId, isFolder, … }) per node, then buildDone()), driven from Rust over the existing bridge.
  • Row content comes from NodeParam.container, a builder slot (“set subcomponent binded on tree item”). It holds a ContentSlot bound to a per-node NodeContent, keyed by node id exactly as navContents keys pages today, and Day mounts the row’s C-API subtree into it.
  • Events arrive through TreeListener: NODE_CLICK for selection and NODE_MOVE with CallbackParam { currentNodeId, parentNodeId, childIndex }, which is the (node, parent, index) commit TreeMoves is shaped around.

There are two costs. TreeController builds nodes imperatively with no cell reuse, so ArkUI’s tree does not recycle and Cap::ListRecycling should say so; a layer panel is fine, a hundred-thousand-row tree is not. And the listener fires after a move, so move_guard cannot run live there; that is the same drop-time verdict Day’s ArkUI list reorder already documents. The component also ships its own add/delete/rename affordances (NODE_ADD, NODE_DELETE, NODE_MODIFY, editIcon), which Day either suppresses or maps onto its own options.

The note on Qt

QTreeView renders through delegates, so hosting a Day-built row means setIndexWidget per row, which Qt documents as inappropriate for large models because it defeats virtualization. That would disqualify it, except that Day’s Qt list already declines to virtualize: it builds a real widget per row into an emulated scroller. On that basis a QTreeView with per-row index widgets costs what Qt’s list costs today and buys native expansion, indentation, keyboard handling and QAccessible::Tree. A spike is due before the emulated path is fixed as Qt’s permanent answer; TreeSource does not change either way.

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("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(true)
.move_guard(|node, parent, _| {
    // Not into itself, not into a descendant, not into a leaf.
    Move::deny_if(parent.is_some_and(|p| p == node || model::is_descendant(p, node)))
})
.on_move(|node, parent, index| model::reparent(node, parent, index))
.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(tr("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 under `.movable(true)`.
    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 `on_move` 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:nativeDay

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("x").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

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

.movable(true) turns on the platform’s drag; move_guard answers while the drag is live, so the affordance reflects the verdict before the user lets go; that is the same contract as list’s reorder_guard, and the reason a guard must stay pure. Every tree needs the same refusals: a node cannot move into itself, into its own descendant, or into a leaf.

on_move is the commit. 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, .on_move, .move_guard, .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 on_move 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 .context_menu_fn 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 ArkTS TreeView driven through the bridge described above: TreeController.addNode per node with a per-node NodeContent in NodeParam.container, TreeListener for NODE_CLICK and NODE_MOVE, and tree_ext::node_params for the per-node extras. This lands last of the natives because it is the most unusual and needs TreeSource settled first.

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. day_ark_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.
  • onViewRecycledListSource::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 on_move 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. Two traps to remember: .id("demo-tree") must chain on the tree builder itself; after .height it tags the wrapper and every expand:/tree_move: step fails with “no tree at this node”; and assert_text against argumented Fluent messages must go through key:+args: (the runner formats them with the same bidi isolates the label carries). 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. on_move(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. move_guard refuses a node into itself or a descendant, and refuses a drop into a leaf.

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.