Reactive Data Models
Companion to: architecture.md, charts.md
Scope: The teksilo-data crate — ListModel, TreeModel, TreeSlice, TreeDataSlice, TreeRowFilter, SelectionModel, CheckedModel, TreeCheckedModel, KeyedTreeCheckedModel, CheckState, ListDataSource, TreeDataSource, ChartModel, ChartWindow, ChartAggregate, ChartSelection, and the change-notification enums that connect them to data-driven widgets (ListView, TreeView, Repeater) and to teksilo-charts (BarChart / LineChart / PieChart).
API reference: the full rustdoc for every type lives at /api/teksilo_data/.
1. Why teksilo-data is its own crate
Data models sit above the widget tree conceptually: a ListModel<String> has no idea whether it is rendered by a ListView, a Repeater, or pretty-printed to stdout. Keeping them in their own crate enforces that separation in the dependency graph.
teksilo-core→ widgets, layout, events — the retained-tree infrastructure.teksilo-data→ reactive collections — depends onteksilo-coreonly forSignal<T>andObserverHandle(utility plumbing).teksilo-widgets→ depends on both and consumes teksilo-data through its widgets.
Application code that wants to share a ListModel<Project> between a Teksilo view and, say, a headless validation pipeline can depend on teksilo-data without pulling in the renderer. The Qleany Clean-Architecture consumer is the main beneficiary: a domain layer holds its entity collections as ListModel<Entity>, a view-model layer observes and transforms, and the view layer binds a ListView to the result. See §6 on MVVM below.
Cloning any teksilo-data handle produces a second handle to the same underlying data. There is no deep-copy semantics and no ownership complication — the models are Rc<RefCell<…>> inside, so clones cost two pointer copies and all see the same items.
2. ListModel<T> — the common case
ListModel<T> is a concrete reactive list: a Vec<T> plus an observer list, behind an Rc<RefCell<…>>. Every mutation method (push, insert, remove, set, move_item, replace_all, clear) drops the mutable borrow before notifying observers, so a callback that reads len() or with_item(...) during the notification does not deadlock the cell.
#![allow(unused)] fn main() { use teksilo_data::{ListModel, DataChange}; let projects: ListModel<Project> = ListModel::new(); projects.push(Project::new("Widget Catalog")); projects.insert(0, Project::new("Onboarding Tutorial")); // Observe changes: let _handle = projects.observe_changes(|change| match change { DataChange::ItemsInserted { range } => println!("{} item(s) inserted at {}", range.len(), range.start), DataChange::ItemsRemoved { range } => println!("{} item(s) removed at {}", range.len(), range.start), DataChange::ItemsMoved { from, to, count } => println!("moved {count} items from {from} to {to}"), DataChange::ItemUpdated { index } => println!("item {index} updated"), DataChange::Reset => println!("list reset"), }); }
with_item(index, f) is the item-access primitive — a callback-taking accessor rather than a reference return, so the RefCell borrow is scoped to the callback lifetime and can't escape. This is the same pattern Signal<T>::with(f) uses.
Observation returns an ObserverHandle — drop the handle to unsubscribe. Widgets that observe a ListModel in their build() typically store the handle on self so it lives as long as the widget. ctx.effect(...) wraps this pattern.
3. ListDataSource — the escape hatch
ListModel<T> holds items in memory. A database-paged view, a filesystem directory listing, or a 10-GB log file doesn't fit that model. ListDataSource is a trait for implementors that own the data in some other form and emit DataChange notifications manually:
#![allow(unused)] fn main() { pub trait ListDataSource: 'static { type Item: 'static; fn len(&self) -> usize; fn with_item<R>(&self, index: usize, f: impl FnOnce(&Self::Item) -> R) -> Option<R>; fn observe_changes(&self, f: impl Fn(&DataChange) + 'static) -> ObserverHandle; } }
ListDataSource is not related to ListModel<T> by inheritance — they are two separate input paths. ListView provides both ListView::new(model, delegate) and ListView::from_source(source, delegate) constructors to consume either.
The trait is not object-safe (associated type + generic methods), which is deliberate: widgets consume it generically. Implementors are free to keep internal locks, LRU caches, or network state across calls; with_item passing the item by callback rather than reference means the implementor controls the borrow lifetime.
Anything that can be presented as "an indexed sequence with change notifications" fits here. Database cursor: implement len() as the cached page count, with_item(i, f) as "fetch if not cached, else look up," observe_changes as "forward the DB replication stream after translating to DataChange." Directory listing: len() is readdir() result length, observe_changes is a file-watcher backed stream.
4. TreeModel<T> + TreeSlice<T> — hierarchies with independent views
The tree side has grown a small family. They layer as source → projection → view: a source holds the tree, a projection turns it into the flat, expandable, per-view row list a TreeView reads (every projection implements TreeDataSource), and TreeRowFilter is a pre-transform that reshapes the rows before a TreeDataSlice. Pick by where the tree lives and what you need on top:
| Type | Layer | Data from | Key | Reach for it when |
|---|---|---|---|---|
TreeModel<T> (§4.1) | source | you build it in memory | NodeId | the tree lives in memory and you own it — the in-memory container |
TreeSlice<T> (§4.2) | projection | wraps a TreeModel | NodeId | you need per-view expand + flatten over a TreeModel (the built-in TreeView source) |
SortFilterTreeModel<T> (§13) | projection | wraps a TreeModel | NodeId | …and sort / tree-aware filter too; it owns its own expand state |
TreeDataSlice<K, T> (§4.4) | projection | Vec<TreeRow> you supply (indent-ordered) | your domain K | the tree lives in an external store (Qleany / DB) as an outline — no TreeModel mirror |
TreeRowFilter<K, T> (§4.5) | pre-transform | Vec<TreeRow> → Vec<TreeRow> | — | sort / filter the rows feeding a TreeDataSlice (wire into set_source; pair with set_all_expanded to reveal matches) |
Two orthogonal companions ride alongside a tree view rather than being sources themselves — pick the NodeId or domain-keyed variant to match your projection: selection — SelectionModel / KeyedSelectionModel<K> (§5); checkboxes — TreeCheckedModel<T> / KeyedTreeCheckedModel<K> (§6, §6.1). Rule of thumb: everything on a TreeModel is NodeId-keyed; everything on a TreeDataSlice/external source is domain-K-keyed.
4.1 TreeModel<T>
The tree equivalent of ListModel<T>. Nodes are stored in a SlotMap<DefaultKey, TreeNode<T>>; each node carries its data, a parent reference, and a Vec<NodeId> of children. NodeId is an opaque handle that is stable across mutations — inserting or removing other nodes does not invalidate existing handles. This is what lets a view (see TreeSlice below) remember an expanded-set of node IDs across a model mutation.
#![allow(unused)] fn main() { use teksilo_data::{TreeModel, NodeId}; let fs: TreeModel<FsEntry> = TreeModel::new(); let docs: NodeId = fs.insert_root(0, FsEntry::dir("docs")); let readme: NodeId = fs.insert_child(docs, 0, FsEntry::file("README.md")); let inner: NodeId = fs.insert_child(docs, 1, FsEntry::dir("inner")); }
Mutations emit TreeChange::{NodeInserted, NodeRemoved, NodeMoved, NodeUpdated, Reset}. NodeRemoved removes the entire subtree; observers see a single event but the subtree is gone.
4.2 TreeSlice<T> — per-view flattening
A TreeModel<T> describes a hierarchy but does not decide what's expanded, what's visible, or how it lays out. That's the TreeView's (or any other consumer's) choice. TreeSlice<T> is the bridge:
- Owns an
expanded: HashSet<NodeId>— the set of nodes whose children are shown. - Maintains a flat
Vec<FlatEntry>of the currently-visible nodes with depth information. - Re-flattens on every
TreeChangefrom the underlying model. - Publishes a
version: Signal<u64>that bumps on each re-flatten — consumers bind to this signal to know when to repaint.
#![allow(unused)] fn main() { pub struct FlatEntry { pub node_id: NodeId, pub depth: usize, // 0 for roots pub has_children: bool, // whether the node has any children in the model pub is_expanded: bool, // whether this slice shows them } }
Two TreeView widgets bound to the same TreeModel each hold their own TreeSlice, so each has its own independent expand state. Opening a folder in one does not open it in the other. This matters for dual-pane file managers, for hierarchical search results shown alongside a full tree, and for any "overview" pane.
Consumers access entries via slice.with_entry(index, |data, entry| …) and get a reactive rebuild signal via slice.version_signal().
4.3 TreeSliceHandle — the consumer API
TreeView doesn't re-implement expand/collapse; it holds a TreeSliceHandle and calls toggle_expand(node_id), expand(node_id), collapse(node_id). The handle is Clone — widgets can share access to the same slice without ambient state. (expand_all() and collapse_all() are available on the owning TreeSlice itself.)
4.4 TreeDataSlice<K, T> — the same, over an external tree
TreeSlice needs a TreeModel to wrap. When the tree's source of truth lives outside teksilo — a Qleany entity store, a database, a virtual filesystem — and you don't want to mirror it into a TreeModel, TreeDataSlice<K, T> gives the same machinery over your own data. It is the tree counterpart of the ListDataSource escape hatch (§3), but ready-made rather than a bare trait — it implements TreeDataSource for you.
You hand it the tree as a flat, indent-ordered row stream — the shape an outline is actually stored in (binders / chapters / scenes, OPML, Markdown headings) — and it derives the hierarchy:
#![allow(unused)] fn main() { use teksilo_data::{TreeDataSlice, TreeRow}; let slice: TreeDataSlice<EntityId, Row> = TreeDataSlice::new(); slice.set_expand_new_nodes(true); // new nodes appear expanded slice.set_source(move || load_rows()); // your `rows::load` -> Vec<TreeRow> slice.reload(); // let view = TreeView::from_source(slice.clone(), delegate); }
Each TreeRow<K, T> is { key, item, depth } in document order. The engine derives every row's parent (the nearest preceding row of strictly smaller depth), its children, the roots (depth-0 rows), and its structural depth — then owns, exactly like TreeSlice:
- a per-view expand set keyed by
K— so expand state (and keyed selection) survive a full re-source, which aTreeModelmirror can't guarantee becauseNodeIds are reassigned on rebuild; - the collapse-aware flatten into visible rows;
- the
version_signal()andfirst_changed_index()side-channel (§13); - the DnD cycle guard.
Identity is your domain key K (an i64 entity id, a tagged enum) — not a positional NodeId. Domain policy is injected as closures, so the mechanism stays in the slice and the meaning stays in your code:
| Setter | Purpose |
|---|---|
set_source(|| …) | re-materialise the rows (called by reload() and after a drop) |
set_reorder(|dragged, target, pos| …) | apply a move through the backend (with undo); returns whether it took |
set_drag_policy(|key| …) | which rows may be dragged |
set_drop_resolver(|dragged, target, target_item, pos| …) | domain drop rules; receives the hovered target's item, so it decides without capturing the slice (no Rc cycle). The cycle guard runs first. |
T: PartialEq is required: the divergence compares item content, so a re-source that changed only a row's text still narrows the height cache to that row.
When to use which: TreeSlice if the data lives in a TreeModel; TreeDataSlice if it lives in an external store as an indent-ordered outline. Implement TreeDataSource by hand only for a source that isn't a resolved indent sequence — a huge/lazy tree that pages children on demand (§14).
4.5 TreeRowFilter<K, T> — sort + filter for the TreeDataSlice pipeline
SortFilterTreeModel (§below, the TreeModel-backed sort/filter projection) owns its own expand state, so stacking it on a TreeDataSlice — which already has one — would give you two projections and two expand states. For an external tree, sort/filter belongs below the slice, on its raw indent-ordered input:
rows::load() → TreeRowFilter::apply → TreeDataSlice::set_source → TreeView
\___ Vec<TreeRow> → Vec<TreeRow> ___/ \___ the one projection ___/
TreeRowFilter is a pure Vec<TreeRow<K, T>> → Vec<TreeRow<K, T>> transform you build once and apply to each freshly-sourced stream (usually inside the set_source closure; re-apply to cached rows on a filter change to avoid re-querying the backend):
#![allow(unused)] fn main() { let sieve = TreeRowFilter::new() .filter_mode(TreeFilterMode::KeepAncestors) .filter(move |item: &Row| item.title.contains(&query)) // outline search .sort(|a: &Row, b: &Row| a.title.cmp(&b.title)); slice.set_source(move || sieve.apply(rows::load())); slice.reload(); slice.set_all_expanded(true); // reveal the whole filtered result (see below) }
TreeRowFilter reshapes the rows but not the slice's per-view expand state, so KeepAncestors keeps the ancestor rows without expanding them — the matches would sit hidden under collapsed ancestors. While a filter is active, call slice.set_all_expanded(true) to reveal the narrowed result, and set_all_expanded(false) when it clears; the user's persistent collapse state is preserved underneath (it's a display override, not a mutation of the expand set).
It reuses the three TreeFilterMode strategies and sorts siblings per parent, then re-emits a valid indent-ordered stream (survivors' depths compact onto their nearest surviving ancestor, which TreeDataSlice re-derives). Two mode details worth knowing: HideNonMatching keeps a node only if it and every ancestor match (children of a hidden parent stay hidden), and KeepDescendants surfaces a matching subtree even when the match's own ancestors don't match — deliberately unlike SortFilterTreeModel's flatten, which drops such a match. KeepAncestors (show the path to each match) is the usual outline-search mode.
5. SelectionModel — one rule set, two widgets
Both ListView and TreeView share selection semantics, so selection lives in its own type in teksilo-data:
#![allow(unused)] fn main() { pub enum SelectionMode { None, Single, Multi } pub struct SelectionModel { mode: SelectionMode, selection: Signal<BTreeSet<usize>>, // indices into the flat view anchor: Rc<Cell<Option<usize>>>, // for Shift+click range extend } }
The selection exposes a Signal<BTreeSet<usize>> (via selection_signal()), so any widget can bind to it and repaint on selection change without manual subscription. Methods:
select(index)— replace selection with a single item; set anchor.toggle(index)— Ctrl+click: add or remove. InSinglemode, degrades toselect.extend_to(index)— Shift+click: select the range from anchor to index, keeping anchor.select_all(range)/clear()— bulk ops.
In None mode every operation is a no-op; widgets can construct a disabled selection model when selection doesn't apply (a toolbar's action list, for instance). select_all is also a no-op in Single mode — every other mutator (select, toggle, extend_to, select_indices) already collapses to one index there, so "select all" has no coherent reading for a model that holds at most one item, and selecting one arbitrary row would be more surprising than doing nothing. ListView's Ctrl+A handler and TableView's select_all helper already gated on Multi before this was enforced in the model itself; GridView's Ctrl+A handler did not, so a single-selection GridView used to select every tile on Ctrl+A.
The selection is stored as flat indices into the view. For a TreeView, those are indices into the TreeSlice's flat list — which means expanding or collapsing a parent changes which NodeIds those indices correspond to. Widgets translate at interaction time (e.g., on Ctrl+click): take the clicked FlatEntry.node_id, find its current flat index via the slice, then call selection.toggle(index). Alternative designs where selection stores NodeIds directly have their own trade-offs (expansion doesn't lose selection, but the signal type changes per-widget); keeping selection index-based keeps the type uniform.
5.1 RowAnchor — surviving the shift
Everything above is about where the cursor is. There is a second, quieter consequence of storing positions: a row's event handlers are built once and then live as long as the row widget does. If they capture the flat index they were built at, expanding a branch above them, applying a filter, or sorting shifts every index below — and a stale handler acts on whatever row moved into that slot. A chevron toggles the wrong branch; a click selects a neighbour; an open cell editor slides onto a different row.
RowAnchor closes over the row's source-owned identity instead and resolves
the row's current position on demand — the captured slot when it still holds
that key, else a lookup by key, else None when the row is gone. The key never
surfaces in the anchor's type: it is captured inside the resolver, so
TreeSource / ListSource keep erasing it and the four views stay
key-agnostic. That erasure is load-bearing — it is what lets TreeTableView
accept any TreeDataSource at all, and a Key type parameter would have had
to unwind it.
All four data views anchor their per-row handlers this way, and the two table
views additionally re-resolve editing_cell on each rebuild, so an open editor
follows its row and closes if that row disappears rather than editing its
replacement.
What a source can offer decides how much this buys:
| Source | Identity | Anchors |
|---|---|---|
TreeSlice, TreeDataSlice, SortFilterTreeModel | real (NodeId / domain key) | track fully |
SortFilterListModel | the row's source index | tracks across any sort/filter reprojection |
ListModel, accessor-backed sources | none — a Vec row is its position | fixed (no worse than a captured index) |
Two limits worth stating plainly. Keys must be unique: resolution falls back
to a lookup returning the first match, so duplicate keys would redirect an
anchor onto another row — the very failure the type exists to prevent. And a
SortFilterListModel's source index is renumbered by an upstream
insert/remove/move, so an anchor can mis-resolve inside the window between that
mutation and the rebuild it schedules; sort/filter-only rebuilds never renumber,
which is the case that actually bites in a UI.
6. CheckedModel and TreeCheckedModel — per-row checkbox state
Selection (where the cursor is) and checkedness (which rows are marked) are orthogonal axes — Outlook / Files-app convention. So checkbox state lives in its own type pair, parallel to SelectionModel:
#![allow(unused)] fn main() { // Flat-list checkbox state. pub struct CheckedModel { checked: Signal<BTreeSet<usize>>, per_index: Rc<RefCell<HashMap<usize, Signal<bool>>>>, } impl CheckedModel { pub fn new() -> Self; pub fn signal_for(&self, index: usize) -> Signal<bool>; // shared per index pub fn checked_indices(&self) -> Vec<usize>; pub fn check(&self, index: usize); pub fn uncheck(&self, index: usize); pub fn toggle(&self, index: usize); pub fn check_all(&self, count: usize); pub fn clear(&self); } // Tree checkbox state with optional descendant→ancestor aggregation. pub enum AggregateMode { None, DescendantsDriveAncestors } pub struct TreeCheckedModel<T: 'static> { /* per-NodeId Signal<CheckState> */ } impl<T: 'static> TreeCheckedModel<T> { pub fn new(tree: TreeModel<T>) -> Self; pub fn with_mode(tree: TreeModel<T>, mode: AggregateMode) -> Self; pub fn signal_for(&self, node: NodeId) -> Signal<CheckState>; // tristate per node pub fn check(&self, node: NodeId); pub fn uncheck(&self, node: NodeId); pub fn toggle(&self, node: NodeId); pub fn checked_nodes(&self) -> Vec<NodeId>; // Checked only — Indeterminate excluded pub fn aggregate_mode(&self) -> AggregateMode; pub fn set_aggregate_mode(&self, mode: AggregateMode); } }
signal_for(...) is cached per key: repeat calls with the same index/NodeId return signals sharing the same root, so widgets bound to it re-render whenever the model is mutated through any other accessor.
TreeCheckedModel defaults to DescendantsDriveAncestors: a parent's CheckState is Checked when all descendants are, Unchecked when none are, Indeterminate otherwise. Setting a parent cascades to all descendants. The None mode disables aggregation when nodes own their state independently.
The CheckState enum (Unchecked | Checked | Indeterminate) lives in teksilo-data (re-exported from teksilo::widgets::CheckState for convenience). The Checkbox widget consumes it via Checkbox::tristate(Signal<CheckState>); StandardListItem.tristate_checkbox(...) and StandardTreeItem.tristate_checkbox(...) accept the same signal.
Wiring with the new row widgets:
#![allow(unused)] fn main() { let checks: CheckedModel = state.app_state(); ListView::new(model, move |idx, item, _sel| { Box::new( StandardListItem::new(lit!(&item.name)) .checkbox(checks.signal_for(idx)) ) }) let tree_checks: TreeCheckedModel<Item> = state.app_state(); TreeView::new_with_context(tree, move |item, entry, _sel, ctx| { Box::new( StandardTreeItem::new(lit!(&item.title)) .from_entry(entry) .tristate_checkbox(tree_checks.signal_for(entry.node_id)) .on_toggle_rc(ctx.toggle_callback()) ) }) }
Path A (ad-hoc Signal<bool> stored on each row's view-model item) remains valid — it's the right answer for fixed dialog lists and small settings panels. Reach for CheckedModel / TreeCheckedModel once item types are domain models you don't want to retrofit a signal field onto.
6.1 KeyedTreeCheckedModel<K> — the same, for an external tree
TreeCheckedModel is bound to a TreeModel and keyed by NodeId. KeyedTreeCheckedModel<K> is its domain-keyed twin — the checkbox counterpart of KeyedSelectionModel (§5) — for the "select scenes to export" tristate over a TreeDataSlice / any TreeDataSource. It takes the tree shape as two injected closures (children + parent), so from_source(slice.clone()) wires it to a slice with no TreeModel to mirror:
#![allow(unused)] fn main() { let checked = KeyedTreeCheckedModel::from_source(outline_slice.clone()); // bind each row's checkbox to `checked.signal_for(key)` / `.bool_signal_for(key)`; // read the result with `checked.checked_keys()`. }
Because state is keyed by your stable domain id, a checked node survives a full re-source. After a reload call prune_missing(|k| source.contains_key(k)) (drops deleted nodes' state and recomputes the ancestors they affected) or reaggregate() (recompute every parent from the new shape) so the tristates stay correct across structural changes. Same cascade / Signal<CheckState> ↔ Signal<bool> bridge / AggregateMode as TreeCheckedModel.
Both methods recompute more than just the keys that already carry a state entry, because a node only gains one once it is explicitly checked/toggled — an untouched leaf never does. prune_missing always calls reaggregate() after removing the stale keys, even when none of the pruned nodes had a state entry of their own: skipping that step whenever stale came back empty would leave a surviving ancestor's cached tristate computed against the child set from before the prune. reaggregate() itself widens its recompute set to every ancestor reachable by walking parent up from each tracked key, not just the tracked keys themselves — otherwise a node that was an untouched leaf before a re-source, and is now a meaningful intermediate branch under the new shape, would read back as the Unchecked default instead of being derived from its new children. Both changes only ever widen what gets recomputed; a key that was already correct cannot come out wrong.
7. MVVM flow
Typical data flow for a list-backed view:
Domain ViewModel View
────── ───────── ────
Entities ─► ListModel<ProjectVM> ─► ListView ─► paint
▲ │ │
│ │ delegate │ on_tap / on_drop
│ │ closure │
│ ▼ ▼
│ Widget subtree intent (enum variant)
│ per item │
│ ▼
└── apply command ◄───── Action::on_invoke
- The domain owns entities (rows in a DB, nodes in a file tree, whatever the app is actually about).
- The view-model layer maps entities to display types (
ProjectVM { title, subtitle, icon, status_color }) and holds them in aListModel<ProjectVM>. The view-model observes the domain; when a domain entity changes, it updates the model, which emitsDataChange. - The view is a
ListViewbound to theListModel<ProjectVM>via a delegate closure. Item interaction fires typed intents (see shortcut-intent-action.md); an ancestorAction::on_invoketranslates the intent into a domain command.
The view never mutates the model directly. The intent/action split means "what the user did" and "what the app does about it" are separable layers — testable independently, replaceable independently, reconfigurable via Action::enabled_when.
8. Repeater vs ListView — when to use which
The widget catalog provides two data-driven collection consumers; pick by the size and scroll behavior of the collection.
Repeater — dynamic, non-virtualized
A Repeater takes a ListModel<T> and a delegate, creates one child subtree per item, and lives inside whatever container the widget tree nests it in. On DataChange::ItemsInserted { range } the delegate fires for each new item; on ItemsRemoved the corresponding subtrees are destroyed; on ItemsMoved children are reordered without recreation. On ItemUpdated { index } the current implementation destroys and recreates that item's subtree; a future optimization path re-uses the existing subtree by pushing updates through reactive bindings on the delegate's Signals (no structural mutation).
Use Repeater for bounded collections where every item should produce a widget:
- Toolbar button lists.
- Tab headers.
- Form fields generated from a schema.
- Chapter lists in a side panel (author has tens of chapters, not thousands).
Repeater does not scroll or clip — it produces siblings. Wrap it in a ScrollArea to get scrolling; every item is still laid out whether visible or not.
ListView — virtualized, scrollable
ListView creates widget subtrees only for the items currently in the viewport plus a small buffer. A ListModel<Row> with 100,000 rows consumes ~20 widgets' worth of tree memory when rendered through a ListView fitting 15 rows on screen. On scroll, newly-visible items get subtrees built and departing items are destroyed.
Use ListView for unbounded or large collections that scroll:
- Database-driven row lists.
- File managers.
- Chat histories.
- Log viewers.
ListView accepts both ListModel<T> and ListDataSource via separate constructors. Selection is driven by a shared SelectionModel. Drag-and-drop (intra-widget reorder) produces insertion-line feedback and emits typed reorder commands; see §8.
9. Drag-and-drop integration
ListView and TreeView are drag sources and drop targets out of the box. Intra-widget reorder routes through ListModel::move_item(from, to) / TreeModel::move_node(node, new_parent, new_index); the widget produces visual feedback (insertion lines, depth-tinted highlight on tree drop targets) and emits typed reorder intents. Cross-widget drag flows through DragPayload; external (OS) drops (files / text / URLs from another app) arrive as a DragPayload with origin() == External through the same handlers — see architecture.md §14 Drag and Drop and drag-and-drop.md §11 for the full picture.
The relevant teksilo-data hook is the DataChange::ItemsMoved { from, to, count } / TreeChange::NodeMoved { node, old_parent, new_parent, new_index } notifications. The source widget emits the mutation on the model; every observer of the model — including other ListViews sharing the data — receives the notification and updates consistently.
10. Qleany and adjacent-app integration
For applications that already have a Clean Architecture split, teksilo-data sits naturally at the ViewModel layer:
- Qleany entities live in the domain crate, no teksilo dependency.
- The view-model crate depends on teksilo-data to publish entity collections as
ListModel<EntityVM>. - The view crate (widgets + windows) depends on teksilo-widgets and binds
ListView/TreeViewto those models.
The architecture doc's EntityListModel example shows the shape: a wrapper that observes a Qleany store, maps its entities through a presentation transform on change, and holds the result in a ListModel<EntityVM>. The widget side is unaware of Qleany.
Nothing in teksilo-data requires Qleany. An application that uses diesel + raw structs, or one that streams events off a Kafka topic, follows the same pattern with whatever domain-layer types it prefers.
11. Testing patterns
teksilo-data is headless. Tests hold models, mutate them, and assert observer callbacks received the right DataChange / TreeChange:
#![allow(unused)] fn main() { let model = ListModel::<String>::new(); let log = Rc::new(RefCell::new(Vec::<DataChange>::new())); let log_c = log.clone(); let _handle = model.observe_changes(move |change| log_c.borrow_mut().push(change.clone())); model.push("alice".to_string()); model.push("bob".to_string()); model.insert(0, "zoe".to_string()); model.remove(1); let events = log.borrow().clone(); assert_eq!(events, vec![ DataChange::ItemsInserted { range: 0..1 }, DataChange::ItemsInserted { range: 1..2 }, DataChange::ItemsInserted { range: 0..1 }, DataChange::ItemsRemoved { range: 1..2 }, ]); }
Widget-tree tests that want a representative model use ListModel::from_vec(vec![...]) and pass the model clone to the ListView under test. Selection tests construct a SelectionModel::new(SelectionMode::Multi) and drive it with select / toggle / extend_to calls, asserting on selection_signal().get().
12. Design rules in one list
- Models are
Rc<RefCell<…>>internally andClone-friendly. Share by cloning; there's no ownership transfer cost. - Every mutation notifies observers after dropping the mutable borrow, so observer callbacks can freely read the model.
- Access items through callbacks (
with_item,with_entry) rather than returning references. TheRefCellborrow stays internal. NodeIdis stable across mutations; index-based addressing is not (indices change when items insert or move). Store IDs, not indices, in long-lived state.- Selection is a separate concern (
SelectionModel) — not part ofListModel/TreeModel. ListModel<T>in memory,ListDataSourcefor external; pick one per view — they are not composable.Repeaterfor bounded non-scrollable collections,ListViewfor scrollable or large ones.- Two
TreeViews sharing aTreeModelget independentTreeSlices; expand state is per-view. - Mutations flow one-way: widget emits typed intent →
Actiontranslates → model mutates → change notification → widgets repaint. The widget never writes directly to the model. ChartModel<T>(§15) follows the sameRc<RefCell<…>>/ mutate-then-notify discipline as every other model here, keyed bySeriesId— a slotmap key that survives series reorder, exactly likeNodeIdsurvives tree mutation.
13. Divergence reporting — first_changed_index()
The four projection layers — TreeSlice, TreeDataSlice,
SortFilterTreeModel, and SortFilterListModel — rebuild their visible
list wholesale on every change, and (for the sort/filter proxies) notify
observers with a blanket Reset. That is the safe notification contract, but it
destroys information a consumer may need: which prefix of the visible
list is actually unchanged. The canonical consumer is variable-row-height
virtualization (ListView / TreeView / TableView / TreeTableView
keep per-visible-row measured heights), but anything caching per-row
derived state can use it.
Each projection therefore computes, during the rebuild it already performs, the first visible index whose content may differ and exposes it as a side-channel:
#![allow(unused)] fn main() { proxy.first_changed_index() // -> Option<usize> }
Some(d)— rows0..dshow the same items, in the same order, at the same depth/expand state as before the rebuild.d == len()means nothing visible changed.None— unknown (no rebuild observed yet); treat as a full change.
Semantics per type:
TreeSlice/SortFilterTreeModel— prefix-compare of the old and new flat lists (NodeIds are stable, so equality means identity). An expand/collapse at flat index k reports k (the toggled row's own entry changed); aNodeUpdatedwith unchanged structure reports that node's flat index.TreeDataSlice— prefix-compare of the old and new visible lists, comparing key, depth, has-children, expand state, and item content (hence theT: PartialEqbound). UnlikeTreeSliceit has noNodeUpdatedevent — a whole re-source (set_rows) replaces the row stream — so folding the item comparison into the prefix check is what catches a pure rename (structure unchanged, one row's text differs) and reports that row's flat index; an expand/collapse reports the toggled row, an append reports the old length. A stable domain keyKis required for equality to mean identity.SortFilterListModel— prefix-compare of the projected source-index map, with an identity floor: upstream inserts/removes/ moves renumber source indices, so equal index values are only trusted below the change point. AnItemUpdatedthat didn't move under the sort reports that row's visible position; an append reports the old length.
The value describes the latest rebuild only and is overwritten by
the next one. Read it synchronously from a change observer
(observe_changes callbacks and version_signal() observers fire
inline on every rebuild, so per-change reads cannot miss a value). The
external DataChange::Reset contract of the proxies is unchanged.
ListDataSource carries a defaulted first_changed_index() (returning
None) so generic consumers reach the side-channel without downcasts.
14. Projecting an external source of truth
§7 and §10 assume the view-model owns its model (the QStandardItemModel
shape). When the domain owns the data and the teksilo model is a projection
over it (a Qleany entity store, a DB, an event stream — the
QAbstractItemModel shape), implement a data source over the domain instead
of mirroring it into a built-in model.
Implement [ListDataSource] / [TreeDataSource] directly over the domain and
feed the view with ListView::from_source / TreeView::from_source. The view
reads through with_item/with_entry and commands through the DnD + lazy
capability methods (drag / can_accept / accept_drop / request_window /
fetch_more), so there is no second in-memory copy to keep in sync — the
domain stays the single source of truth, keyed by its own id (i64, a UUID,
…). Because identity is the domain key (not a positional NodeId), keyed
selection, tree expand state, and scroll follow a domain refresh automatically;
version_signal() + first_changed_index() (§13) narrow row-height caches
across the refresh. This is the path the designer's outline uses, and the one
to reach for whenever the data is a tree, lives behind a query, or doesn't fit
in memory. Full protocol reference: data-source.md.
Trees: reach for TreeDataSlice before hand-rolling TreeDataSource. For
the common case — a tree stored as an indent-ordered row sequence (an outline:
binders/chapters/scenes, OPML, headings) — you rarely implement TreeDataSource
by hand. TreeDataSlice<K, T> (§4.4) is the ready-made engine: hand it
Vec<TreeRow{ key, item, depth }> on
each (re)load via set_source, and it derives the tree from the indent depth and
owns the per-view expand set, the collapse-aware flatten, version_signal(),
first_changed_index() (§13), and the DnD cycle guard — exactly what TreeSlice
gives a TreeModel, but keyed by your domain id and with no TreeModel to
mirror. Inject only the domain policy (set_reorder, set_drag_policy,
set_drop_resolver). Implement TreeDataSource directly only when the source
isn't a resolved indent sequence — a huge/lazy tree that pages children on
demand, where the eager flatten doesn't fit. Add view-layer sort/filter with a
TreeRowFilter on the row stream (§4.5), and tree checkboxes with a
KeyedTreeCheckedModel beside it (§6.1) — both compose without a second
projection.
A bounded, fully-resident, flat list is the one case where a ListModel
projection can still be simpler than a source impl. There is no reconcile
helper for it — keep the projection in sync by emitting the minimal insert /
remove / move_item / set mutations yourself (a Repeater then reorders
subtrees instead of recreating them); accept a Reset only when per-item state
loss is acceptable.
Tables. A TableView's rows are a ListDataSource (or a ListModel for
the bounded-projection case); a TreeTableView's rows are a TreeDataSource. A
cell edit is an in-place value update the source emits. Columns are
configuration, not data.
15. ChartModel, projections, and selection
teksilo-charts (BarChart / LineChart / PieChart) is the other
consumer of teksilo-data besides the widget-catalog row views — it
gets its own model family rather than reusing ListModel<T> because
chart data is two-level (series, then points within a series) and
carries chart-specific concerns (a color per series, a visibility
flag, a distinct paint-only vs. relayout change class) that a flat
list model has no vocabulary for. Full widget-side usage and the
reactivity/binding-level mapping live in
charts.md §3 and
charts.md §8; this section
covers the data-layer mechanism.
15.1 ChartModel<T> — the source
ChartModel<T> is a
concrete reactive multi-series chart data model, Rc<RefCell<…>>
inside like every other model here — cloning shares the same series
and points and all clones see the same ChartChange notifications.
Series live in a flat SlotMap arena keyed by
SeriesId (an opaque,
stable handle — the chart counterpart of NodeId: removing other
series never invalidates an existing SeriesId), plus a separate
order: Vec<SeriesId> giving display order independent of arena
layout. Each series holds a Vec<ChartDatum<T>> ({ category: T, value: f32 }).
#![allow(unused)] fn main() { use teksilo_data::{ChartModel, ChartSeries, ChartDatum}; let model = ChartModel::from_series_vec(vec![ ChartSeries::new("Revenue").data(vec![ ChartDatum::new("Q1".to_string(), 10.0), ChartDatum::new("Q2".to_string(), 20.0), ]), ]); let revenue = model.series_id_at(0).unwrap(); model.push_point(revenue, "Q3".to_string(), 30.0); }
Every mutation method follows the mutate-then-notify discipline
(drop the RefCell borrow, then notify) and does two things:
- Emits a
ChartChangedescribing exactly what changed —SeriesInserted/SeriesRemoved/SeriesMoved/SeriesRenamed/SeriesColorChanged/SeriesPatternChanged/SeriesVisibilityChanged/PointsInserted/PointsRemoved/PointUpdated/SeriesDataReplaced/Reset— to every observer registered viamodel.observe_changes(|change| …) -> ObserverHandle(RAII, same as every other model's observer handle). - Bumps exactly one of two
Signal<u64>version counters:structure_version()for everything that can move the y-domain, tick positions, or bar/point layout (series add/remove/move/rename, and visibility toggles, plus every point mutation), orstyle_version()for the two variants that are paint-only —SeriesColorChanged(fromset_series_color/clear_series_color) andSeriesPatternChanged(fromset_series_pattern/clear_series_pattern). This binary split is deliberately coarse: a consumer that only cares "did anything change" can bind either signal atRebuild; a chart widget that wants to skip a relayout for a pure color change bindsstructure_versionatRelayoutandstyle_versionatRepaintOnlyseparately (see charts.md §8 for the exact wiring).
Construction: ChartModel::new() (empty) + add_series /
insert_series, ChartModel::from_series_vec(vec![ChartSeries...])
(the common multi-series case, no per-item notification — mirrors
ListModel::from_vec), or ChartModel::from_points(vec![ChartDatum...])
(a single anonymous, visible series — the flat, one-dimensional path
PieChart uses).
Each series also carries a
pattern: Option<SeriesPattern>
— the non-colour channel that identifies it (a dash on a line, a
marker shape on a point, a hatch on a filled region). None takes the
pattern the series' position implies, so the channel exists with no
application code; set one when a series' identity must survive a
reorder. This is what keeps a multi-series chart readable in greyscale,
under forced colours, or by a reader who does not see the palette — see
charts.md §5.1 for the rendering table and the policy that
decides when a chart draws it.
ChartSeries<T> (the construction DTO) carries
visible: bool — a plain bool, not a Signal<bool>: it only
describes a series' desired shape at construction time. Once a series
is in the model, mutate it through the model's own methods
(set_series_visible, set_series_color, set_series_pattern,
rename_series, move_series, push_point / insert_point / remove_point /
update_point / replace_series_data), not by reaching back into the
DTO. Read access is callback-scoped like every other model here —
with_series, with_point, with_series_view (one series, metadata
- points slice),
with_all_series(every series as an ordered slice of views) — so theRefCellborrow never escapes.
15.2 ChartWindow<T> — last-N-points streaming projection
ChartWindow<T> wraps a
ChartModel<T> and exposes only the tail window_size points of
every series — the live-scrolling-strip-chart pattern (a sensor feed,
a log-rate graph, a stock ticker). Unlike ChartAggregate below, it
copies no point data: it tracks, per series, the source index of
the window's first visible point and delegates every read straight
through to the source, so it needs no T: Clone bound at all.
#![allow(unused)] fn main() { let window = ChartWindow::new(model.clone(), 10); assert_eq!(window.point_count(revenue), 10.min(model.point_count(revenue))); }
The upstream ChartChange stream is translated, not collapsed to a
blanket Reset — a fixed-size tail window has no sort-key-move hazard
the way SortFilterListModel does, so fine-grained translation is
safe: a tail append into a full window becomes a PointsRemoved +
PointsInserted pair (the window slides), a tail append into a
still-growing window becomes a plain PointsInserted, and anything
that isn't a clean tail append (a mid-series insert, any removal)
falls back to a per-series rebuild reported as SeriesDataReplaced.
set_window_size(n) rebuilds every series and emits Reset.
first_changed_index(series) reports the first window-local index
that may differ since the latest translated change — per-series,
unlike the single flat value the list/tree proxies expose (§13),
because chart data is naturally two-level.
15.3 ChartAggregate<T> — bucket/rollup projection
ChartAggregate<T>
wraps a ChartModel<T> and reduces each series into fixed-size
buckets of bucket_size source points, each collapsed to one
ChartDatum via a ChartAggregateFn
— Mean / Sum / Min / Max / First / Last / Custom(Rc<dyn Fn(&[f32]) -> f32>). The "downsample a long series for display"
pattern — a year of daily sensor readings shown as weekly means, a
tick feed shown as 1-minute bars. Bucket b covers source indices
[b*bucket_size, min((b+1)*bucket_size, n)); a trailing partial
bucket is included; a bucket's category is its first member's.
#![allow(unused)] fn main() { let weekly = ChartAggregate::new(model.clone(), 7, ChartAggregateFn::Mean); }
Unlike ChartWindow, ChartAggregate materializes its buckets —
a bucket's category is a clone of a source point's category, so
building or rebuilding one requires T: Clone (read-only queries
afterward need only T: 'static). Reactivity: a tail append that
doesn't change the bucket count updates the not-yet-full last bucket
in place (PointUpdated); a tail append that starts a new bucket
finalizes the previous last bucket (PointUpdated) and appends the
new one(s) (PointsInserted); a mid-series insert or any removal
falls back to a full per-series rebuild (SeriesDataReplaced).
set_bucket_size(n) / set_aggregate_fn(f) rebuild and emit Reset.
Same per-series first_changed_index() side-channel as ChartWindow.
15.4 ChartSelection — point-level selection
ChartSelection is
the chart counterpart of SelectionModel (§5) / KeyedSelectionModel
— it manages which (SeriesId, usize) pairs are selected across a
ChartModel, share-by-clone like the model itself, with the current
selection exposed as a reactive Signal<HashSet<(SeriesId, usize)>>
via selection_signal(). It uses a HashSet, not the BTreeSet flat
SelectionModel uses, because SeriesId is intentionally not Ord
(an opaque SlotMap key, mirroring NodeId) — there is no natural
ordering across series, only within one series' point indices.
#![allow(unused)] fn main() { let sel = ChartSelection::new(SelectionMode::Multi); sel.select_point(revenue, 1); sel.extend_to(revenue, 3); // (revenue,1), (revenue,2), (revenue,3) sel.toggle_point(revenue, 5); // Ctrl+click }
Same three SelectionModes as SelectionModel (None / Single /
Multi, with anchor-based range extension in Multi).
extend_to(series, target) only extends within the anchor's own
series — a cross-series "range" has no natural order, so it falls
back to a single-point select of (series, target). adjust(&change)
keeps the selection consistent as the source model mutates: a removed
or wholesale-replaced series drops its selected points (and the
anchor, if it pointed there); point insertions/removals shift or drop
indices within their series; series metadata changes (rename / recolor
/ visibility / move / insert) and in-place point updates never affect
which points are selected. prune(exists) drops any selected point
exists rejects — the same shape as KeyedTreeCheckedModel::prune_missing
(§6.1).
ChartWindow and ChartAggregate stay pure teksilo-data building
blocks an app composes on top of a ChartModel (feed a ChartWindow's
or ChartAggregate's output into a fresh ChartModel::from_series_vec
snapshot). ChartSelection is wired in directly, though: all three
chart widgets (BarChart / LineChart / PieChart) accept a shared
handle via .selection(ChartSelection) — the chart reuses its own
hover hit-test to select the tapped mark (Ctrl/Cmd-click toggles it in
Multi mode), clears the selection on a tap that misses every mark,
and paints an accent highlight on every selected mark. See
charts.md §9 for the paint/interaction details and
charts.md §13 for
the current state of the remaining ChartWindow / ChartAggregate
wiring.
See also
- architecture.md §6 UI Construction Patterns —
Repeaterin context, static-vs-dynamic children. - architecture.md §14 Drag and Drop —
DragPayload, cross-widget reorder. - shortcut-intent-action.md — typed intents, ancestor
Actions, how the MVVM command layer lands in Rust. - crates/teksilo-data/src/list_model.rs, tree_model.rs, tree_slice.rs, tree_data_slice.rs, tree_row_filter.rs, selection_model.rs, list_data_source.rs, tree_data_source.rs, keyed_tree_checked_model.rs.
- crates/teksilo-data/src/data_change.rs, tree_change.rs.
- crates/teksilo-data/src/chart_model.rs (§15) —
ChartModel<T>,ChartChange,SeriesId; see also chart_window.rs, chart_aggregate.rs, chart_selection.rs. - examples/data_collections — runnable demonstration of ListView, TreeView, Repeater, SelectionModel, and intra-widget DnD.
- examples/chart_demo —
teksilo-chartsdemo; see charts.md for the chart-widget side ofChartModel.