teksilo_widgets/tree_table_view.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `TreeTableView<T>` — hierarchical multi-column data table with expand/collapse.
5//!
6//! Sibling of [`TableView`](crate::TableView) for tree-shaped data. Each row carries
7//! a depth level; one designated column (the *tree column*, defaulting to the first)
8//! shows a twist (chevron) and an indent gutter that toggles the row's children.
9//! Backed by a [`SortFilterTreeModel<T>`] so sort, filter, and expand state compose
10//! without extra bookkeeping. Shares the header, column, keyboard, and selection
11//! modules with `TableView`.
12//!
13//! Rows live in a `TreeBodyPane` — a sibling of the scrollbar — so buffer-exit /
14//! selection / expand rebuilds are never deferred mid-thumb-drag. Three row-height
15//! modes: uniform (`row_height`, fast path), exact per-flat-index callback
16//! (`row_height_fn`), and auto-measured (`auto_row_height` — grows to tallest cell).
17//!
18//! ## Common patterns
19//!
20//! **A checkbox column.** Selection and "checked" are different things — a
21//! checkbox column wants its own state, with parent/child propagation. Build it
22//! from [`TreeCheckedModel`](teksilo_data::TreeCheckedModel) over the same tree
23//! the view projects.
24//!
25//! A cell delegate receives `(&T, &CellContext)` and **`CellContext` carries no
26//! node identity** — only [`row_index`](crate::CellContext::row_index). So
27//! capture the projection and resolve the row's `NodeId` through it:
28//!
29//! ```ignore
30//! let proxy = SortFilterTreeModel::new(tree);
31//! let checks = TreeCheckedModel::new(proxy.tree());
32//! let for_cells = proxy.clone();
33//! let col = Column::new("done", lit!("Done"), move |_item, cx: &CellContext| {
34//! match for_cells.visible_node_id(cx.row_index) {
35//! Some(node) => Box::new(Checkbox::new(checks.check_state(node))) as Box<dyn Widget>,
36//! None => Box::new(Spacer::new()),
37//! }
38//! });
39//! ```
40//!
41//! For a tree whose identity is a domain key rather than a `NodeId`, use
42//! [`KeyedTreeCheckedModel`](teksilo_data::KeyedTreeCheckedModel) instead — it
43//! survives a full re-source, which a `NodeId`-keyed set cannot.
44//!
45//! ## Accessibility
46//!
47//! Root emits `Role::TreeGrid`; rows carry `set_level` + `set_expanded`.
48//! ArrowLeft / ArrowRight on the tree column collapse / expand.
49//!
50//! ```ignore
51//! // Column delegates capture closures — use ignore.
52//! use teksilo_widgets::TreeTableView;
53//! use teksilo_data::TreeModel;
54//! # struct File { name: String }
55//! # let model: TreeModel<File> = TreeModel::new();
56//! let _view = TreeTableView::new(model).row_height(28.0);
57//! ```
58//!
59//! ## Pan to scroll
60//!
61//! The view installs [`common::scrollable::ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior),
62//! which gives it the shared wheel arithmetic, a finger's pan and the
63//! `PanClaim` that puts it on a pan's claimant chain. A pan scrolls it, the
64//! release coasts, and a pan it cannot absorb hands the **whole** event to the
65//! container outside — never a residual. A pan that starts on a row scrolls
66//! rather than activating it or collapsing a multi-selection onto it. Both axes
67//! are claimed. Shift+wheel still
68//! scrolls the columns, and a finger's pan is never remapped by a held Shift:
69//! the remap is a wheel convention, and turning a drag sideways is not what
70//! the hand asked for.
71
72mod body_pane;
73mod widget_impl;
74
75use std::cell::{Cell, RefCell};
76use std::collections::HashMap;
77use std::rc::Rc;
78use std::time::Duration;
79
80use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
81
82use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
83use teksilo_core::binding::BindingLevel;
84use teksilo_core::build_context::BuildContext;
85use teksilo_core::drag_payload::DragPayload;
86use teksilo_core::event::EventResponse;
87use teksilo_core::kinetic::KineticScroller;
88use teksilo_core::pointer::touch_action::PanAxes;
89use teksilo_core::signal::{Prop, Signal};
90use teksilo_core::widget::{EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement};
91use teksilo_core::widget_builder::HandlerSet;
92use teksilo_core::widget_id::WidgetId;
93use teksilo_data::{
94 DropPosition, KeyedSelectionModel, NodeId, SelectionModel, SortDirection, SortFilterTreeModel,
95 TreeFilterMode, TreeModel,
96};
97use teksilo_i18n::LocalizedString;
98use teksilo_tokens::{BorderRole, OverscrollStyle, SurfaceRole};
99
100use crate::styles::recipe_table_style as cp;
101
102use crate::common::row_metrics::{HeightSource, RowMetrics, SharedRowMetrics};
103use crate::common::scroll::OverscrollBehavior;
104use crate::data_views::{DragTransferMode, RowDragData, RowSelection, ViewId, ViewKind};
105use crate::data_views::{DropViz, drop_into_tint};
106use crate::scroll_area::ScrollBarMode;
107use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
108use crate::table_view::ColumnReorderDragData;
109use crate::table_view::body::SharedColumnWidths;
110use crate::table_view::column::{
111 Column, ColumnResizePolicy, EditTriggers, GridLines, PinnedSide, TabTraversal,
112};
113use crate::table_view::header::{
114 ColumnResizeInfo, ColumnResizeTable, HeaderCell, HeaderCellSpec, HeaderRow, ResizeStateHandle,
115 attach_header_reorder_handlers,
116};
117use crate::table_view::imperative;
118use crate::table_view::keyboard;
119use crate::table_view::layout;
120use crate::table_view::row_navigator::RowNavigator;
121use crate::table_view::selection::{CellSelectionModel, TableSelectionMode};
122use crate::tree_source::TreeSource;
123use teksilo_data::{DropResponse, TreeDataSource};
124
125const BUFFER_ROWS: usize = 5;
126const SCROLLBAR_THICKNESS: f32 = 12.0;
127
128/// Hierarchical row navigator. Adapts a [`TreeSource`]'s flat-list view to the
129/// [`RowNavigator`] interface used by the shared keyboard handler.
130///
131/// Index-keyed throughout, so it works over any [`TreeDataSource`] — a
132/// `SortFilterTreeModel` over a `TreeModel`, or an external store carrying its
133/// own `Key`.
134pub(crate) struct TreeNavigator<T: 'static> {
135 source: Rc<TreeSource<T>>,
136}
137
138impl<T: 'static> TreeNavigator<T> {
139 pub(crate) fn new(source: Rc<TreeSource<T>>) -> Self {
140 Self { source }
141 }
142}
143
144impl<T: 'static> RowNavigator for TreeNavigator<T> {
145 fn row_count(&self) -> usize {
146 self.source.visible_count()
147 }
148
149 fn depth(&self, row: usize) -> Option<usize> {
150 self.source.meta(row).map(|m| m.depth)
151 }
152
153 fn has_children(&self, row: usize) -> bool {
154 self.source
155 .meta(row)
156 .map(|m| m.has_children)
157 .unwrap_or(false)
158 }
159
160 fn is_expanded(&self, row: usize) -> bool {
161 self.source
162 .meta(row)
163 .map(|m| m.is_expanded)
164 .unwrap_or(false)
165 }
166
167 fn toggle_expanded(&self, row: usize) {
168 self.source.toggle_at(row);
169 }
170}
171
172/// Hierarchical multi-column widget. See module documentation.
173pub struct TreeTableView<T: 'static> {
174 /// Erased row access — every read (counts, entries, expansion, DnD,
175 /// keyboard reorder) goes through here, so the widget works over any
176 /// [`TreeDataSource`] and never needs to know the source's `Key`.
177 source: Rc<TreeSource<T>>,
178 /// Present only on the [`from_projection`](Self::from_projection) /
179 /// [`new`](Self::new) paths. It backs the `NodeId`-typed public API
180 /// ([`expand`](Self::expand), [`projection`](Self::projection), …), which is
181 /// meaningless for an external source carrying its own key — those methods
182 /// no-op when this is `None`.
183 proxy: Option<SortFilterTreeModel<T>>,
184
185 columns: Vec<Column<T>>,
186 /// Column id hosting the twist + indent. `None` defaults to the
187 /// first column at build time.
188 tree_column_id: Option<String>,
189 indent_per_level: Option<f32>,
190 row_height: Option<f32>,
191 /// Height-mode selection (uniform / exact callback / auto-measure).
192 height_source: HeightSource,
193 /// Row geometry — shared with the keyboard handler and the body
194 /// pane.
195 row_metrics: SharedRowMetrics,
196 header_height: Option<f32>,
197 show_header: bool,
198 selection_mode: TableSelectionMode,
199 /// Row selection — index-based `SelectionModel` or keyed
200 /// `KeyedSelectionModel<NodeId>`, unified behind the index-facing facade.
201 row_selection: Option<RowSelection>,
202 cell_selection: Option<CellSelectionModel>,
203 alternating_rows: bool,
204 grid_lines: GridLines,
205 /// See [`Self::stretch_last_column`].
206 stretch_last_column: bool,
207 a11y_label: Option<LocalizedString>,
208 show_internal_scrollbars: bool,
209 column_resize_policy: ColumnResizePolicy,
210 tab_traversal: TabTraversal,
211 edit_triggers: EditTriggers,
212 #[allow(clippy::type_complexity)]
213 on_cell_edit_request: Option<Rc<dyn Fn(usize, &str, &mut EventContext)>>,
214 on_cell_edit_dismissed: Option<Rc<dyn Fn(usize, &str, &mut EventContext)>>,
215 #[allow(clippy::type_complexity)]
216 on_row_activate: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
217
218 /// Animate wheel scrolling instead of snapping to the new offset.
219 /// Enabled by default — mirrors `ScrollArea`. Without it, each wheel
220 /// notch jumps by `row_height` per delivered line, which reads as a
221 /// coarse multi-row jump rather than a smooth glide.
222 smooth_scrolling: bool,
223 /// Duration of the smooth scroll animation.
224 smooth_scroll_duration: Duration,
225
226 /// How the scroll bar is displayed (default `Permanent`). `Overlay`
227 /// and `Thin` float the bar over the content instead of reserving a
228 /// layout column for it, mirroring `ScrollArea::scroll_bar_style`.
229 scroll_bar_style: ScrollBarMode,
230
231 // Public reactive signals
232 scroll_y: Signal<f32>,
233 max_scroll_y: Signal<f32>,
234 /// Scroll-chaining behavior at the boundary (default `Chain`).
235 overscroll_behavior: OverscrollBehavior,
236 viewport_ratio_y: Signal<f32>,
237 /// Horizontal scroll offset of the Middle (unpinned) pane — mirrors
238 /// `TableView::scroll_x`. See `table_view::PaneBoundaries`.
239 scroll_x: Signal<f32>,
240 max_scroll_x: Signal<f32>,
241 viewport_ratio_x: Signal<f32>,
242 /// This surface's pan physics: the range a finger's pan is clamped to and
243 /// the offset it is currently holding. Owned by the view rather than by
244 /// the [`ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior)
245 /// so it survives a rebuild, and so `place_children` — the only pass that
246 /// knows the viewport extent — can publish into it.
247 scroller: Rc<RefCell<KineticScroller>>,
248 sort_signal: Signal<Option<(String, SortDirection)>>,
249 column_widths_signal: Signal<HashMap<String, f32>>,
250 column_order_signal: Signal<Vec<String>>,
251 column_pinning_signal: Signal<HashMap<String, PinnedSide>>,
252 filters_signal: Signal<HashMap<String, String>>,
253 focused_cell: Signal<Option<(usize, usize)>>,
254 /// The realized `(row index -> row wrapper id)` map, filled by the body
255 /// pane each build. Lets this widget's `&self` methods resolve a row index
256 /// to a widget without reaching into the pane. Mirrors `ListView::row_map`.
257 row_map: Rc<RefCell<Vec<(usize, WidgetId)>>>,
258 editing_cell: Signal<Option<(usize, usize)>>,
259 /// Type-ahead ("type to jump") label extractor — opt-in via
260 /// [`type_ahead_label`](Self::type_ahead_label).
261 #[allow(clippy::type_complexity)]
262 type_ahead_label: Option<Rc<dyn Fn(&T) -> String>>,
263 /// Reset window for the type-ahead search term.
264 type_ahead_timeout: Duration,
265 /// Persistent type-ahead buffer (survives the per-keystroke rebuild).
266 type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
267 /// Widget shown in place of the rows when nothing is visible — an empty
268 /// tree, or a filter that matched nothing.
269 #[allow(clippy::type_complexity)]
270 empty_view: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
271 /// Set on the first `place_children`. Until then `viewport_height` still
272 /// holds its construction placeholder, so viewport-relative imperatives
273 /// (`ensure_row_visible`) would scroll against a size that was never real.
274 laid_out: Rc<Cell<bool>>,
275 /// Anchor for the row with an open cell editor, so the editor follows its
276 /// row instead of its index. See `reconcile_editing_row`.
277 editing_anchor: Rc<RefCell<Option<crate::data_views::RowAnchor>>>,
278
279 // Build state
280 header_row_id: Option<WidgetId>,
281 body_pane_id: Option<WidgetId>,
282 scrollbar_id: Option<WidgetId>,
283 /// Horizontal scroll bar along the bottom of the Middle pane only —
284 /// mirrors `TableView::h_scrollbar_id`.
285 h_scrollbar_id: Option<WidgetId>,
286 empty_id: Option<WidgetId>,
287 /// Pane-local rebuild trigger + buffered range, owned here so they
288 /// survive `TreeTableView` rebuilds (each rebuild constructs a fresh
289 /// `TreeBodyPane` struct that inherits these handles).
290 pane_version: Signal<u64>,
291 pane_built_start: Rc<Cell<usize>>,
292 pane_built_end: Rc<Cell<usize>>,
293 /// Bumped by the pane when a measure pass changes the content
294 /// total; bound at `Relayout` on this root so `max_scroll_y` / the
295 /// thumb ratio are recomputed with the corrected total next frame.
296 pane_total_refresh: Signal<u64>,
297
298 /// Enable drag-to-reorder of rows (pointer drag + Alt+Arrow). The move
299 /// reparents/reorders nodes in the underlying `TreeModel`, cycle-guarded.
300 /// Suppressed while a sort is active (the visible order then differs from
301 /// the tree order, so a manual reorder would be meaningless).
302 reorderable: bool,
303 /// Active row-drop insertion indicator `(body_local_y, width)`. Set by
304 /// `on_drag_hover`, cleared on leave / drop, read by `paint`.
305 drop_feedback: Signal<Option<DropViz>>,
306
307 /// Whether activation is a single or double click (default `DoubleClick`).
308 activate_on: crate::data_views::ActivateOn,
309
310 /// `true` while this view — its root or any descendant — holds keyboard
311 /// focus. Captured at build from [`BuildContext::begin_view_focus`], bound
312 /// `RepaintOnly`. Drives focus-aware selection: the band paints `Selected`
313 /// while focused, muted `SelectedInactive` once focus leaves the view.
314 view_focused: Signal<bool>,
315 /// Input-modality `:focus-visible`. Gates the cell focus ring to keyboard
316 /// navigation (never a mouse click). Bound `RepaintOnly`.
317 focus_visible: Signal<bool>,
318
319 // Layout state
320 column_widths: SharedColumnWidths,
321 display_indices: Rc<RefCell<Vec<usize>>>,
322 /// Counts of (leading-pinned, middle, trailing-pinned) columns —
323 /// mirrors `TableView::pane_boundaries`. Populated by `display_order()`.
324 pane_boundaries: Rc<RefCell<crate::table_view::PaneBoundaries>>,
325 /// `(row, display_pos) -> WidgetId` for every cell realized by the
326 /// body pane's latest `build()`. Mirrors `TableView::cell_map` (the
327 /// GridView `tile_map` pattern — shared between the root and its
328 /// sibling-of-scrollbar pane); `accessibility()` reads it to point
329 /// `active_descendant` at the keyboard-focused cell's own AT node.
330 cell_map: Rc<RefCell<Vec<((usize, usize), WidgetId)>>>,
331 viewport_height: Rc<Cell<f32>>,
332 /// Middle-pane viewport width, snapshotted by `place_children` —
333 /// mirrors `TableView::middle_viewport_width`.
334 middle_viewport_width: Rc<Cell<f32>>,
335 /// The row-area's absolute (window) rect (below the header), cached by
336 /// `place_children`. Threaded into the keyboard handler so it can chase the
337 /// focused row into any *enclosing* scroll area via
338 /// [`EventContext::ensure_visible`](teksilo_core::widget::EventContext::ensure_visible).
339 body_bounds: Rc<Cell<Rect>>,
340 resize_state: ResizeStateHandle,
341 /// Display slot of the column under an active resize drag, or `None`.
342 /// Mirrors `TableView::resize_target` — shared with every `HeaderCell`
343 /// so the *target* column carries the "resizing" chrome even when the
344 /// gesture is anchored on its neighbour's half of the grip.
345 resize_target: Signal<Option<usize>>,
346 /// Window x of the prospective divider during a
347 /// [`ColumnResizePolicy::OnRelease`] drag. Mirrors
348 /// `TableView::resize_preview_x`.
349 resize_preview_x: Signal<Option<f32>>,
350 /// Width of the header strip (= the column band) snapshotted by
351 /// `place_children`. Mirrors `TableView::header_strip_width` — the
352 /// column-reorder drop handler needs it to mirror the drop x under RTL.
353 header_strip_width: Rc<Cell<f32>>,
354 /// Stable id grouping the column-header reorder/resize drag (an
355 /// unrelated mechanism to the row DnD below — see `table_view::header`).
356 table_id: usize,
357
358 /// Stable, kind-tagged identity for this view's **row** drag-and-drop —
359 /// distinct from `table_id` above. Minted via
360 /// `ViewId::next(ViewKind::TreeTable)`.
361 model_id: ViewId,
362
363 /// Cross-widget export / foreign-receive machinery — the builders
364 /// (`.exportable`, `.export_external`, `.accept_foreign_rows`,
365 /// `.on_rows_received`, `.on_rows_transferred_out`), the drag-start
366 /// payload build, and the move-out completion, shared by all five data
367 /// views. `TreeTableView` builds its reader + stable-key removal thunk
368 /// inline at drag-start (see `TreeBodyPane::build`'s `on_drag`) rather
369 /// than from source capability closures, so the key it removes by is
370 /// resolved once at drag-start and stays correct even if a mid-drag
371 /// spring-load reflattens the rows under the pointer.
372 export: crate::data_views::RowExport<T>,
373 /// Raw escape hatch for a payload this view cannot interpret itself.
374 ///
375 /// A source-backed view ([`from_source`](Self::from_source)) expresses
376 /// foreign-accept through its source's capability closures, like
377 /// `ListView` / `TableView`. This hook is what a **projection**-backed
378 /// view ([`from_projection`](Self::from_projection) / [`new`](Self::new))
379 /// has instead, since a `SortFilterTreeModel` carries no such closures.
380 /// Fires for any payload NOT recognized as this view's own row drag,
381 /// dropped on a node —
382 /// `(payload, target node, drop position, ctx) -> accepted`. Tried after
383 /// [`on_rows_received`](Self::on_rows_received).
384 #[allow(clippy::type_complexity)]
385 on_foreign_drop:
386 Option<Rc<dyn Fn(&DragPayload, NodeId, DropPosition, &mut EventContext) -> bool>>,
387
388 /// Whole-view enabled state, statically or reactively. Forwarded to the
389 /// arena via `ctx.enabled_when(self_id, self.enabled.clone())` at build
390 /// time; a disabled view greys out and stops accepting focus /
391 /// selection / keyboard input (arena-gated).
392 enabled: Prop<bool>,
393}
394
395impl<T: 'static> TreeTableView<T> {
396 /// Wrap a `SortFilterTreeModel<T>`.
397 /// Wrap a `SortFilterTreeModel<T>`.
398 pub fn from_projection(proxy: SortFilterTreeModel<T>) -> Self {
399 let source = Rc::new(TreeSource::from_data_source(Rc::new(proxy.clone())));
400 Self::assemble(source, Some(proxy))
401 }
402
403 /// Build a tree table over any [`TreeDataSource`] — an external source of
404 /// truth (a Qleany entity store, a database, a virtual filesystem) carrying
405 /// its own `Key`, so it needs no `TreeModel` mirror.
406 ///
407 /// This is the tree-table sibling of
408 /// [`TreeView::from_source`](crate::TreeView::from_source). Because the
409 /// source owns identity, its expand state (and a keyed selection) survive a
410 /// full re-source — which a `TreeModel` mirror cannot guarantee, since
411 /// `NodeId`s are reassigned on rebuild.
412 ///
413 /// The `NodeId`-typed methods ([`expand`](Self::expand),
414 /// [`projection`](Self::projection), [`keyed_selection`](Self::keyed_selection))
415 /// do not apply here and no-op; drive expansion through the source itself.
416 ///
417 /// Row drag-reorder **is** wired on this path: a drop routes through the source's
418 /// own `drag` / `can_accept` / `accept_drop`, exactly as
419 /// [`TreeView`](crate::TreeView) does — so the
420 /// source owns both the cycle guard and the commit. Note that
421 /// [`TreeDataSlice::drag`](teksilo_data::TreeDataSlice) defaults to `NoDrag`: an
422 /// external source must opt its rows in before anything can be dragged.
423 pub fn from_source<S: TreeDataSource<Item = T> + 'static>(source: S) -> Self {
424 Self::assemble(Rc::new(TreeSource::from_data_source(Rc::new(source))), None)
425 }
426
427 /// Like [`from_source`](Self::from_source) but with **keyed** selection:
428 /// the `KeyedSelectionModel<S::Key>` tracks rows by source identity, so it
429 /// survives expand / collapse, sort / filter and a full re-source. Pruning
430 /// consults the source's `contains_key`, so a collapsed-but-present row
431 /// keeps its selection. The view stays `TreeTableView<T>` — the `Key` is
432 /// captured here.
433 pub fn from_source_keyed<S: TreeDataSource<Item = T> + 'static>(
434 source: S,
435 keyed: KeyedSelectionModel<S::Key>,
436 ) -> Self
437 where
438 S::Key: teksilo_data::ItemKey,
439 {
440 let s = Rc::new(source);
441 let key_at = {
442 let s = s.clone();
443 Rc::new(move |i| s.key_at(i)) as Rc<dyn Fn(usize) -> Option<S::Key>>
444 };
445 let len = {
446 let s = s.clone();
447 Rc::new(move || s.visible_count()) as Rc<dyn Fn() -> usize>
448 };
449 let contains = {
450 let s = s.clone();
451 Rc::new(move |k: &S::Key| s.contains_key(k)) as Rc<dyn Fn(&S::Key) -> bool>
452 };
453 let mut view = Self::assemble(Rc::new(TreeSource::from_data_source(s)), None);
454 view.row_selection = Some(RowSelection::from_keyed(keyed, key_at, len, contains));
455 view
456 }
457
458 fn assemble(source: Rc<TreeSource<T>>, proxy: Option<SortFilterTreeModel<T>>) -> Self {
459 use std::sync::atomic::{AtomicUsize, Ordering};
460 static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
461 let table_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
462 Self {
463 source,
464 proxy,
465 columns: Vec::new(),
466 tree_column_id: None,
467 indent_per_level: None,
468 row_height: None,
469 height_source: HeightSource::Uniform,
470 row_metrics: Rc::new(RefCell::new(RowMetrics::uniform(cp::ROW_HEIGHT, 0.0))),
471 header_height: None,
472 show_header: true,
473 selection_mode: TableSelectionMode::default(),
474 row_selection: None,
475 cell_selection: None,
476 alternating_rows: false,
477 grid_lines: GridLines::None,
478 stretch_last_column: false,
479 a11y_label: None,
480 show_internal_scrollbars: true,
481 column_resize_policy: ColumnResizePolicy::default(),
482 tab_traversal: TabTraversal::default(),
483 edit_triggers: EditTriggers::default(),
484 on_cell_edit_request: None,
485 on_cell_edit_dismissed: None,
486 on_row_activate: None,
487 reorderable: false,
488 drop_feedback: Signal::new(None),
489 activate_on: crate::data_views::ActivateOn::default(),
490 smooth_scrolling: true,
491 smooth_scroll_duration: Duration::from_millis(150),
492 scroll_bar_style: ScrollBarMode::Permanent,
493 scroll_y: Signal::new_animated(0.0),
494 max_scroll_y: Signal::new(0.0),
495 overscroll_behavior: OverscrollBehavior::default(),
496 viewport_ratio_y: Signal::new(1.0),
497 scroll_x: Signal::new_animated(0.0),
498 max_scroll_x: Signal::new(0.0),
499 viewport_ratio_x: Signal::new(1.0),
500 scroller: Rc::new(RefCell::new(KineticScroller::new(OverscrollStyle::Clamp))),
501 sort_signal: Signal::new(None),
502 column_widths_signal: Signal::new(HashMap::new()),
503 column_order_signal: Signal::new(Vec::new()),
504 column_pinning_signal: Signal::new(HashMap::new()),
505 filters_signal: Signal::new(HashMap::new()),
506 focused_cell: Signal::new(None),
507 row_map: Rc::new(RefCell::new(Vec::new())),
508 type_ahead_label: None,
509 type_ahead_timeout: crate::common::type_ahead::DEFAULT_TYPE_AHEAD_TIMEOUT,
510 type_ahead: crate::common::type_ahead::TypeAheadState::new(),
511 // Replaced at build with the live tree signals.
512 view_focused: Signal::new(true),
513 focus_visible: Signal::new(false),
514 editing_cell: Signal::new(None),
515 empty_view: None,
516 laid_out: Rc::new(Cell::new(false)),
517 editing_anchor: Rc::new(RefCell::new(None)),
518 header_row_id: None,
519 body_pane_id: None,
520 scrollbar_id: None,
521 h_scrollbar_id: None,
522 empty_id: None,
523 pane_version: Signal::new(0_u64),
524 pane_built_start: Rc::new(Cell::new(0)),
525 pane_built_end: Rc::new(Cell::new(0)),
526 pane_total_refresh: Signal::new(0_u64),
527 column_widths: Rc::new(RefCell::new(Vec::new())),
528 display_indices: Rc::new(RefCell::new(Vec::new())),
529 pane_boundaries: Rc::new(RefCell::new(crate::table_view::PaneBoundaries::default())),
530 cell_map: Rc::new(RefCell::new(Vec::new())),
531 viewport_height: Rc::new(Cell::new(600.0)),
532 middle_viewport_width: Rc::new(Cell::new(600.0)),
533 body_bounds: Rc::new(Cell::new(Rect::ZERO)),
534 resize_state: Rc::new(RefCell::new(None)),
535 resize_target: Signal::new(None),
536 resize_preview_x: Signal::new(None),
537 header_strip_width: Rc::new(Cell::new(0.0)),
538 table_id,
539 model_id: ViewId::next(ViewKind::TreeTable),
540 export: crate::data_views::RowExport::default(),
541 on_foreign_drop: None,
542 enabled: Prop::Static(true),
543 }
544 }
545
546 /// Wrap a raw `TreeModel<T>` — convenience for callers that don't
547 /// need sort/filter. Internally builds an identity
548 /// `SortFilterTreeModel`.
549 pub fn new(model: TreeModel<T>) -> Self {
550 Self::from_projection(SortFilterTreeModel::new(model))
551 }
552
553 // ── Builder ────────────────────────────────────────────────────────
554
555 /// Enable or disable the whole view. A disabled view greys out and stops
556 /// accepting focus / selection / keyboard input (arena-gated).
557 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
558 self.enabled = enabled.into();
559 self
560 }
561
562 /// Set the scroll-chaining behavior at the boundary (default
563 /// [`OverscrollBehavior::Chain`]; [`Contain`](OverscrollBehavior::Contain)
564 /// disables chaining to an ancestor scrollable).
565 pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
566 self.overscroll_behavior = behavior;
567 self
568 }
569
570 /// Enable or disable animated wheel scrolling (enabled by default).
571 /// When disabled, wheel events snap immediately to the new offset.
572 pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
573 self.smooth_scrolling = enabled;
574 self
575 }
576
577 /// Enable **type-ahead** ("type to jump"): typing a printable character
578 /// while the tree-table has keyboard focus jumps the focused row to the
579 /// next *visible* row whose label starts with the accumulated search term,
580 /// wrapping around (Qt `keyboardSearch` / macOS & Windows type-select).
581 /// `label(&item)` yields the searchable text; matching is
582 /// ASCII-case-insensitive. A pause longer than the
583 /// [`type_ahead_timeout`](Self::type_ahead_timeout) starts a fresh term.
584 pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self {
585 self.type_ahead_label = Some(Rc::new(label));
586 self
587 }
588
589 /// Reset window between keystrokes before the type-ahead search term
590 /// clears (default 500 ms). A zero duration disables type-ahead.
591 pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self {
592 self.type_ahead_timeout = timeout;
593 self
594 }
595
596 /// Duration of the smooth scroll animation (default 150 ms).
597 pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
598 self.smooth_scroll_duration = duration;
599 self
600 }
601
602 /// How the scroll bar is displayed (default `Permanent`). `Overlay`
603 /// and `Thin` float the bar over the content instead of reserving a
604 /// layout column for it, mirroring `ScrollArea::scroll_bar_style`.
605 pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
606 self.scroll_bar_style = style;
607 self
608 }
609
610 /// Append a column definition. Columns are displayed in declaration order unless
611 /// reordered by the user.
612 pub fn add_column(mut self, col: Column<T>) -> Self {
613 self.columns.push(col);
614 self
615 }
616
617 /// Enable drag-to-reorder of **rows** (pointer drag + keyboard
618 /// Alt+ArrowUp/Down). Distinct from
619 /// [`Column::reorderable`](crate::Column::reorderable), which reorders
620 /// *columns* and defaults to `true`; this defaults to `false`.
621 ///
622 /// A drop reparents/reorders the dragged node in the underlying
623 /// `TreeModel` (top third of a row = Before, middle = Into / make-child,
624 /// bottom = After). The move is cycle-guarded — dropping a node onto
625 /// itself or into its own subtree is refused (no insertion line). Reorder
626 /// is **suppressed while a sort is active**: with the visible order driven
627 /// by the sort, a manual reorder would have no visible effect.
628 pub fn reorderable(mut self, enabled: bool) -> Self {
629 self.reorderable = enabled;
630 self
631 }
632
633 /// Make rows **droppable outside this view** — on a
634 /// [`DropTarget`](crate::DropTarget), another data view, or the OS.
635 ///
636 /// A dragged row (or the whole selection, when the pressed row is part of a
637 /// multi-selection) carries clones of its items in a public
638 /// [`RowDragData<T>`](crate::RowDragData), so a foreign receiver can pull
639 /// them out with `payload.get_typed::<RowDragData<T>>()` /
640 /// `DropTarget::on_drop_typed::<RowDragData<T>>()` — no serialization. This
641 /// also makes rows a drag source even without [`reorderable`](Self::reorderable).
642 ///
643 /// `mode` chooses what happens to the origin rows once a *foreign* target
644 /// accepts them: [`DragTransferMode::Move`] removes them — by default,
645 /// directly from the underlying `TreeModel` (any dragged node that is a
646 /// descendant of another dragged node is skipped, since removing the
647 /// ancestor already removes it); override via
648 /// [`on_rows_transferred_out`](Self::on_rows_transferred_out).
649 /// [`DragTransferMode::Copy`] leaves them. A same-view reorder is never a
650 /// transfer, so `mode` never affects it. Requires `T: Clone`.
651 pub fn exportable(mut self, mode: DragTransferMode) -> Self
652 where
653 T: Clone,
654 {
655 self.export.set_exportable(mode);
656 self
657 }
658
659 /// Additionally advertise the dragged rows as MIME data so they can be
660 /// dropped on a [`DropZone`](crate::DropZone) or exported to another
661 /// application / window via the OS. `f` maps the dragged items to
662 /// `(mime_type, bytes)` pairs (e.g. `text/plain`, `text/uri-list`, an
663 /// app-specific `application/x-…`). Implies [`exportable`](Self::exportable)
664 /// (defaulting to [`DragTransferMode::Move`] if not already set). Requires
665 /// `T: Clone`.
666 pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
667 where
668 T: Clone,
669 {
670 self.export.set_export_external(f);
671 self
672 }
673
674 /// Override how rows moved out to a foreign target are removed from this
675 /// view. Receives the dragged rows' flat visible indices (as captured at
676 /// drag-start) and the live context. Without this, an
677 /// [`exportable`](Self::exportable) [`Move`](DragTransferMode::Move) drag
678 /// removes the dragged nodes directly from the underlying `TreeModel`
679 /// (leaf-first / descending — a dragged node that is a descendant of
680 /// another dragged node is skipped, since removing the ancestor already
681 /// removes its whole subtree).
682 pub fn on_rows_transferred_out(
683 mut self,
684 f: impl Fn(&[usize], &mut EventContext) + 'static,
685 ) -> Self {
686 self.export.set_on_rows_transferred_out(f);
687 self
688 }
689
690 /// Accept exported rows dropped from a **different** view or source
691 /// without writing a custom source. Pair with
692 /// [`on_rows_received`](Self::on_rows_received), which is handed the
693 /// dropped items and the target flat row index. (Same-view reorder is
694 /// [`reorderable`](Self::reorderable).)
695 pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
696 self.export.accept_foreign_rows = accept;
697 self
698 }
699
700 /// Handler for rows accepted via
701 /// [`accept_foreign_rows`](Self::accept_foreign_rows): `(items, target
702 /// flat row index, ctx)`. Insert them into your tree at/near the index.
703 pub fn on_rows_received(
704 mut self,
705 f: impl Fn(Vec<T>, usize, &mut EventContext) + 'static,
706 ) -> Self {
707 self.export.set_on_rows_received(f);
708 self
709 }
710
711 /// Raw escape hatch for a foreign drop.
712 ///
713 /// **Projection path only.** This hook is `NodeId`-typed and predates
714 /// [`from_source`](Self::from_source); over an external source there is no
715 /// `NodeId` to hand it, so it never fires. Prefer
716 /// [`accept_foreign_rows`](Self::accept_foreign_rows) +
717 /// [`on_rows_received`](Self::on_rows_received), which are source-agnostic.
718 /// A source-backed view expresses foreign-accept through its source's own
719 /// capability closures (`can_accept` / `accept_drop`), like `ListView` /
720 /// `TableView`; this hook is what a **projection**-backed view has instead,
721 /// since a `SortFilterTreeModel` carries no such closures.
722 /// This fires for **any** payload NOT recognized as this view's own row
723 /// drag — a different view's [`RowDragData<T>`](crate::RowDragData), or a
724 /// completely different payload type — dropped on a node: `(payload,
725 /// target node, drop position, ctx) -> accepted`. Tried after
726 /// [`on_rows_received`](Self::on_rows_received), so the typed sugar wins
727 /// when both are set and the payload happens to carry an exportable
728 /// `RowDragData<T>`.
729 pub fn on_foreign_drop(
730 mut self,
731 f: impl Fn(&DragPayload, NodeId, DropPosition, &mut EventContext) -> bool + 'static,
732 ) -> Self {
733 self.on_foreign_drop = Some(Rc::new(f));
734 self
735 }
736
737 /// Choose single- vs double-click activation for `on_row_activate` (default
738 /// [`ActivateOn::DoubleClick`](crate::ActivateOn)). Enter/Space activates in
739 /// either mode.
740 pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
741 self.activate_on = mode;
742 self
743 }
744
745 /// Append multiple columns from an iterator.
746 pub fn columns(mut self, cols: impl IntoIterator<Item = Column<T>>) -> Self {
747 self.columns.extend(cols);
748 self
749 }
750
751 /// Designate which column hosts the twist + indent. Default: the
752 /// first column.
753 pub fn tree_column(mut self, col_id: impl Into<String>) -> Self {
754 self.tree_column_id = Some(col_id.into());
755 self
756 }
757
758 /// Override the per-depth indent in the tree column in logical pixels (default
759 /// comes from the active `TableStyle`).
760 pub fn indent_per_level(mut self, px: f32) -> Self {
761 self.indent_per_level = Some(px);
762 self
763 }
764
765 /// Re-materialize `self.row_metrics` after a height-mode /
766 /// row-height builder call.
767 fn remake_metrics(&self) {
768 *self.row_metrics.borrow_mut() = self
769 .height_source
770 .make_metrics(self.effective_row_height(), 0.0);
771 }
772
773 /// Fixed row height (default: the table style's 28 px) — the
774 /// uniform fast path. Mutually exclusive with
775 /// [`row_height_fn`](Self::row_height_fn) and
776 /// [`auto_row_height`](Self::auto_row_height); the last mode setter
777 /// wins.
778 pub fn row_height(mut self, height: f32) -> Self {
779 self.row_height = Some(height);
780 self.height_source = HeightSource::Uniform;
781 self.remake_metrics();
782 self
783 }
784
785 /// Per-row heights from a callback over the flat (visible) row
786 /// index. The callback must be pure (same index + same data → same
787 /// height); it is re-swept from the first changed flat index on
788 /// every projection rebuild (expand/collapse/sort/filter/mutation).
789 /// No measurement pass runs.
790 pub fn row_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
791 self.height_source = HeightSource::Exact(Rc::new(f));
792 self.remake_metrics();
793 self
794 }
795
796 /// Auto-measured row heights: each realized row reports the height
797 /// of its tallest cell measured at the cell's column width
798 /// (height-for-width), unrealized rows assume `estimated`. Scroll
799 /// anchoring keeps content above the viewport stationary; measured
800 /// heights above a toggled row survive expand/collapse
801 /// (divergence-driven invalidation). The scrollbar settles one
802 /// frame after a measurement change.
803 pub fn auto_row_height(mut self, estimated: f32) -> Self {
804 self.height_source = HeightSource::Auto { estimated };
805 self.remake_metrics();
806 self
807 }
808
809 /// Override the header row height in logical pixels.
810 pub fn header_height(mut self, height: f32) -> Self {
811 self.header_height = Some(height);
812 self
813 }
814
815 /// Show or hide the column header row (default `true`).
816 pub fn show_header(mut self, visible: bool) -> Self {
817 self.show_header = visible;
818 self
819 }
820
821 /// Set the row/cell selection mode (default
822 /// [`TableSelectionMode::MultiRow`]).
823 pub fn selection_mode(mut self, mode: TableSelectionMode) -> Self {
824 self.selection_mode = mode;
825 self
826 }
827
828 /// Set the index-based row selection model (visible positions). For
829 /// identity-based selection that survives expand / collapse / sort /
830 /// filter / structural edits, use [`keyed_selection`](Self::keyed_selection)
831 /// instead.
832 pub fn selection(mut self, sel: SelectionModel) -> Self {
833 self.row_selection = Some(RowSelection::from_index(sel));
834 self
835 }
836
837 /// Set a keyed row selection model (by `NodeId`). Selection is tracked by
838 /// node identity, so it survives expand / collapse, sort / filter, and node
839 /// moves — and stays consistent if two views share the projection. Pruned
840 /// of deleted nodes on each projection change. Mutually exclusive with
841 /// [`selection`](Self::selection) (last one set wins).
842 /// Only meaningful on the [`from_projection`](Self::from_projection) /
843 /// [`new`](Self::new) paths, whose identity *is* `NodeId`; a no-op over an
844 /// external source, which carries its own key — use
845 /// [`from_source_keyed`](Self::from_source_keyed) there.
846 pub fn keyed_selection(mut self, keyed: KeyedSelectionModel<NodeId>) -> Self {
847 let Some(proxy) = self.proxy.clone() else {
848 return self;
849 };
850 let key_at = {
851 let p = proxy.clone();
852 Rc::new(move |i| p.visible_node_id(i)) as Rc<dyn Fn(usize) -> Option<NodeId>>
853 };
854 let len = {
855 let p = proxy.clone();
856 Rc::new(move || p.visible_count()) as Rc<dyn Fn() -> usize>
857 };
858 // A collapsed-but-present node must NOT be pruned, so existence is
859 // checked against the tree, not the (visible) projection window.
860 let contains = {
861 let p = proxy;
862 Rc::new(move |n: &NodeId| p.tree().with_item(*n, |_| ()).is_some())
863 as Rc<dyn Fn(&NodeId) -> bool>
864 };
865 self.row_selection = Some(RowSelection::from_keyed(keyed, key_at, len, contains));
866 self
867 }
868
869 /// Attach a cell-level selection model (row and column axes tracked
870 /// independently).
871 pub fn cell_selection(mut self, sel: CellSelectionModel) -> Self {
872 self.cell_selection = Some(sel);
873 self
874 }
875
876 /// Paint odd-indexed rows with the `SurfaceRole::AlternatingRow` tint
877 /// (default `false`).
878 pub fn alternating_rows(mut self, enabled: bool) -> Self {
879 self.alternating_rows = enabled;
880 self
881 }
882
883 /// Paint horizontal and/or vertical dividers between cells.
884 pub fn grid_lines(mut self, kind: GridLines) -> Self {
885 self.grid_lines = kind;
886 self
887 }
888
889 /// Let the **last column in display order** take up whatever width the
890 /// other columns leave, so the table never ends in a bare strip at its
891 /// trailing edge — Qt's `stretchLastSection`, NSTableView's
892 /// `lastColumnOnlyAutoresizingStyle`. Default: off.
893 ///
894 /// Positional, not a property of a column: after a reorder it is the
895 /// *new* last column that stretches and the previous one goes back to
896 /// its own width. While a column stretches, its declared width is the
897 /// floor it grows from, its user-resize override is ignored, and its
898 /// trailing grip is disabled (no AccessKit Increment/Decrement either):
899 /// any size the user gave it, the stretch would take straight back.
900 /// Resizing any *other* column reflows it. Once the other columns
901 /// exceed the viewport there is nothing left to stretch into — the
902 /// last column sits at its own width and the pane scrolls, as in Qt.
903 ///
904 /// `Flex` columns already share every spare pixel among themselves, so
905 /// a table of `Flex` columns looks the same either way: this is for
906 /// pixel-sized tables (`Fixed` / `Auto`, or widths the user has set)
907 /// that would otherwise end in a gap.
908 pub fn stretch_last_column(mut self, on: bool) -> Self {
909 self.stretch_last_column = on;
910 self
911 }
912
913 /// Accessible label for the whole tree table, announced by AT as the
914 /// table's name.
915 pub fn a11y_label(mut self, label: impl Into<LocalizedString>) -> Self {
916 self.a11y_label = Some(label.into());
917 self
918 }
919
920 /// Show or hide the widget's internal vertical and horizontal scroll bars
921 /// (default `true`). Set to `false` when the table lives inside an external
922 /// `ScrollArea`.
923 pub fn show_internal_scrollbars(mut self, show: bool) -> Self {
924 self.show_internal_scrollbars = show;
925 self
926 }
927
928 /// Control how column widths are distributed when the table is resized
929 /// (default `Proportional`).
930 pub fn column_resize_policy(mut self, policy: ColumnResizePolicy) -> Self {
931 self.column_resize_policy = policy;
932 self
933 }
934
935 /// Set the keyboard Tab traversal direction inside the table (default `CellsThenRows`).
936 pub fn tab_traversal(mut self, mode: TabTraversal) -> Self {
937 self.tab_traversal = mode;
938 self
939 }
940
941 /// Set which user gestures open an in-place cell editor — a set, composed
942 /// with `|` (default `F2 | ANY_KEY | DOUBLE_CLICK`). See [`EditTriggers`].
943 pub fn edit_triggers(mut self, trigger: EditTriggers) -> Self {
944 self.edit_triggers = trigger;
945 self
946 }
947
948 /// Callback invoked when the user requests an in-place cell edit (e.g.
949 /// double-click when `edit_triggers` contains `DOUBLE_CLICK`). Receives the flat row
950 /// index, the column id, and a mutable `EventContext`.
951 pub fn on_cell_edit_request(
952 mut self,
953 f: impl Fn(usize, &str, &mut EventContext) + 'static,
954 ) -> Self {
955 self.on_cell_edit_request = Some(Rc::new(f));
956 self
957 }
958
959 /// Callback invoked when an **open** cell editor should end because the
960 /// pointer went somewhere else: a press that lands on any cell other than
961 /// the one being edited. Receives the editing cell's flat row index and
962 /// column id, so the owner can commit (or discard) whatever is in its
963 /// buffer, then clear its own editing state.
964 ///
965 /// The counterpart of [`on_cell_edit_request`](Self::on_cell_edit_request),
966 /// and the view cannot do it alone: the framework owns *which* cell is being
967 /// edited, but only the owner knows what an ended edit means — commit,
968 /// discard, or refuse a value that will not parse.
969 ///
970 /// **Why a press and not a focus change.** "The editor lost focus" is the
971 /// obvious signal and it cannot be used: a body pane rebuilds constantly —
972 /// selection, filtering, scroll, a reload from elsewhere — and every rebuild
973 /// destroys and re-creates the open editor, so focus leaves it many times
974 /// during an edit the writer never interrupted. A press on another cell is
975 /// unambiguous and happens exactly once.
976 pub fn on_cell_edit_dismissed(
977 mut self,
978 f: impl Fn(usize, &str, &mut EventContext) + 'static,
979 ) -> Self {
980 self.on_cell_edit_dismissed = Some(Rc::new(f));
981 self
982 }
983
984 /// Callback invoked when a row is activated (double-click or Enter, per
985 /// `activate_on`). Receives the flat row index.
986 pub fn on_row_activate(mut self, f: impl Fn(usize, &mut EventContext) + 'static) -> Self {
987 self.on_row_activate = Some(Rc::new(f));
988 self
989 }
990
991 /// Forward `mode` to the underlying projection. The proxy holds its
992 /// state behind `Rc<RefCell>`, so calling `.filter_mode()` on a
993 /// clone mutates the shared inner — effectively persisting the
994 /// choice on `self.proxy`.
995 pub fn filter_mode(self, mode: TreeFilterMode) -> Self {
996 if let Some(p) = &self.proxy {
997 let _ = p.clone().filter_mode(mode);
998 }
999 self
1000 }
1001
1002 // ── Reactive signals ──────────────────────────────────────────────
1003
1004 /// Current vertical scroll offset in logical pixels.
1005 pub fn scroll_y_signal(&self) -> &Signal<f32> {
1006 &self.scroll_y
1007 }
1008
1009 /// Maximum vertical scroll offset (content height − viewport height).
1010 pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
1011 &self.max_scroll_y
1012 }
1013
1014 /// Viewport-to-content height ratio — drives the scrollbar thumb size.
1015 pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
1016 &self.viewport_ratio_y
1017 }
1018
1019 /// Current horizontal scroll offset of the Middle (unpinned) pane, in
1020 /// logical pixels. Leading/Trailing-pinned columns are unaffected.
1021 pub fn scroll_x_signal(&self) -> &Signal<f32> {
1022 &self.scroll_x
1023 }
1024
1025 /// Maximum horizontal scroll offset — `middle_content_width −
1026 /// middle_viewport_width`.
1027 pub fn max_scroll_x_signal(&self) -> &Signal<f32> {
1028 &self.max_scroll_x
1029 }
1030
1031 /// Middle-pane viewport-to-content width ratio.
1032 pub fn viewport_ratio_x_signal(&self) -> &Signal<f32> {
1033 &self.viewport_ratio_x
1034 }
1035
1036 /// Active sort state: `Some((col_id, direction))` or `None` for unsorted.
1037 ///
1038 /// **This is the header's state, not the data's.** Clicking a sort header
1039 /// writes here; nothing reorders rows until you bind this onto the backing
1040 /// projection yourself:
1041 ///
1042 /// ```ignore
1043 /// let proxy = SortFilterTreeModel::new(tree)
1044 /// .with_comparator("name", |a: &Row, b: &Row| a.name.cmp(&b.name));
1045 /// proxy.sort_signal(view.sort_signal().clone());
1046 /// ```
1047 ///
1048 /// The binding is deliberately not automatic: a projection may already
1049 /// carry preset comparators, predicates, and a filter mode, and adopting
1050 /// the view's empty signal at construction would clobber them.
1051 pub fn sort_signal(&self) -> &Signal<Option<(String, SortDirection)>> {
1052 &self.sort_signal
1053 }
1054
1055 /// Active per-column filters keyed by column id.
1056 ///
1057 /// Like [`sort_signal`](Self::sort_signal), this holds the header's state
1058 /// only — bind it onto the projection to actually filter rows:
1059 ///
1060 /// ```ignore
1061 /// let proxy = SortFilterTreeModel::new(tree)
1062 /// .with_predicate("name", |t| {
1063 /// let needle = t.to_string();
1064 /// Box::new(move |r: &Row| r.name.contains(&needle))
1065 /// });
1066 /// proxy.filters_signal(view.filters_signal().clone());
1067 /// ```
1068 pub fn filters_signal(&self) -> &Signal<HashMap<String, String>> {
1069 &self.filters_signal
1070 }
1071
1072 /// Current column widths in logical pixels, keyed by column id.
1073 pub fn column_widths_signal(&self) -> &Signal<HashMap<String, f32>> {
1074 &self.column_widths_signal
1075 }
1076
1077 /// Current column display order as a list of column ids.
1078 pub fn column_order_signal(&self) -> &Signal<Vec<String>> {
1079 &self.column_order_signal
1080 }
1081
1082 /// Keyboard-focused cell as `(row, display_column_index)`, or `None`.
1083 pub fn focused_cell_signal(&self) -> &Signal<Option<(usize, usize)>> {
1084 &self.focused_cell
1085 }
1086
1087 /// Cell currently being edited as `(row, display_column_index)`, or `None`.
1088 pub fn editing_cell_signal(&self) -> &Signal<Option<(usize, usize)>> {
1089 &self.editing_cell
1090 }
1091
1092 /// The widget realized for the cell at `(row, display column)` in the body
1093 /// pane's latest build, or `None` once it has scrolled (or collapsed) out
1094 /// of the realized buffer — `cell_map` is a snapshot, not an index of every
1095 /// row the source holds, so a miss here means "not on screen", never "no
1096 /// such cell".
1097 fn realized_cell(&self, row: usize, col: usize) -> Option<WidgetId> {
1098 self.cell_map
1099 .borrow()
1100 .iter()
1101 .find(|&&(pos, _)| pos == (row, col))
1102 .map(|&(_, id)| id)
1103 }
1104
1105 /// Access the underlying `SortFilterTreeModel` (for programmatic sort /
1106 /// filter / expand outside of the builder API).
1107 /// `None` when the view was built from an external
1108 /// [`teksilo_data::TreeDataSource`] via
1109 /// [`from_source`](Self::from_source) — there is no `TreeModel`-backed
1110 /// projection to hand back in that case.
1111 pub fn projection(&self) -> Option<&SortFilterTreeModel<T>> {
1112 self.proxy.as_ref()
1113 }
1114
1115 // ── Imperative API ─────────────────────────────────────────────────
1116
1117 /// Expand the subtree rooted at `node`.
1118 pub fn expand(&self, node: NodeId) {
1119 if let Some(p) = &self.proxy {
1120 p.expand(node);
1121 }
1122 }
1123
1124 /// Collapse the subtree rooted at `node`.
1125 pub fn collapse(&self, node: NodeId) {
1126 if let Some(p) = &self.proxy {
1127 p.collapse(node);
1128 }
1129 }
1130
1131 /// Toggle the expand/collapse state of `node`.
1132 pub fn toggle(&self, node: NodeId) {
1133 if let Some(p) = &self.proxy {
1134 p.toggle(node);
1135 }
1136 }
1137
1138 /// Expand all nodes in the tree.
1139 pub fn expand_all(&self) {
1140 if let Some(p) = &self.proxy {
1141 p.expand_all();
1142 }
1143 }
1144
1145 /// Collapse all nodes in the tree.
1146 pub fn collapse_all(&self) {
1147 if let Some(p) = &self.proxy {
1148 p.collapse_all();
1149 }
1150 }
1151
1152 /// Move keyboard focus to the cell at `(row, col)`.
1153 pub fn set_focused_cell(&self, row: usize, col: usize) {
1154 self.focused_cell.set(Some((row, col)));
1155 }
1156
1157 /// Clear the keyboard-focused cell.
1158 pub fn clear_focused_cell(&self) {
1159 self.focused_cell.set(None);
1160 }
1161
1162 /// Programmatically sort by `col_id` (pass `None` to clear the sort).
1163 ///
1164 /// Equality-guarded, like every persisted-layout setter here — see
1165 /// [`set_column_widths`](Self::set_column_widths).
1166 pub fn set_sort(&self, col_id: Option<&str>, dir: SortDirection) {
1167 imperative::set_if_changed(&self.sort_signal, col_id.map(|c| (c.to_string(), dir)));
1168 }
1169
1170 /// Set or clear the filter text for a single column.
1171 pub fn set_filter(&self, col_id: &str, text: &str) {
1172 imperative::set_filter(&self.filters_signal, col_id, text);
1173 }
1174
1175 pub fn clear_filters(&self) {
1176 imperative::set_if_changed(&self.filters_signal, HashMap::new());
1177 }
1178
1179 /// Widget shown when no rows are visible — an empty tree, or a filter
1180 /// that matched nothing. Without one, the body region is simply blank.
1181 pub fn empty_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
1182 self.empty_view = Some(Rc::new(f));
1183 self
1184 }
1185
1186 /// Clear the active sort.
1187 pub fn clear_sort(&self) {
1188 imperative::set_if_changed(&self.sort_signal, None);
1189 }
1190
1191 /// Scroll so that `row` is aligned to the top of the viewport. A no-op
1192 /// before the first layout pass.
1193 pub fn scroll_to_row(&self, row: usize) {
1194 if !self.laid_out.get() {
1195 return;
1196 }
1197 imperative::scroll_to_row(row, &self.row_metrics, &self.scroll_y, &self.max_scroll_y);
1198 }
1199
1200 /// Scroll the minimum distance needed to make `row` visible. A no-op
1201 /// before the first layout pass, when the viewport height is not yet known.
1202 pub fn ensure_row_visible(&self, row: usize) {
1203 imperative::ensure_row_visible(
1204 row,
1205 &self.row_metrics,
1206 &self.scroll_y,
1207 &self.max_scroll_y,
1208 self.viewport_height.get(),
1209 self.laid_out.get(),
1210 );
1211 }
1212
1213 /// Scroll the row the keyboard cursor sits on into view when this view
1214 /// takes focus.
1215 ///
1216 /// Only the rows near the viewport are realized, so on a tree taller than
1217 /// the window the cursor row frequently has no widget. Everything that
1218 /// speaks for it then has nothing to speak about: no cell node exists, so
1219 /// `accessibility()` below finds nothing in `cell_map` and nominates no
1220 /// `active_descendant`, and a screen reader taking focus here is told
1221 /// nothing at all. The first arrow press steps *past* that row as well,
1222 /// because the cursor was somewhere the user was never shown.
1223 ///
1224 /// The cursor is read exactly as the shared keyboard handler reads it
1225 /// (`table_view::keyboard::build_key_handler`, `keyboard.rs:134-139`): the
1226 /// focused cell's row, else the first selected row. Anything else would
1227 /// reveal a row the next arrow press does not step from.
1228 ///
1229 /// That row index is a **flat visible** index, not a position in the
1230 /// unflattened tree: a collapsed node's descendants have no index at all
1231 /// here. Checked on both sides of the read. `focused_cell` is clamped to
1232 /// `TreeNavigator::row_count()`, which returns `TreeSource::visible_count()`
1233 /// (`tree_table_view.rs:145-147`), and the keyed selection facade builds
1234 /// its indices by scanning `0..visible_count()` through
1235 /// `SortFilterTreeModel::visible_node_id` (`data_views.rs:545-552`). On the
1236 /// spending side, `RowMetrics` is sized by `place_children` from that same
1237 /// `visible_count()`, so `row_top(i)` is the top of the *i*-th visible row.
1238 ///
1239 /// `ensure_row_visible`, the view's own imperative path, rather than
1240 /// `scroll_to_row`: a row already on screen must not jump under somebody
1241 /// who can see it. It carries the `laid_out` guard too, so a focus that
1242 /// arrives before the first real height is a no-op instead of scrolling
1243 /// against a viewport that was never measured. It is the same
1244 /// `RowMetrics::scroll_for_ensure_visible` arithmetic the keyboard runs on
1245 /// every arrow press; what the keyboard's own wrapper
1246 /// (`table_view/keyboard.rs:672`) adds on top is chasing the row into an
1247 /// *enclosing* scroll area, and that needs an `EventContext`, which an
1248 /// effect does not have. Nothing is lost: the same keyboard or programmatic
1249 /// focus change makes the framework reveal the newly focused widget in
1250 /// every ancestor scroll area itself (`focus_impl.rs:109`,
1251 /// `WidgetTree::scroll_focused_into_view`), so the enclosing viewport is
1252 /// somebody else's job here.
1253 ///
1254 /// The handles are cloned into the effect rather than reaching through
1255 /// `self`, which the closure cannot borrow.
1256 fn reveal_current_row_on_focus(&self, ctx: &mut BuildContext) {
1257 let focused_cell = self.focused_cell.clone();
1258 let selection = self.row_selection.clone();
1259 let row_metrics = self.row_metrics.clone();
1260 let scroll_y = self.scroll_y.clone();
1261 let max_scroll_y = self.max_scroll_y.clone();
1262 let viewport_height = self.viewport_height.clone();
1263 let laid_out = self.laid_out.clone();
1264
1265 ctx.effect(&self.view_focused, move |focused| {
1266 if !*focused {
1267 return;
1268 }
1269 let Some(row) = focused_cell.get().map(|(row, _col)| row).or_else(|| {
1270 selection
1271 .as_ref()
1272 .and_then(|s| s.selected_indices().first().copied())
1273 }) else {
1274 return;
1275 };
1276 imperative::ensure_row_visible(
1277 row,
1278 &row_metrics,
1279 &scroll_y,
1280 &max_scroll_y,
1281 viewport_height.get(),
1282 laid_out.get(),
1283 );
1284 });
1285 }
1286
1287 /// Set or remove a single column's user-resized width override.
1288 /// A non-positive `width` removes the entry (the column reverts to
1289 /// its declared width policy).
1290 pub fn set_column_width(&self, col_id: &str, width: f32) {
1291 imperative::set_column_width(&self.column_widths_signal, col_id, width);
1292 }
1293
1294 /// Replace the full width-override map (typically used to restore
1295 /// a persisted layout).
1296 ///
1297 /// Equality-guarded for the same reason as
1298 /// [`TableView::set_column_widths`](crate::TableView::set_column_widths):
1299 /// the documented settings round-trip would otherwise recurse without
1300 /// bound on the first tick of a live resize drag.
1301 pub fn set_column_widths(&self, widths: HashMap<String, f32>) {
1302 imperative::set_column_widths(&self.column_widths_signal, widths);
1303 }
1304
1305 /// Replace the column-order list. Ids not declared on this table
1306 /// are silently dropped on the next layout pass.
1307 pub fn set_column_order(&self, order: Vec<String>) {
1308 imperative::set_if_changed(&self.column_order_signal, order);
1309 }
1310
1311 /// Current column pinning overrides, keyed by column id. Wins over
1312 /// each column's declared [`Column::pinned`].
1313 pub fn column_pinning_signal(&self) -> &Signal<HashMap<String, PinnedSide>> {
1314 &self.column_pinning_signal
1315 }
1316
1317 /// Pin or unpin a single column. [`PinnedSide::None`] removes the
1318 /// override, reverting the column to its declared pinning.
1319 pub fn set_column_pinning(&self, col_id: &str, side: PinnedSide) {
1320 imperative::set_column_pinning(&self.column_pinning_signal, col_id, side);
1321 }
1322
1323 /// Begin editing the cell `(row, col_id)`. Silently no-ops if `col_id`
1324 /// isn't a currently-displayed column, or if `row` is outside the visible
1325 /// range — an out-of-range target would otherwise strand `editing_cell` on
1326 /// a row nothing can match.
1327 ///
1328 /// Callable **before the view is mounted**, which is the only point at
1329 /// which a consumer can seed a freshly constructed view with an edit
1330 /// target it already holds. `display_indices` is a cache `build()` fills,
1331 /// so a pre-mount call finds it empty; the order is recomputed on demand
1332 /// in that case rather than resolving against nothing and no-opping for a
1333 /// third, undocumented reason.
1334 pub fn begin_edit(&self, row: usize, col_id: &str) {
1335 let cached = self.display_indices.borrow();
1336 let recomputed;
1337 let display: &[usize] = if cached.is_empty() {
1338 recomputed = self.display_order();
1339 &recomputed
1340 } else {
1341 &cached
1342 };
1343 if let Some(target) = imperative::resolve_edit_target(
1344 row,
1345 col_id,
1346 &self.columns,
1347 display,
1348 self.source.visible_count(),
1349 ) {
1350 drop(cached);
1351 self.editing_cell.set(Some(target));
1352 }
1353 }
1354
1355 /// Close the active cell editor without committing (the field's `on_blur` still fires).
1356 pub fn end_edit(&self) {
1357 self.editing_cell.set(None);
1358 }
1359
1360 // ── Internals ──────────────────────────────────────────────────────
1361
1362 fn effective_row_height(&self) -> f32 {
1363 self.row_height.unwrap_or(cp::ROW_HEIGHT)
1364 }
1365
1366 fn effective_header_height(&self) -> f32 {
1367 if self.show_header {
1368 self.header_height.unwrap_or(cp::HEADER_HEIGHT)
1369 } else {
1370 0.0
1371 }
1372 }
1373
1374 fn effective_indent(&self) -> f32 {
1375 self.indent_per_level.unwrap_or(cp::TREE_INDENT_PER_LEVEL)
1376 }
1377
1378 /// Resolve the tree column id to a declaration index. Falls back
1379 /// to column 0 when the configured id isn't found or unset.
1380 fn tree_column_decl_index(&self) -> usize {
1381 if let Some(ref id) = self.tree_column_id {
1382 for (i, col) in self.columns.iter().enumerate() {
1383 if &col.id == id {
1384 return i;
1385 }
1386 }
1387 }
1388 0
1389 }
1390
1391 fn display_order(&self) -> Vec<usize> {
1392 let order_signal = self.column_order_signal.get();
1393 let mut order_map: HashMap<&str, usize> = HashMap::new();
1394 for (i, id) in order_signal.iter().enumerate() {
1395 order_map.insert(id.as_str(), i);
1396 }
1397 let mut leading: Vec<usize> = Vec::new();
1398 let mut middle: Vec<usize> = Vec::new();
1399 let mut trailing: Vec<usize> = Vec::new();
1400 for (i, col) in self.columns.iter().enumerate() {
1401 let pinning = self
1402 .column_pinning_signal
1403 .get()
1404 .get(&col.id)
1405 .copied()
1406 .unwrap_or(col.pinned);
1407 match pinning {
1408 PinnedSide::Leading => leading.push(i),
1409 PinnedSide::None => middle.push(i),
1410 PinnedSide::Trailing => trailing.push(i),
1411 }
1412 }
1413 const FALLBACK_BASE: usize = usize::MAX / 2;
1414 let cols = &self.columns;
1415 let key_for = |i: usize| {
1416 order_map
1417 .get(cols[i].id.as_str())
1418 .copied()
1419 .unwrap_or(FALLBACK_BASE + i)
1420 };
1421 leading.sort_by_key(|&i| key_for(i));
1422 middle.sort_by_key(|&i| key_for(i));
1423 trailing.sort_by_key(|&i| key_for(i));
1424 let mut out = Vec::with_capacity(leading.len() + middle.len() + trailing.len());
1425 out.extend(leading);
1426 let leading_count = out.len();
1427 out.extend(middle);
1428 let middle_end = out.len();
1429 out.extend(trailing);
1430 // Stash the boundaries so paint / place_children / the keyboard
1431 // handler's ensure-column-visible can read them — mirrors
1432 // `TableView::display_order`.
1433 *self.pane_boundaries.borrow_mut() =
1434 crate::table_view::PaneBoundaries::new(leading_count, middle_end);
1435 out
1436 }
1437
1438 fn clamp_scroll(&self) {
1439 let max = self.max_scroll_y.get();
1440 let current = self.scroll_y.get();
1441 let clamped = current.clamp(0.0, max);
1442 if (clamped - current).abs() > 0.001 {
1443 self.scroll_y.set(clamped);
1444 }
1445 }
1446
1447 /// Buffered realized range — mirrors `TableView::visible_range`. Used
1448 /// only to nudge the lazy source (`request_window`/`fetch_more`); the
1449 /// pane recomputes its own copy independently for actual row
1450 /// realization.
1451 fn visible_range(&self) -> (usize, usize) {
1452 self.row_metrics.borrow_mut().visible_range(
1453 self.scroll_y.get(),
1454 self.viewport_height.get(),
1455 self.source.visible_count(),
1456 BUFFER_ROWS,
1457 )
1458 }
1459}
1460
1461impl<T: 'static> std::fmt::Debug for TreeTableView<T> {
1462 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1463 f.debug_struct("TreeTableView")
1464 .field("rows", &self.source.visible_count())
1465 .field("columns", &self.columns.len())
1466 .field("tree_column", &self.tree_column_id)
1467 .field("scroll_bar_style", &self.scroll_bar_style)
1468 .finish()
1469 }
1470}
1471
1472#[cfg(test)]
1473mod tests;