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 (a hosted
11//! [`TableHeader`]), column, keyboard, and selection
12//! modules with `TableView`.
13//!
14//! Rows live in a `TreeBodyPane` — a sibling of the scrollbar — so buffer-exit /
15//! selection / expand rebuilds are never deferred mid-thumb-drag. Three row-height
16//! modes: uniform (`row_height`, fast path), exact per-flat-index callback
17//! (`row_height_fn`), and auto-measured (`auto_row_height` — grows to tallest cell).
18//!
19//! ## Common patterns
20//!
21//! **A checkbox column.** Selection and "checked" are different things — a
22//! checkbox column wants its own state, with parent/child propagation. Build it
23//! from [`TreeCheckedModel`](teksilo_data::TreeCheckedModel) over the same tree
24//! the view projects.
25//!
26//! A cell delegate receives `(&T, &CellContext)` and **`CellContext` carries no
27//! node identity** — only [`row_index`](crate::CellContext::row_index). So
28//! capture the projection and resolve the row's `NodeId` through it:
29//!
30//! ```ignore
31//! let proxy = SortFilterTreeModel::new(tree);
32//! let checks = TreeCheckedModel::new(proxy.tree());
33//! let for_cells = proxy.clone();
34//! let col = Column::new("done", lit!("Done"), move |_item, cx: &CellContext| {
35//! match for_cells.visible_node_id(cx.row_index) {
36//! Some(node) => Box::new(Checkbox::new(checks.check_state(node))) as Box<dyn Widget>,
37//! None => Box::new(Spacer::new()),
38//! }
39//! });
40//! ```
41//!
42//! For a tree whose identity is a domain key rather than a `NodeId`, use
43//! [`KeyedTreeCheckedModel`](teksilo_data::KeyedTreeCheckedModel) instead — it
44//! survives a full re-source, which a `NodeId`-keyed set cannot.
45//!
46//! **Group rows that span the table, and the group kept in sight.** A tree
47//! whose upper levels are groups — songs under albums under artists — draws
48//! those rows as one band across every column with
49//! [`full_width_row`](TreeTableView::full_width_row) and
50//! [`full_width_row_delegate`](TreeTableView::full_width_row_delegate), and
51//! keeps the groups being scrolled through pinned under the header with
52//! [`pinned_ancestors`](TreeTableView::pinned_ancestors):
53//!
54//! ```ignore
55//! let view = TreeTableView::from_projection(proxy)
56//! .columns(columns)
57//! .full_width_row(|row: &Row| !row.is_song())
58//! .full_width_row_delegate(|row, _cx: &CellContext| {
59//! Box::new(TextWidget::new(lit!(row.summary()))) as Box<dyn Widget>
60//! })
61//! .pinned_ancestors(2); // the artist, then the album
62//! ```
63//!
64//! A band is still a row of the tree — it selects, drags, expands from its
65//! chevron and answers type-ahead — and one cell to the keyboard and to
66//! assistive technology. The pinned rows are copies, pushed up as the next
67//! group arrives, hidden from assistive technology, and every reveal stops
68//! below them. The two builders say the rest.
69//!
70//! ## Accessibility
71//!
72//! Root emits `Role::TreeGrid`; rows carry `set_level` + `set_expanded`.
73//! ArrowLeft / ArrowRight on the tree column collapse / expand. A full-width
74//! row holds one cell, spanning every column.
75//!
76//! ```ignore
77//! // Column delegates capture closures — use ignore.
78//! use teksilo_widgets::TreeTableView;
79//! use teksilo_data::TreeModel;
80//! # struct File { name: String }
81//! # let model: TreeModel<File> = TreeModel::new();
82//! let _view = TreeTableView::new(model).row_height(28.0);
83//! ```
84//!
85//! ## Scrolling to a row
86//!
87//! [`handle`](TreeTableView::handle) returns a [`TreeTableViewHandle`], a
88//! cheap `Clone` taken before the view is added and usable from any handler
89//! afterwards. Its [`scroll_to_row`](TreeTableViewHandle::scroll_to_row) and
90//! [`ensure_row_visible`](TreeTableViewHandle::ensure_row_visible) take a flat
91//! visible row index and are applied at the next layout pass, after row
92//! measurement and scroll anchoring, so they may follow an expansion in the
93//! same turn, or be called before mount:
94//!
95//! ```rust
96//! # use teksilo_data::TreeModel;
97//! # use teksilo_widgets::TreeTableView;
98//! # let model: TreeModel<String> = TreeModel::new();
99//! let view = TreeTableView::new(model);
100//! let handle = view.handle();
101//! // … add the columns, `ctx.add(view)`; then, from a handler, after
102//! // expanding the row's ancestors in the same turn:
103//! handle.ensure_row_visible(42);
104//! ```
105//!
106//! The builder-value methods of the same names compute the offset when they
107//! are called, against the last layout pass.
108//!
109//! ## Pan to scroll
110//!
111//! The view installs [`common::scrollable::ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior),
112//! which gives it the shared wheel arithmetic, a finger's pan and the
113//! `PanClaim` that puts it on a pan's claimant chain. A pan scrolls it, the
114//! release coasts, and a pan it cannot absorb hands the **whole** event to the
115//! container outside — never a residual. A pan that starts on a row scrolls
116//! rather than activating it or collapsing a multi-selection onto it. Both axes
117//! are claimed. Shift+wheel still
118//! scrolls the columns, and a finger's pan is never remapped by a held Shift:
119//! the remap is a wheel convention, and turning a drag sideways is not what
120//! the hand asked for.
121
122mod body_pane;
123mod full_width;
124mod pinned;
125mod widget_impl;
126
127use std::cell::{Cell, RefCell};
128use std::collections::HashMap;
129use std::rc::Rc;
130use std::time::Duration;
131
132use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
133
134use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
135use teksilo_core::binding::BindingLevel;
136use teksilo_core::build_context::BuildContext;
137use teksilo_core::drag_payload::DragPayload;
138use teksilo_core::event::EventResponse;
139use teksilo_core::kinetic::KineticScroller;
140use teksilo_core::pointer::touch_action::PanAxes;
141use teksilo_core::signal::{Prop, Signal};
142use teksilo_core::widget::{EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement};
143use teksilo_core::widget_builder::HandlerSet;
144use teksilo_core::widget_id::WidgetId;
145use teksilo_data::{
146 DropPosition, KeyedSelectionModel, NodeId, SelectionModel, SortDirection, SortFilterTreeModel,
147 TreeFilterMode, TreeModel,
148};
149use teksilo_i18n::LocalizedString;
150use teksilo_tokens::{BorderRole, OverscrollStyle, SurfaceRole};
151
152use crate::styles::recipe_table_style as cp;
153
154use crate::common::row_metrics::{HeightSource, RowMetrics, SharedRowMetrics};
155use crate::common::row_reveal::{RevealAlign, RowReveal, SharedRowReveal};
156use crate::common::scroll::OverscrollBehavior;
157use crate::data_views::{DragTransferMode, RowDragData, RowSelection, ViewId, ViewKind};
158use crate::data_views::{DropViz, drop_into_tint};
159use crate::scroll_area::ScrollBarMode;
160use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
161use crate::table_view::ColumnReorderDragData;
162use crate::table_view::body::SharedColumnWidths;
163use crate::table_view::column::{
164 CellContext, Column, ColumnResizePolicy, EditTriggers, GridLines, PinnedSide, TabTraversal,
165};
166use crate::table_view::header::{ResizeStateHandle, SharedFrozenWidths, width_overrides};
167use crate::table_view::imperative;
168use crate::table_view::keyboard;
169use crate::table_view::layout;
170use crate::table_view::row_navigator::RowNavigator;
171use crate::table_view::selection::{CellSelectionModel, TableSelectionMode};
172use crate::table_view::{HeaderLink, TableHeader};
173use crate::tree_source::TreeSource;
174use teksilo_data::{DropResponse, TreeDataSource};
175
176const BUFFER_ROWS: usize = 5;
177const SCROLLBAR_THICKNESS: f32 = 12.0;
178
179/// Hierarchical row navigator. Adapts a [`TreeSource`]'s flat-list view to the
180/// [`RowNavigator`] interface used by the shared keyboard handler.
181///
182/// Index-keyed throughout, so it works over any [`TreeDataSource`] — a
183/// `SortFilterTreeModel` over a `TreeModel`, or an external store carrying its
184/// own `Key`.
185pub(crate) struct TreeNavigator<T: 'static> {
186 source: Rc<TreeSource<T>>,
187 /// Which rows are full-width ones, when the view has any.
188 full_width: Option<Rc<dyn Fn(usize) -> bool>>,
189 /// The reveal below the pinned ancestors, while the view pins any.
190 pinned: Option<pinned::PinnedReveal>,
191}
192
193impl<T: 'static> TreeNavigator<T> {
194 pub(crate) fn new(source: Rc<TreeSource<T>>) -> Self {
195 Self {
196 source,
197 full_width: None,
198 pinned: None,
199 }
200 }
201
202 pub(crate) fn with_full_width(mut self, at: Option<Rc<dyn Fn(usize) -> bool>>) -> Self {
203 self.full_width = at;
204 self
205 }
206
207 pub(crate) fn with_pinned(mut self, reveal: Option<pinned::PinnedReveal>) -> Self {
208 self.pinned = reveal;
209 self
210 }
211}
212
213impl<T: 'static> RowNavigator for TreeNavigator<T> {
214 fn row_count(&self) -> usize {
215 self.source.visible_count()
216 }
217
218 fn depth(&self, row: usize) -> Option<usize> {
219 self.source.meta(row).map(|m| m.depth)
220 }
221
222 fn has_children(&self, row: usize) -> bool {
223 self.source
224 .meta(row)
225 .map(|m| m.has_children)
226 .unwrap_or(false)
227 }
228
229 fn is_expanded(&self, row: usize) -> bool {
230 self.source
231 .meta(row)
232 .map(|m| m.is_expanded)
233 .unwrap_or(false)
234 }
235
236 fn toggle_expanded(&self, row: usize) {
237 self.source.toggle_at(row);
238 }
239
240 fn spans_all_columns(&self, row: usize) -> bool {
241 self.full_width.as_ref().is_some_and(|at| at(row))
242 }
243
244 fn reveal_scroll(
245 &self,
246 row: usize,
247 scroll: f32,
248 viewport: f32,
249 max_scroll: f32,
250 ) -> Option<f32> {
251 self.pinned
252 .as_ref()
253 .map(|pinned| pinned.scroll_for(row, scroll, viewport, max_scroll))
254 }
255}
256
257/// Hierarchical multi-column widget. See module documentation.
258pub struct TreeTableView<T: 'static> {
259 /// Erased row access — every read (counts, entries, expansion, DnD,
260 /// keyboard reorder) goes through here, so the widget works over any
261 /// [`TreeDataSource`] and never needs to know the source's `Key`.
262 source: Rc<TreeSource<T>>,
263 /// Present only on the [`from_projection`](Self::from_projection) /
264 /// [`new`](Self::new) paths. It backs the `NodeId`-typed public API
265 /// ([`expand`](Self::expand), [`projection`](Self::projection), …), which is
266 /// meaningless for an external source carrying its own key — those methods
267 /// no-op when this is `None`.
268 proxy: Option<SortFilterTreeModel<T>>,
269
270 columns: Vec<Column<T>>,
271 /// Column id hosting the twist + indent. `None` defaults to the
272 /// first column at build time.
273 tree_column_id: Option<String>,
274 indent_per_level: Option<f32>,
275 row_height: Option<f32>,
276 /// Height-mode selection (uniform / exact callback / auto-measure).
277 height_source: HeightSource,
278 /// Row geometry — shared with the keyboard handler and the body
279 /// pane.
280 row_metrics: SharedRowMetrics,
281 header_height: Option<f32>,
282 /// The active `TableStyle`'s row and header heights at the density
283 /// in force, read at build by `adopt_style_heights`. Until then they
284 /// hold IntUI's Compact values, the module constants.
285 style_row_height: f32,
286 style_header_height: f32,
287 show_header: bool,
288 selection_mode: TableSelectionMode,
289 /// Row selection — index-based `SelectionModel` or keyed
290 /// `KeyedSelectionModel<NodeId>`, unified behind the index-facing facade.
291 row_selection: Option<RowSelection>,
292 cell_selection: Option<CellSelectionModel>,
293 alternating_rows: bool,
294 grid_lines: GridLines,
295 /// See [`Self::stretch_last_column`].
296 stretch_last_column: bool,
297 a11y_label: Option<LocalizedString>,
298 show_internal_scrollbars: bool,
299 column_resize_policy: ColumnResizePolicy,
300 tab_traversal: TabTraversal,
301 edit_triggers: EditTriggers,
302 #[allow(clippy::type_complexity)]
303 on_cell_edit_request: Option<Rc<dyn Fn(usize, &str, &mut EventContext)>>,
304 on_cell_edit_dismissed: Option<Rc<dyn Fn(usize, &str, &mut EventContext)>>,
305 #[allow(clippy::type_complexity)]
306 on_row_activate: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
307
308 /// Animate wheel scrolling instead of snapping to the new offset.
309 /// Enabled by default — mirrors `ScrollArea`. Without it, each wheel
310 /// notch jumps by `row_height` per delivered line, which reads as a
311 /// coarse multi-row jump rather than a smooth glide.
312 smooth_scrolling: bool,
313 /// Duration of the smooth scroll animation.
314 smooth_scroll_duration: Duration,
315
316 /// How the scroll bar is displayed (default `Permanent`). `Overlay`
317 /// and `Thin` float the bar over the content instead of reserving a
318 /// layout column for it, mirroring `ScrollArea::scroll_bar_style`.
319 scroll_bar_style: ScrollBarMode,
320
321 // Public reactive signals
322 scroll_y: Signal<f32>,
323 max_scroll_y: Signal<f32>,
324 /// Scroll-chaining behavior at the boundary (default `Chain`).
325 overscroll_behavior: OverscrollBehavior,
326 viewport_ratio_y: Signal<f32>,
327 /// Horizontal scroll offset of the Middle (unpinned) pane — mirrors
328 /// `TableView::scroll_x`. See `table_view::PaneBoundaries`.
329 scroll_x: Signal<f32>,
330 max_scroll_x: Signal<f32>,
331 viewport_ratio_x: Signal<f32>,
332 /// This surface's pan physics: the range a finger's pan is clamped to and
333 /// the offset it is currently holding. Owned by the view rather than by
334 /// the [`ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior)
335 /// so it survives a rebuild, and so `place_children` — the only pass that
336 /// knows the viewport extent — can publish into it.
337 scroller: Rc<RefCell<KineticScroller>>,
338 sort_signal: Signal<Option<(String, SortDirection)>>,
339 column_widths_signal: Signal<HashMap<String, f32>>,
340 /// `Some` once `bind_column_widths` adopted the width map — see the
341 /// field of the same name on `TableView`.
342 column_widths_frozen: Option<SharedFrozenWidths>,
343 column_order_signal: Signal<Vec<String>>,
344 column_pinning_signal: Signal<HashMap<String, PinnedSide>>,
345 filters_signal: Signal<HashMap<String, String>>,
346 focused_cell: Signal<Option<(usize, usize)>>,
347 /// The realized `(row index -> row wrapper id)` map, filled by the body
348 /// pane each build. Lets this widget's `&self` methods resolve a row index
349 /// to a widget without reaching into the pane. Mirrors `ListView::row_map`.
350 row_map: Rc<RefCell<Vec<(usize, WidgetId)>>>,
351 editing_cell: Signal<Option<(usize, usize)>>,
352 /// Type-ahead ("type to jump") label extractor — opt-in via
353 /// [`type_ahead_label`](Self::type_ahead_label).
354 #[allow(clippy::type_complexity)]
355 type_ahead_label: Option<Rc<dyn Fn(&T) -> String>>,
356 /// Reset window for the type-ahead search term.
357 type_ahead_timeout: Duration,
358 /// Persistent type-ahead buffer (survives the per-keystroke rebuild).
359 type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
360 /// Widget shown in place of the rows when nothing is visible — an empty
361 /// tree, or a filter that matched nothing.
362 #[allow(clippy::type_complexity)]
363 empty_view: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
364 /// Set on the first `place_children`. Until then `viewport_height` still
365 /// holds its construction placeholder, so viewport-relative imperatives
366 /// (`ensure_row_visible`) would scroll against a size that was never real.
367 laid_out: Rc<Cell<bool>>,
368 /// Anchor for the row with an open cell editor, so the editor follows its
369 /// row instead of its index. See `reconcile_editing_row`.
370 editing_anchor: Rc<RefCell<Option<crate::data_views::RowAnchor>>>,
371
372 // Build state
373 header_row_id: Option<WidgetId>,
374 body_pane_id: Option<WidgetId>,
375 scrollbar_id: Option<WidgetId>,
376 /// Horizontal scroll bar along the bottom of the Middle pane only —
377 /// mirrors `TableView::h_scrollbar_id`.
378 h_scrollbar_id: Option<WidgetId>,
379 empty_id: Option<WidgetId>,
380 /// Pane-local rebuild trigger + buffered range, owned here so they
381 /// survive `TreeTableView` rebuilds (each rebuild constructs a fresh
382 /// `TreeBodyPane` struct that inherits these handles).
383 pane_version: Signal<u64>,
384 pane_built_start: Rc<Cell<usize>>,
385 pane_built_end: Rc<Cell<usize>>,
386 /// Bumped by the pane when a measure pass changes the content
387 /// total; bound at `Relayout` on this root so `max_scroll_y` / the
388 /// thumb ratio are recomputed with the corrected total next frame.
389 pane_total_refresh: Signal<u64>,
390 /// The reveal a [`TreeTableViewHandle`] asked for, applied by the body
391 /// pane after its measure pass. Created with the view so a handle taken
392 /// before mount reaches every pane the view builds.
393 reveal: SharedRowReveal,
394
395 /// Enable drag-to-reorder of rows (pointer drag + Alt+Arrow). The move
396 /// reparents/reorders nodes in the underlying `TreeModel`, cycle-guarded.
397 /// Suppressed while the view is sorted by one of its own columns (the
398 /// visible order then differs from the tree order, so a manual reorder
399 /// would be meaningless). A shared sort on a column it lacks leaves it on.
400 reorderable: bool,
401 /// Active row-drop insertion indicator `(body_local_y, width)`. Set by
402 /// `on_drag_hover`, cleared on leave / drop, read by `paint`.
403 drop_feedback: Signal<Option<DropViz>>,
404
405 /// Whether activation is a single or double click (default `DoubleClick`).
406 activate_on: crate::data_views::ActivateOn,
407
408 /// `true` while this view — its root or any descendant — holds keyboard
409 /// focus. Captured at build from [`BuildContext::begin_view_focus`], bound
410 /// `RepaintOnly`. Drives focus-aware selection: the band paints `Selected`
411 /// while focused, muted `SelectedInactive` once focus leaves the view.
412 view_focused: Signal<bool>,
413 /// Input-modality `:focus-visible`. Gates the cell focus ring to keyboard
414 /// navigation (never a mouse click). Bound `RepaintOnly`.
415 focus_visible: Signal<bool>,
416
417 // Layout state
418 column_widths: SharedColumnWidths,
419 display_indices: Rc<RefCell<Vec<usize>>>,
420 /// Counts of (leading-pinned, middle, trailing-pinned) columns —
421 /// mirrors `TableView::pane_boundaries`. Populated by `display_order()`.
422 pane_boundaries: Rc<RefCell<crate::table_view::PaneBoundaries>>,
423 /// `(row, display_pos) -> WidgetId` for every cell realized by the
424 /// body pane's latest `build()`. Mirrors `TableView::cell_map` (the
425 /// GridView `tile_map` pattern — shared between the root and its
426 /// sibling-of-scrollbar pane); `accessibility()` reads it to point
427 /// `active_descendant` at the keyboard-focused cell's own AT node.
428 cell_map: Rc<RefCell<Vec<((usize, usize), WidgetId)>>>,
429 viewport_height: Rc<Cell<f32>>,
430 /// Middle-pane viewport width, snapshotted by `place_children` —
431 /// mirrors `TableView::middle_viewport_width`.
432 middle_viewport_width: Rc<Cell<f32>>,
433 /// The row-area's absolute (window) rect (below the header), cached by
434 /// `place_children`. Threaded into the keyboard handler so it can chase the
435 /// focused row into any *enclosing* scroll area via
436 /// [`EventContext::ensure_visible`](teksilo_core::widget::EventContext::ensure_visible).
437 body_bounds: Rc<Cell<Rect>>,
438 resize_state: ResizeStateHandle,
439 /// Display slot of the column under an active resize drag, or `None`.
440 /// Mirrors `TableView::resize_target` — shared with every `HeaderCell`
441 /// so the *target* column carries the "resizing" chrome even when the
442 /// gesture is anchored on its neighbour's half of the grip.
443 resize_target: Signal<Option<usize>>,
444 /// Window x of the prospective divider during a
445 /// [`ColumnResizePolicy::OnRelease`] drag. Mirrors
446 /// `TableView::resize_preview_x`.
447 resize_preview_x: Signal<Option<f32>>,
448 /// Width of the header strip (= the column band) snapshotted by
449 /// `place_children`. Mirrors `TableView::header_strip_width` — the
450 /// column-reorder drop handler needs it to mirror the drop x under RTL.
451 header_strip_width: Rc<Cell<f32>>,
452 /// Stable id grouping the column-header reorder/resize drag (an
453 /// unrelated mechanism to the row DnD below — see `table_view::header`).
454 table_id: usize,
455
456 /// Stable, kind-tagged identity for this view's **row** drag-and-drop —
457 /// distinct from `table_id` above. Minted via
458 /// `ViewId::next(ViewKind::TreeTable)`.
459 model_id: ViewId,
460
461 /// Cross-widget export / foreign-receive machinery — the builders
462 /// (`.exportable`, `.export_external`, `.accept_foreign_rows`,
463 /// `.on_rows_received`, `.on_rows_transferred_out`), the drag-start
464 /// payload build, and the move-out completion, shared by all five data
465 /// views. `TreeTableView` builds its reader + stable-key removal thunk
466 /// inline at drag-start (see `TreeBodyPane::build`'s `on_drag`) rather
467 /// than from source capability closures, so the key it removes by is
468 /// resolved once at drag-start and stays correct even if a mid-drag
469 /// spring-load reflattens the rows under the pointer.
470 export: crate::data_views::RowExport<T>,
471 /// Raw escape hatch for a payload this view cannot interpret itself.
472 ///
473 /// A source-backed view ([`from_source`](Self::from_source)) expresses
474 /// foreign-accept through its source's capability closures, like
475 /// `ListView` / `TableView`. This hook is what a **projection**-backed
476 /// view ([`from_projection`](Self::from_projection) / [`new`](Self::new))
477 /// has instead, since a `SortFilterTreeModel` carries no such closures.
478 /// Fires for any payload NOT recognized as this view's own row drag,
479 /// dropped on a node —
480 /// `(payload, target node, drop position, ctx) -> accepted`. Tried after
481 /// [`on_rows_received`](Self::on_rows_received).
482 #[allow(clippy::type_complexity)]
483 on_foreign_drop:
484 Option<Rc<dyn Fn(&DragPayload, NodeId, DropPosition, &mut EventContext) -> bool>>,
485
486 /// Whole-view enabled state, statically or reactively. Forwarded to the
487 /// arena via `ctx.enabled_when(self_id, self.enabled.clone())` at build
488 /// time; a disabled view greys out and stops accepting focus /
489 /// selection / keyboard input (arena-gated).
490 enabled: Prop<bool>,
491
492 /// Which items are drawn as one band across every column — see
493 /// [`full_width_row`](Self::full_width_row).
494 #[allow(clippy::type_complexity)]
495 full_width_row: Option<Rc<dyn Fn(&T) -> bool>>,
496 /// What draws that band — see
497 /// [`full_width_row_delegate`](Self::full_width_row_delegate).
498 full_width_delegate: Option<full_width::BandDelegate<T>>,
499 /// How many levels of ancestors stay pinned under the header; `0` pins
500 /// none. See [`pinned_ancestors`](Self::pinned_ancestors).
501 pinned_ancestors: usize,
502 /// The rows the pinned stack copies, outermost first, written by
503 /// `place_children` and bound `Rebuild` on the stack. Owned here so it
504 /// survives the root's rebuilds, as `ListView::pinned_section` does.
505 pinned_rows: Signal<Vec<usize>>,
506 /// The rows the stack last built. See `pinned::PinnedStack::built`.
507 pinned_built: Rc<RefCell<Vec<usize>>>,
508 /// Where each line of the stack goes this frame, from `place_children`.
509 pinned_layout: Rc<RefCell<Vec<pinned::StackLine>>>,
510 pinned_stack_id: Option<WidgetId>,
511}
512
513impl<T: 'static> TreeTableView<T> {
514 /// Wrap a `SortFilterTreeModel<T>`.
515 pub fn from_projection(proxy: SortFilterTreeModel<T>) -> Self {
516 let source = Rc::new(TreeSource::from_data_source(Rc::new(proxy.clone())));
517 Self::assemble(source, Some(proxy))
518 }
519
520 /// Build a tree table over any [`TreeDataSource`] — an external source of
521 /// truth (an entity store, a database, a virtual filesystem) carrying
522 /// its own `Key`, so it needs no `TreeModel` mirror.
523 ///
524 /// This is the tree-table sibling of
525 /// [`TreeView::from_source`](crate::TreeView::from_source). Because the
526 /// source owns identity, its expand state (and a keyed selection) survive a
527 /// full re-source — which a `TreeModel` mirror cannot guarantee, since
528 /// `NodeId`s are reassigned on rebuild.
529 ///
530 /// The `NodeId`-typed methods ([`expand`](Self::expand),
531 /// [`projection`](Self::projection), [`keyed_selection`](Self::keyed_selection))
532 /// do not apply here and no-op; drive expansion through the source itself.
533 ///
534 /// Row drag-reorder **is** wired on this path: a drop routes through the source's
535 /// own `drag` / `can_accept` / `accept_drop`, exactly as
536 /// [`TreeView`](crate::TreeView) does — so the
537 /// source owns both the cycle guard and the commit. Note that
538 /// [`TreeDataSlice::drag`](teksilo_data::TreeDataSlice) defaults to `NoDrag`: an
539 /// external source must opt its rows in before anything can be dragged.
540 pub fn from_source<S: TreeDataSource<Item = T> + 'static>(source: S) -> Self {
541 Self::assemble(Rc::new(TreeSource::from_data_source(Rc::new(source))), None)
542 }
543
544 /// Like [`from_source`](Self::from_source) but with **keyed** selection:
545 /// the `KeyedSelectionModel<S::Key>` tracks rows by source identity, so it
546 /// survives expand / collapse, sort / filter and a full re-source. Pruning
547 /// consults the source's `contains_key`, so a collapsed-but-present row
548 /// keeps its selection. The view stays `TreeTableView<T>` — the `Key` is
549 /// captured here.
550 pub fn from_source_keyed<S: TreeDataSource<Item = T> + 'static>(
551 source: S,
552 keyed: KeyedSelectionModel<S::Key>,
553 ) -> Self
554 where
555 S::Key: teksilo_data::ItemKey,
556 {
557 let s = Rc::new(source);
558 let key_at = {
559 let s = s.clone();
560 Rc::new(move |i| s.key_at(i)) as Rc<dyn Fn(usize) -> Option<S::Key>>
561 };
562 let len = {
563 let s = s.clone();
564 Rc::new(move || s.visible_count()) as Rc<dyn Fn() -> usize>
565 };
566 let contains = {
567 let s = s.clone();
568 Rc::new(move |k: &S::Key| s.contains_key(k)) as Rc<dyn Fn(&S::Key) -> bool>
569 };
570 let mut view = Self::assemble(Rc::new(TreeSource::from_data_source(s)), None);
571 view.row_selection = Some(RowSelection::from_keyed(keyed, key_at, len, contains));
572 view
573 }
574
575 fn assemble(source: Rc<TreeSource<T>>, proxy: Option<SortFilterTreeModel<T>>) -> Self {
576 let table_id = crate::table_view::next_header_id();
577 Self {
578 source,
579 proxy,
580 columns: Vec::new(),
581 tree_column_id: None,
582 indent_per_level: None,
583 row_height: None,
584 height_source: HeightSource::Uniform,
585 row_metrics: Rc::new(RefCell::new(RowMetrics::uniform(cp::ROW_HEIGHT, 0.0))),
586 header_height: None,
587 style_row_height: cp::ROW_HEIGHT,
588 style_header_height: cp::HEADER_HEIGHT,
589 show_header: true,
590 selection_mode: TableSelectionMode::default(),
591 row_selection: None,
592 cell_selection: None,
593 alternating_rows: false,
594 grid_lines: GridLines::None,
595 stretch_last_column: false,
596 a11y_label: None,
597 show_internal_scrollbars: true,
598 column_resize_policy: ColumnResizePolicy::default(),
599 tab_traversal: TabTraversal::default(),
600 edit_triggers: EditTriggers::default(),
601 on_cell_edit_request: None,
602 on_cell_edit_dismissed: None,
603 on_row_activate: None,
604 reorderable: false,
605 drop_feedback: Signal::new(None),
606 activate_on: crate::data_views::ActivateOn::default(),
607 smooth_scrolling: true,
608 smooth_scroll_duration: Duration::from_millis(150),
609 scroll_bar_style: ScrollBarMode::Permanent,
610 scroll_y: Signal::new_animated(0.0),
611 max_scroll_y: Signal::new(0.0),
612 overscroll_behavior: OverscrollBehavior::default(),
613 viewport_ratio_y: Signal::new(1.0),
614 scroll_x: Signal::new_animated(0.0),
615 max_scroll_x: Signal::new(0.0),
616 viewport_ratio_x: Signal::new(1.0),
617 scroller: Rc::new(RefCell::new(KineticScroller::new(OverscrollStyle::Clamp))),
618 sort_signal: Signal::new(None),
619 column_widths_signal: Signal::new(HashMap::new()),
620 column_widths_frozen: None,
621 column_order_signal: Signal::new(Vec::new()),
622 column_pinning_signal: Signal::new(HashMap::new()),
623 filters_signal: Signal::new(HashMap::new()),
624 focused_cell: Signal::new(None),
625 row_map: Rc::new(RefCell::new(Vec::new())),
626 type_ahead_label: None,
627 type_ahead_timeout: crate::common::type_ahead::DEFAULT_TYPE_AHEAD_TIMEOUT,
628 type_ahead: crate::common::type_ahead::TypeAheadState::new(),
629 // Replaced at build with the live tree signals.
630 view_focused: Signal::new(true),
631 focus_visible: Signal::new(false),
632 editing_cell: Signal::new(None),
633 empty_view: None,
634 laid_out: Rc::new(Cell::new(false)),
635 editing_anchor: Rc::new(RefCell::new(None)),
636 header_row_id: None,
637 body_pane_id: None,
638 scrollbar_id: None,
639 h_scrollbar_id: None,
640 empty_id: None,
641 pane_version: Signal::new(0_u64),
642 pane_built_start: Rc::new(Cell::new(0)),
643 pane_built_end: Rc::new(Cell::new(0)),
644 pane_total_refresh: Signal::new(0_u64),
645 reveal: RowReveal::new(),
646 column_widths: Rc::new(RefCell::new(Vec::new())),
647 display_indices: Rc::new(RefCell::new(Vec::new())),
648 pane_boundaries: Rc::new(RefCell::new(crate::table_view::PaneBoundaries::default())),
649 cell_map: Rc::new(RefCell::new(Vec::new())),
650 viewport_height: Rc::new(Cell::new(600.0)),
651 middle_viewport_width: Rc::new(Cell::new(600.0)),
652 body_bounds: Rc::new(Cell::new(Rect::ZERO)),
653 resize_state: Rc::new(RefCell::new(None)),
654 resize_target: Signal::new(None),
655 resize_preview_x: Signal::new(None),
656 header_strip_width: Rc::new(Cell::new(0.0)),
657 table_id,
658 model_id: ViewId::next(ViewKind::TreeTable),
659 export: crate::data_views::RowExport::default(),
660 on_foreign_drop: None,
661 enabled: Prop::Static(true),
662 full_width_row: None,
663 full_width_delegate: None,
664 pinned_ancestors: 0,
665 pinned_rows: Signal::new(Vec::new()),
666 pinned_built: Rc::new(RefCell::new(Vec::new())),
667 pinned_layout: Rc::new(RefCell::new(Vec::new())),
668 pinned_stack_id: None,
669 }
670 }
671
672 /// Wrap a raw `TreeModel<T>` — convenience for callers that don't
673 /// need sort/filter. Internally builds an identity
674 /// `SortFilterTreeModel`.
675 pub fn new(model: TreeModel<T>) -> Self {
676 Self::from_projection(SortFilterTreeModel::new(model))
677 }
678
679 // ── Builder ────────────────────────────────────────────────────────
680
681 /// Enable or disable the whole view. A disabled view greys out and stops
682 /// accepting focus / selection / keyboard input (arena-gated).
683 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
684 self.enabled = enabled.into();
685 self
686 }
687
688 /// Set the scroll-chaining behavior at the boundary (default
689 /// [`OverscrollBehavior::Chain`]; [`Contain`](OverscrollBehavior::Contain)
690 /// disables chaining to an ancestor scrollable).
691 pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
692 self.overscroll_behavior = behavior;
693 self
694 }
695
696 /// Enable or disable animated wheel scrolling (enabled by default).
697 /// When disabled, wheel events snap immediately to the new offset.
698 pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
699 self.smooth_scrolling = enabled;
700 self
701 }
702
703 /// Enable **type-ahead** ("type to jump"): typing a printable character
704 /// while the tree-table has keyboard focus jumps the focused row to the
705 /// next *visible* row whose label starts with the accumulated search term,
706 /// wrapping around (Qt `keyboardSearch` / macOS & Windows type-select).
707 /// `label(&item)` yields the searchable text; matching is
708 /// ASCII-case-insensitive. A pause longer than the
709 /// [`type_ahead_timeout`](Self::type_ahead_timeout) starts a fresh term.
710 pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self {
711 self.type_ahead_label = Some(Rc::new(label));
712 self
713 }
714
715 /// Reset window between keystrokes before the type-ahead search term
716 /// clears (default 500 ms). A zero duration disables type-ahead.
717 pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self {
718 self.type_ahead_timeout = timeout;
719 self
720 }
721
722 /// Duration of the smooth scroll animation (default 150 ms).
723 pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
724 self.smooth_scroll_duration = duration;
725 self
726 }
727
728 /// How the scroll bar is displayed (default `Permanent`). `Overlay`
729 /// and `Thin` float the bar over the content instead of reserving a
730 /// layout column for it, mirroring `ScrollArea::scroll_bar_style`.
731 pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
732 self.scroll_bar_style = style;
733 self
734 }
735
736 /// Append a column definition. Columns are displayed in declaration order unless
737 /// reordered by the user.
738 pub fn add_column(mut self, col: Column<T>) -> Self {
739 self.columns.push(col);
740 self
741 }
742
743 /// Enable drag-to-reorder of **rows** (pointer drag + keyboard
744 /// Alt+ArrowUp/Down). Distinct from
745 /// [`Column::reorderable`](crate::Column::reorderable), which reorders
746 /// *columns* and defaults to `true`; this defaults to `false`.
747 ///
748 /// A drop reparents/reorders the dragged node in the underlying
749 /// `TreeModel` (top third of a row = Before, middle = Into / make-child,
750 /// bottom = After). The move is cycle-guarded — dropping a node onto
751 /// itself or into its own subtree is refused (no insertion line). Reorder
752 /// is **suppressed while the view is sorted** by one of its own columns:
753 /// with the visible order driven by the sort, a manual reorder would have
754 /// no visible effect. A sort a shared [`bind_sort`](Self::bind_sort)
755 /// holds for a column this view lacks does not count.
756 pub fn reorderable(mut self, enabled: bool) -> Self {
757 self.reorderable = enabled;
758 self
759 }
760
761 /// Make rows **droppable outside this view** — on a
762 /// [`DropTarget`](crate::DropTarget), another data view, or the OS.
763 ///
764 /// A dragged row (or the whole selection, when the pressed row is part of a
765 /// multi-selection) carries clones of its items in a public
766 /// [`RowDragData<T>`](crate::RowDragData), so a foreign receiver can pull
767 /// them out with `payload.get_typed::<RowDragData<T>>()` /
768 /// `DropTarget::on_drop_typed::<RowDragData<T>>()` — no serialization. This
769 /// also makes rows a drag source even without [`reorderable`](Self::reorderable).
770 ///
771 /// `mode` chooses what happens to the origin rows once a *foreign* target
772 /// accepts them: [`DragTransferMode::Move`] removes them — by default,
773 /// directly from the underlying `TreeModel` (any dragged node that is a
774 /// descendant of another dragged node is skipped, since removing the
775 /// ancestor already removes it); override via
776 /// [`on_rows_transferred_out`](Self::on_rows_transferred_out).
777 /// [`DragTransferMode::Copy`] leaves them. A same-view reorder is never a
778 /// transfer, so `mode` never affects it. Requires `T: Clone`.
779 pub fn exportable(mut self, mode: DragTransferMode) -> Self
780 where
781 T: Clone,
782 {
783 self.export.set_exportable(mode);
784 self
785 }
786
787 /// Additionally advertise the dragged rows as MIME data so they can be
788 /// dropped on a [`DropZone`](crate::DropZone) or exported to another
789 /// application / window via the OS. `f` maps the dragged items to
790 /// `(mime_type, bytes)` pairs (e.g. `text/plain`, `text/uri-list`, an
791 /// app-specific `application/x-…`). Implies [`exportable`](Self::exportable)
792 /// (defaulting to [`DragTransferMode::Move`] if not already set). Requires
793 /// `T: Clone`.
794 pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
795 where
796 T: Clone,
797 {
798 self.export.set_export_external(f);
799 self
800 }
801
802 /// Override how rows moved out to a foreign target are removed from this
803 /// view. Receives the dragged rows' flat visible indices (as captured at
804 /// drag-start) and the live context. Without this, an
805 /// [`exportable`](Self::exportable) [`Move`](DragTransferMode::Move) drag
806 /// removes the dragged nodes directly from the underlying `TreeModel`
807 /// (leaf-first / descending — a dragged node that is a descendant of
808 /// another dragged node is skipped, since removing the ancestor already
809 /// removes its whole subtree).
810 pub fn on_rows_transferred_out(
811 mut self,
812 f: impl Fn(&[usize], &mut EventContext) + 'static,
813 ) -> Self {
814 self.export.set_on_rows_transferred_out(f);
815 self
816 }
817
818 /// Accept exported rows dropped from a **different** view or source
819 /// without writing a custom source. Pair with
820 /// [`on_rows_received`](Self::on_rows_received), which is handed the
821 /// dropped items and the target flat row index. (Same-view reorder is
822 /// [`reorderable`](Self::reorderable).)
823 pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
824 self.export.accept_foreign_rows = accept;
825 self
826 }
827
828 /// Handler for rows accepted via
829 /// [`accept_foreign_rows`](Self::accept_foreign_rows): `(items, target
830 /// flat row index, ctx)`. Insert them into your tree at/near the index.
831 pub fn on_rows_received(
832 mut self,
833 f: impl Fn(Vec<T>, usize, &mut EventContext) + 'static,
834 ) -> Self {
835 self.export.set_on_rows_received(f);
836 self
837 }
838
839 /// Raw escape hatch for a foreign drop.
840 ///
841 /// **Projection path only.** This hook is `NodeId`-typed and predates
842 /// [`from_source`](Self::from_source); over an external source there is no
843 /// `NodeId` to hand it, so it never fires. Prefer
844 /// [`accept_foreign_rows`](Self::accept_foreign_rows) +
845 /// [`on_rows_received`](Self::on_rows_received), which are source-agnostic.
846 /// A source-backed view expresses foreign-accept through its source's own
847 /// capability closures (`can_accept` / `accept_drop`), like `ListView` /
848 /// `TableView`; this hook is what a **projection**-backed view has instead,
849 /// since a `SortFilterTreeModel` carries no such closures.
850 /// This fires for **any** payload NOT recognized as this view's own row
851 /// drag — a different view's [`RowDragData<T>`](crate::RowDragData), or a
852 /// completely different payload type — dropped on a node: `(payload,
853 /// target node, drop position, ctx) -> accepted`. Tried after
854 /// [`on_rows_received`](Self::on_rows_received), so the typed sugar wins
855 /// when both are set and the payload happens to carry an exportable
856 /// `RowDragData<T>`.
857 pub fn on_foreign_drop(
858 mut self,
859 f: impl Fn(&DragPayload, NodeId, DropPosition, &mut EventContext) -> bool + 'static,
860 ) -> Self {
861 self.on_foreign_drop = Some(Rc::new(f));
862 self
863 }
864
865 /// Choose single- vs double-click activation for `on_row_activate` (default
866 /// [`ActivateOn::DoubleClick`](crate::ActivateOn)). Enter/Space activates in
867 /// either mode.
868 pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
869 self.activate_on = mode;
870 self
871 }
872
873 /// Append multiple columns from an iterator.
874 pub fn columns(mut self, cols: impl IntoIterator<Item = Column<T>>) -> Self {
875 self.columns.extend(cols);
876 self
877 }
878
879 /// Designate which column hosts the twist + indent. Default: the
880 /// first column.
881 pub fn tree_column(mut self, col_id: impl Into<String>) -> Self {
882 self.tree_column_id = Some(col_id.into());
883 self
884 }
885
886 /// Override the per-depth indent in the tree column in logical pixels (default
887 /// comes from the active `TableStyle`).
888 pub fn indent_per_level(mut self, px: f32) -> Self {
889 self.indent_per_level = Some(px);
890 self
891 }
892
893 /// Draw the rows whose item satisfies `is_full_width` as **one cell across
894 /// every column** instead of a cell per column: a group row ("an artist ·
895 /// 12 songs | 3 albums | 52:10") over rows that fill the columns. What the
896 /// band shows comes from
897 /// [`full_width_row_delegate`](Self::full_width_row_delegate); without one
898 /// it shows the tree column's own cell, laid across the row.
899 ///
900 /// Asked of the item, like the cell delegates; a row whose item is still
901 /// loading is never full-width. The predicate is read on every build and
902 /// on every key press, so keep it to a field test.
903 ///
904 /// The row stays a row of the tree in every other respect: it selects,
905 /// activates, drags, takes drops and answers type-ahead as any row does,
906 /// its chevron (indent and twist) sits in the band, and ←/→ act on it as
907 /// on the tree column from any column: they collapse and expand it, and ←
908 /// on a collapsed band moves up to its parent. In cell navigation the band
909 /// is one cell: Home and End stay on it, Tab passes it as one stop, and no
910 /// column of it opens an editor. The cursor keeps the column it arrived
911 /// with, so stepping on to an ordinary row lands back in that column.
912 ///
913 /// The band is as wide as the columns together, or the row when they are
914 /// wider, so beside columns narrower than the view it ends where a row's
915 /// selection band, alternating tint and focus ring do. It stays in the
916 /// viewport while the columns
917 /// scroll sideways, so a group row stays readable; its chevron sits where
918 /// the tree column's does with the columns unscrolled. Its height comes
919 /// from the view's height mode like any row's: the uniform height, the
920 /// [`row_height_fn`](Self::row_height_fn) callback, or, under
921 /// [`auto_row_height`](Self::auto_row_height), the band measured at its
922 /// width.
923 ///
924 /// To assistive technology the row is unchanged (`Role::Row` with its
925 /// level, expanded state and position) and holds one `Role::Cell`
926 /// (`Role::GridCell` in the cell modes) at column 1, with a column span of
927 /// every column and the band as its content.
928 pub fn full_width_row(mut self, is_full_width: impl Fn(&T) -> bool + 'static) -> Self {
929 self.full_width_row = Some(Rc::new(is_full_width));
930 self
931 }
932
933 /// Build the band of a [`full_width_row`](Self::full_width_row) from its
934 /// item, as a [`Column`]'s delegate builds a cell. The context describes
935 /// the band as the tree column, whose chevron it carries: `col_id` and
936 /// `col_index` are the tree column's and `is_tree_column` is `true`;
937 /// `is_focused` holds while the cursor is on the row, in any column, and
938 /// `is_selected` while the row is selected (in a cell mode, while any of
939 /// its cells is). The indent and the chevron are drawn before the
940 /// delegate's widget, as in the tree column.
941 ///
942 /// Does nothing without [`full_width_row`](Self::full_width_row), which
943 /// says which rows get it.
944 pub fn full_width_row_delegate(
945 mut self,
946 delegate: impl Fn(&T, &CellContext) -> Box<dyn Widget> + 'static,
947 ) -> Self {
948 self.full_width_delegate = Some(Rc::new(delegate));
949 self
950 }
951
952 /// Keep the ancestor rows of the first visible row pinned under the
953 /// header while the view scrolls, outermost first and at most `depth`
954 /// levels of them: the "sticky scroll" of code editors, the artist and
955 /// then the album over the songs. `0` (the default) pins nothing.
956 ///
957 /// A pinned row is pushed up by the next row at its level as that row
958 /// arrives, as a pinned section header is, so it never covers a row it is
959 /// not an ancestor of. It is a copy, drawn with the real row's cells (its
960 /// band, for a [`full_width_row`](Self::full_width_row)) at the real row's
961 /// height, on the header's surface. The copy shows the row's content, not
962 /// its selection or its focus, and its chevron is a picture. Under
963 /// [`auto_row_height`](Self::auto_row_height) the copy measures the row,
964 /// as the row does once laid out: an ancestor the view scrolled past
965 /// without laying it out takes its height a frame after it is pinned, and
966 /// a reveal made before then counts it at the estimate.
967 ///
968 /// A press on a pinned row does what a plain click on the real row would:
969 /// it ends an open cell edit, selects the row and puts the cursor on it,
970 /// activates it under [`ActivateOn::SingleClick`](crate::data_views::ActivateOn::SingleClick),
971 /// and scrolls it back into view, right under its own pinned ancestors. A
972 /// double click on a copy does not activate it, and a press never reaches
973 /// a control drawn inside the copy. A drop on a pinned row lands on the
974 /// row it shows, and its indicator is drawn over the copy.
975 ///
976 /// Every reveal the view makes (the keyboard moving the cursor,
977 /// [`ensure_row_visible`](Self::ensure_row_visible),
978 /// [`scroll_to_row`](Self::scroll_to_row), taking focus, the
979 /// ScrollIntoView action) stops below the ancestors that would be pinned
980 /// over the row, so a copy never covers the row the cursor is on.
981 ///
982 /// The copies are hidden from assistive technology and are not focusable;
983 /// the real rows stay where they are in the tree, scrolled under the
984 /// copies like any row off the top. The stack is re-derived on every
985 /// layout, so an expand, a collapse, an insert or a re-source above or
986 /// inside it shows on the next frame.
987 pub fn pinned_ancestors(mut self, depth: usize) -> Self {
988 self.pinned_ancestors = depth;
989 self
990 }
991
992 /// Re-materialize `self.row_metrics` after a height-mode /
993 /// row-height builder call.
994 fn remake_metrics(&self) {
995 *self.row_metrics.borrow_mut() = self
996 .height_source
997 .make_metrics(self.effective_row_height(), 0.0);
998 }
999
1000 /// Read the active `TableStyle`'s row and header heights at the density
1001 /// in force. Called first thing in `build`, so every height the build and
1002 /// the later layout passes read is the style's, unless the app set its
1003 /// own through [`row_height`](Self::row_height) /
1004 /// [`header_height`](Self::header_height), which win. A uniform-height
1005 /// view with no row height of its own re-materializes its metrics when
1006 /// the style's row height differs from the one they were made with.
1007 fn adopt_style_heights(&mut self, ctx: &BuildContext) {
1008 let style = crate::styles::recipe_table_style::resolve_table_style(ctx);
1009 let tokens = &ctx.theme().input;
1010 self.style_header_height = style.header_height(tokens);
1011 let row = style.row_height(tokens);
1012 if row != self.style_row_height {
1013 self.style_row_height = row;
1014 if self.row_height.is_none() && matches!(self.height_source, HeightSource::Uniform) {
1015 self.remake_metrics();
1016 }
1017 }
1018 }
1019
1020 /// Fixed row height, the uniform fast path. Default: the active
1021 /// `TableStyle`'s
1022 /// [`row_height`](teksilo_core::styles::TableStyle::row_height), 28 px
1023 /// under IntUI at the Compact density. Mutually exclusive with
1024 /// [`row_height_fn`](Self::row_height_fn) and
1025 /// [`auto_row_height`](Self::auto_row_height); the last mode setter
1026 /// wins.
1027 pub fn row_height(mut self, height: f32) -> Self {
1028 self.row_height = Some(height);
1029 self.height_source = HeightSource::Uniform;
1030 self.remake_metrics();
1031 self
1032 }
1033
1034 /// Per-row heights from a callback over the flat (visible) row
1035 /// index. The callback must be pure (same index + same data → same
1036 /// height); it is re-swept from the first changed flat index on
1037 /// every projection rebuild (expand/collapse/sort/filter/mutation).
1038 /// No measurement pass runs.
1039 pub fn row_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
1040 self.height_source = HeightSource::Exact(Rc::new(f));
1041 self.remake_metrics();
1042 self
1043 }
1044
1045 /// Auto-measured row heights: each realized row reports the height
1046 /// of its tallest cell measured at the cell's column width
1047 /// (height-for-width), unrealized rows assume `estimated`. Scroll
1048 /// anchoring keeps content above the viewport stationary; measured
1049 /// heights above a toggled row survive expand/collapse
1050 /// (divergence-driven invalidation). The scrollbar settles one
1051 /// frame after a measurement change.
1052 pub fn auto_row_height(mut self, estimated: f32) -> Self {
1053 self.height_source = HeightSource::Auto { estimated };
1054 self.remake_metrics();
1055 self
1056 }
1057
1058 /// Override the header row height in logical pixels. Default: the active
1059 /// `TableStyle`'s
1060 /// [`header_height`](teksilo_core::styles::TableStyle::header_height),
1061 /// 32 px under IntUI at the Compact density.
1062 pub fn header_height(mut self, height: f32) -> Self {
1063 self.header_height = Some(height);
1064 self
1065 }
1066
1067 /// Show or hide the column header row (default `true`).
1068 pub fn show_header(mut self, visible: bool) -> Self {
1069 self.show_header = visible;
1070 self
1071 }
1072
1073 /// Set the row/cell selection mode (default
1074 /// [`TableSelectionMode::MultiRow`]).
1075 pub fn selection_mode(mut self, mode: TableSelectionMode) -> Self {
1076 self.selection_mode = mode;
1077 self
1078 }
1079
1080 /// Set the index-based row selection model (visible positions). For
1081 /// identity-based selection that survives expand / collapse / sort /
1082 /// filter / structural edits, use [`keyed_selection`](Self::keyed_selection)
1083 /// instead.
1084 pub fn selection(mut self, sel: SelectionModel) -> Self {
1085 self.row_selection = Some(RowSelection::from_index(sel));
1086 self
1087 }
1088
1089 /// Set a keyed row selection model (by `NodeId`). Selection is tracked by
1090 /// node identity, so it survives expand / collapse, sort / filter, and node
1091 /// moves — and stays consistent if two views share the projection. Pruned
1092 /// of deleted nodes on each projection change. Mutually exclusive with
1093 /// [`selection`](Self::selection) (last one set wins).
1094 /// Only meaningful on the [`from_projection`](Self::from_projection) /
1095 /// [`new`](Self::new) paths, whose identity *is* `NodeId`; a no-op over an
1096 /// external source, which carries its own key — use
1097 /// [`from_source_keyed`](Self::from_source_keyed) there.
1098 pub fn keyed_selection(mut self, keyed: KeyedSelectionModel<NodeId>) -> Self {
1099 let Some(proxy) = self.proxy.clone() else {
1100 return self;
1101 };
1102 let key_at = {
1103 let p = proxy.clone();
1104 Rc::new(move |i| p.visible_node_id(i)) as Rc<dyn Fn(usize) -> Option<NodeId>>
1105 };
1106 let len = {
1107 let p = proxy.clone();
1108 Rc::new(move || p.visible_count()) as Rc<dyn Fn() -> usize>
1109 };
1110 // A collapsed-but-present node must NOT be pruned, so existence is
1111 // checked against the tree, not the (visible) projection window.
1112 let contains = {
1113 let p = proxy;
1114 Rc::new(move |n: &NodeId| p.tree().with_item(*n, |_| ()).is_some())
1115 as Rc<dyn Fn(&NodeId) -> bool>
1116 };
1117 self.row_selection = Some(RowSelection::from_keyed(keyed, key_at, len, contains));
1118 self
1119 }
1120
1121 /// Attach a cell-level selection model (row and column axes tracked
1122 /// independently).
1123 pub fn cell_selection(mut self, sel: CellSelectionModel) -> Self {
1124 self.cell_selection = Some(sel);
1125 self
1126 }
1127
1128 /// Paint odd-indexed rows with the `SurfaceRole::AlternatingRow` tint
1129 /// (default `false`).
1130 pub fn alternating_rows(mut self, enabled: bool) -> Self {
1131 self.alternating_rows = enabled;
1132 self
1133 }
1134
1135 /// Paint horizontal and/or vertical dividers between cells.
1136 pub fn grid_lines(mut self, kind: GridLines) -> Self {
1137 self.grid_lines = kind;
1138 self
1139 }
1140
1141 /// Let the **last column in display order** take up whatever width the
1142 /// other columns leave, so the table never ends in a bare strip at its
1143 /// trailing edge — Qt's `stretchLastSection`, NSTableView's
1144 /// `lastColumnOnlyAutoresizingStyle`. Default: off.
1145 ///
1146 /// Positional, not a property of a column: after a reorder it is the
1147 /// *new* last column that stretches and the previous one goes back to
1148 /// its own width. While a column stretches, its declared width is the
1149 /// floor it grows from, its user-resize override is ignored, and its
1150 /// trailing grip is disabled (no AccessKit Increment/Decrement either):
1151 /// any size the user gave it, the stretch would take straight back.
1152 /// Resizing any *other* column reflows it. Once the other columns
1153 /// exceed the viewport there is nothing left to stretch into — the
1154 /// last column sits at its own width and the pane scrolls, as in Qt.
1155 ///
1156 /// `Flex` columns already share every spare pixel among themselves, so
1157 /// a table of `Flex` columns looks the same either way: this is for
1158 /// pixel-sized tables (`Fixed` / `Auto`, or widths the user has set)
1159 /// that would otherwise end in a gap.
1160 pub fn stretch_last_column(mut self, on: bool) -> Self {
1161 self.stretch_last_column = on;
1162 self
1163 }
1164
1165 /// Accessible label for the whole tree table, announced by AT as the
1166 /// table's name.
1167 pub fn a11y_label(mut self, label: impl Into<LocalizedString>) -> Self {
1168 self.a11y_label = Some(label.into());
1169 self
1170 }
1171
1172 /// Show or hide the widget's internal vertical and horizontal scroll bars
1173 /// (default `true`). Set to `false` when the table lives inside an external
1174 /// `ScrollArea`.
1175 pub fn show_internal_scrollbars(mut self, show: bool) -> Self {
1176 self.show_internal_scrollbars = show;
1177 self
1178 }
1179
1180 /// Control how column widths are distributed when the table is resized
1181 /// (default `Proportional`).
1182 pub fn column_resize_policy(mut self, policy: ColumnResizePolicy) -> Self {
1183 self.column_resize_policy = policy;
1184 self
1185 }
1186
1187 /// Set the keyboard Tab traversal direction inside the table (default `CellsThenRows`).
1188 pub fn tab_traversal(mut self, mode: TabTraversal) -> Self {
1189 self.tab_traversal = mode;
1190 self
1191 }
1192
1193 /// Set which user gestures open an in-place cell editor — a set, composed
1194 /// with `|` (default `F2 | ANY_KEY | DOUBLE_CLICK`). See [`EditTriggers`].
1195 pub fn edit_triggers(mut self, trigger: EditTriggers) -> Self {
1196 self.edit_triggers = trigger;
1197 self
1198 }
1199
1200 /// Callback invoked when the user requests an in-place cell edit (e.g.
1201 /// double-click when `edit_triggers` contains `DOUBLE_CLICK`). Receives the flat row
1202 /// index, the column id, and a mutable `EventContext`.
1203 pub fn on_cell_edit_request(
1204 mut self,
1205 f: impl Fn(usize, &str, &mut EventContext) + 'static,
1206 ) -> Self {
1207 self.on_cell_edit_request = Some(Rc::new(f));
1208 self
1209 }
1210
1211 /// Callback invoked when an **open** cell editor should end because the
1212 /// pointer went somewhere else: a press that lands on any cell other than
1213 /// the one being edited. Receives the editing cell's flat row index and
1214 /// column id, so the owner can commit (or discard) whatever is in its
1215 /// buffer, then clear its own editing state.
1216 ///
1217 /// The counterpart of [`on_cell_edit_request`](Self::on_cell_edit_request),
1218 /// and the view cannot do it alone: the framework owns *which* cell is being
1219 /// edited, but only the owner knows what an ended edit means — commit,
1220 /// discard, or refuse a value that will not parse.
1221 ///
1222 /// **Why a press and not a focus change.** "The editor lost focus" is the
1223 /// obvious signal and it cannot be used: a body pane rebuilds constantly —
1224 /// selection, filtering, scroll, a reload from elsewhere — and every rebuild
1225 /// destroys and re-creates the open editor, so focus leaves it many times
1226 /// during an edit the writer never interrupted. A press on another cell is
1227 /// unambiguous and happens exactly once.
1228 pub fn on_cell_edit_dismissed(
1229 mut self,
1230 f: impl Fn(usize, &str, &mut EventContext) + 'static,
1231 ) -> Self {
1232 self.on_cell_edit_dismissed = Some(Rc::new(f));
1233 self
1234 }
1235
1236 /// Callback invoked when a row is activated (double-click or Enter, per
1237 /// `activate_on`). Receives the flat row index.
1238 pub fn on_row_activate(mut self, f: impl Fn(usize, &mut EventContext) + 'static) -> Self {
1239 self.on_row_activate = Some(Rc::new(f));
1240 self
1241 }
1242
1243 /// Forward `mode` to the underlying projection. The proxy holds its
1244 /// state behind `Rc<RefCell>`, so calling `.filter_mode()` on a
1245 /// clone mutates the shared inner — effectively persisting the
1246 /// choice on `self.proxy`.
1247 pub fn filter_mode(self, mode: TreeFilterMode) -> Self {
1248 if let Some(p) = &self.proxy {
1249 let _ = p.clone().filter_mode(mode);
1250 }
1251 self
1252 }
1253
1254 // ── Adopted column state ──────────────────────────────────────────
1255 //
1256 // The same contract as `TableView`'s `bind_*` builders: see
1257 // "Column state an application owns" in `table_view`'s module docs.
1258
1259 /// Use `sort` as this view's sort state instead of a signal of its own:
1260 /// header clicks and [`set_sort`](Self::set_sort) write it, a write from
1261 /// anywhere else updates the header, and
1262 /// [`sort_signal`](Self::sort_signal) returns it. Bind the same signal to
1263 /// the [`SortFilterTreeModel`] to re-sort the rows; one signal shared by
1264 /// both is what lets a projection keep its preset comparators while the
1265 /// header drives it.
1266 ///
1267 /// The view writes nothing into it until the user sorts; see
1268 /// [`TableView`](crate::TableView)'s module docs, "Column state an
1269 /// application owns".
1270 pub fn bind_sort(mut self, sort: Signal<Option<(String, SortDirection)>>) -> Self {
1271 self.sort_signal = sort;
1272 self
1273 }
1274
1275 /// Use `widths` as this view's map of column id → width instead of a
1276 /// signal of its own: a resize drag and
1277 /// [`set_column_width`](Self::set_column_width) write it, and a write from
1278 /// anywhere else resizes the columns. A column with no entry takes its
1279 /// declared width; an entry for a column the view lacks is ignored. A
1280 /// resize writes the resized column's entry alone; the `Flex` columns
1281 /// before it keep their widths in this view, not in the map.
1282 /// [`column_widths_signal`](Self::column_widths_signal) returns it.
1283 pub fn bind_column_widths(mut self, widths: Signal<HashMap<String, f32>>) -> Self {
1284 self.column_widths_signal = widths;
1285 self.column_widths_frozen = Some(Rc::default());
1286 self
1287 }
1288
1289 /// Use `order` as this view's column order instead of a signal of its
1290 /// own: a reorder drop and [`set_column_order`](Self::set_column_order)
1291 /// write it, and a write from anywhere else reorders the columns. Ids the
1292 /// view lacks are skipped when it lays out and kept in place when it
1293 /// writes. [`column_order_signal`](Self::column_order_signal) returns it.
1294 pub fn bind_column_order(mut self, order: Signal<Vec<String>>) -> Self {
1295 self.column_order_signal = order;
1296 self
1297 }
1298
1299 /// Use `filters` as this view's per-column filter text instead of a
1300 /// signal of its own: the filter popover and
1301 /// [`set_filter`](Self::set_filter) write it, and
1302 /// [`filters_signal`](Self::filters_signal) returns it. Bind the same
1303 /// signal to the [`SortFilterTreeModel`] to filter the rows.
1304 pub fn bind_filters(mut self, filters: Signal<HashMap<String, String>>) -> Self {
1305 self.filters_signal = filters;
1306 self
1307 }
1308
1309 // ── Reactive signals ──────────────────────────────────────────────
1310
1311 /// Current vertical scroll offset in logical pixels.
1312 pub fn scroll_y_signal(&self) -> &Signal<f32> {
1313 &self.scroll_y
1314 }
1315
1316 /// Maximum vertical scroll offset (content height − viewport height).
1317 pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
1318 &self.max_scroll_y
1319 }
1320
1321 /// Viewport-to-content height ratio — drives the scrollbar thumb size.
1322 pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
1323 &self.viewport_ratio_y
1324 }
1325
1326 /// Current horizontal scroll offset of the Middle (unpinned) pane, in
1327 /// logical pixels. Leading/Trailing-pinned columns are unaffected.
1328 pub fn scroll_x_signal(&self) -> &Signal<f32> {
1329 &self.scroll_x
1330 }
1331
1332 /// Maximum horizontal scroll offset — `middle_content_width −
1333 /// middle_viewport_width`.
1334 pub fn max_scroll_x_signal(&self) -> &Signal<f32> {
1335 &self.max_scroll_x
1336 }
1337
1338 /// Middle-pane viewport-to-content width ratio.
1339 pub fn viewport_ratio_x_signal(&self) -> &Signal<f32> {
1340 &self.viewport_ratio_x
1341 }
1342
1343 /// Active sort state: `Some((col_id, direction))` or `None` for unsorted.
1344 ///
1345 /// **This is the header's state, not the data's.** Clicking a sort header
1346 /// writes here; nothing reorders rows until you bind this onto the backing
1347 /// projection yourself:
1348 ///
1349 /// ```ignore
1350 /// let proxy = SortFilterTreeModel::new(tree)
1351 /// .with_comparator("name", |a: &Row, b: &Row| a.name.cmp(&b.name));
1352 /// proxy.sort_signal(view.sort_signal().clone());
1353 /// ```
1354 ///
1355 /// The binding is deliberately not automatic: a projection may already
1356 /// carry preset comparators, predicates, and a filter mode, and adopting
1357 /// the view's empty signal at construction would clobber them. To share
1358 /// one signal between the two from the start, hand it to
1359 /// [`bind_sort`](Self::bind_sort); this returns the adopted signal then.
1360 pub fn sort_signal(&self) -> &Signal<Option<(String, SortDirection)>> {
1361 &self.sort_signal
1362 }
1363
1364 /// Active per-column filters keyed by column id.
1365 ///
1366 /// Like [`sort_signal`](Self::sort_signal), this holds the header's state
1367 /// only — bind it onto the projection to actually filter rows:
1368 ///
1369 /// ```ignore
1370 /// let proxy = SortFilterTreeModel::new(tree)
1371 /// .with_predicate("name", |t| {
1372 /// let needle = t.to_string();
1373 /// Box::new(move |r: &Row| r.name.contains(&needle))
1374 /// });
1375 /// proxy.filters_signal(view.filters_signal().clone());
1376 /// ```
1377 ///
1378 /// The signal adopted by [`bind_filters`](Self::bind_filters), if any.
1379 pub fn filters_signal(&self) -> &Signal<HashMap<String, String>> {
1380 &self.filters_signal
1381 }
1382
1383 /// User-resized column widths in logical pixels, keyed by column id. The
1384 /// view writes entries only for a resize: the resized column's, and —
1385 /// when the map is its own — one for each `Flex` column before it, frozen
1386 /// at the width it had. A missing key means the declared width. The
1387 /// signal adopted by [`bind_column_widths`](Self::bind_column_widths), if
1388 /// any.
1389 pub fn column_widths_signal(&self) -> &Signal<HashMap<String, f32>> {
1390 &self.column_widths_signal
1391 }
1392
1393 /// Current column display order as a list of column ids. The signal
1394 /// adopted by [`bind_column_order`](Self::bind_column_order), if any.
1395 pub fn column_order_signal(&self) -> &Signal<Vec<String>> {
1396 &self.column_order_signal
1397 }
1398
1399 /// Keyboard-focused cell as `(row, display_column_index)`, or `None`.
1400 ///
1401 /// In a tree table whose selection holds one row or cell, a selection
1402 /// change that leaves an existing cursor off the selection moves the
1403 /// cursor onto it.
1404 pub fn focused_cell_signal(&self) -> &Signal<Option<(usize, usize)>> {
1405 &self.focused_cell
1406 }
1407
1408 /// Cell currently being edited as `(row, display_column_index)`, or `None`.
1409 pub fn editing_cell_signal(&self) -> &Signal<Option<(usize, usize)>> {
1410 &self.editing_cell
1411 }
1412
1413 /// The widget realized for the cell at `(row, display column)` in the body
1414 /// pane's latest build, or `None` once it has scrolled (or collapsed) out
1415 /// of the realized buffer — `cell_map` is a snapshot, not an index of every
1416 /// row the source holds, so a miss here means "not on screen", never "no
1417 /// such cell".
1418 fn realized_cell(&self, row: usize, col: usize) -> Option<WidgetId> {
1419 self.cell_map
1420 .borrow()
1421 .iter()
1422 .find(|&&(pos, _)| pos == (row, col))
1423 .map(|&(_, id)| id)
1424 }
1425
1426 /// Access the underlying `SortFilterTreeModel` (for programmatic sort /
1427 /// filter / expand outside of the builder API).
1428 /// `None` when the view was built from an external
1429 /// [`teksilo_data::TreeDataSource`] via
1430 /// [`from_source`](Self::from_source) — there is no `TreeModel`-backed
1431 /// projection to hand back in that case.
1432 pub fn projection(&self) -> Option<&SortFilterTreeModel<T>> {
1433 self.proxy.as_ref()
1434 }
1435
1436 // ── Imperative API ─────────────────────────────────────────────────
1437
1438 /// Expand the subtree rooted at `node`.
1439 pub fn expand(&self, node: NodeId) {
1440 if let Some(p) = &self.proxy {
1441 p.expand(node);
1442 }
1443 }
1444
1445 /// Collapse the subtree rooted at `node`.
1446 pub fn collapse(&self, node: NodeId) {
1447 if let Some(p) = &self.proxy {
1448 p.collapse(node);
1449 }
1450 }
1451
1452 /// Toggle the expand/collapse state of `node`.
1453 pub fn toggle(&self, node: NodeId) {
1454 if let Some(p) = &self.proxy {
1455 p.toggle(node);
1456 }
1457 }
1458
1459 /// Expand all nodes in the tree.
1460 pub fn expand_all(&self) {
1461 if let Some(p) = &self.proxy {
1462 p.expand_all();
1463 }
1464 }
1465
1466 /// Collapse all nodes in the tree.
1467 pub fn collapse_all(&self) {
1468 if let Some(p) = &self.proxy {
1469 p.collapse_all();
1470 }
1471 }
1472
1473 /// Move keyboard focus to the cell at `(row, col)`.
1474 pub fn set_focused_cell(&self, row: usize, col: usize) {
1475 self.focused_cell.set(Some((row, col)));
1476 }
1477
1478 /// Clear the keyboard-focused cell.
1479 pub fn clear_focused_cell(&self) {
1480 self.focused_cell.set(None);
1481 }
1482
1483 /// Programmatically sort by `col_id` (pass `None` to clear the sort).
1484 ///
1485 /// Equality-guarded, like every persisted-layout setter here — see
1486 /// [`set_column_widths`](Self::set_column_widths).
1487 pub fn set_sort(&self, col_id: Option<&str>, dir: SortDirection) {
1488 imperative::set_if_changed(&self.sort_signal, col_id.map(|c| (c.to_string(), dir)));
1489 }
1490
1491 /// Set or clear the filter text for a single column.
1492 pub fn set_filter(&self, col_id: &str, text: &str) {
1493 imperative::set_filter(&self.filters_signal, col_id, text);
1494 }
1495
1496 pub fn clear_filters(&self) {
1497 imperative::set_if_changed(&self.filters_signal, HashMap::new());
1498 }
1499
1500 /// Widget shown when no rows are visible — an empty tree, or a filter
1501 /// that matched nothing. Without one, the body region is simply blank.
1502 pub fn empty_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
1503 self.empty_view = Some(Rc::new(f));
1504 self
1505 }
1506
1507 /// Clear the active sort.
1508 pub fn clear_sort(&self) {
1509 imperative::set_if_changed(&self.sort_signal, None);
1510 }
1511
1512 /// Scroll so that `row` (a flat visible index) is aligned to the top of
1513 /// the viewport, now, against the row offsets and scroll range of the last
1514 /// layout pass. A no-op before the first layout pass.
1515 ///
1516 /// The offset is clamped when this is called, to the range that pass
1517 /// wrote, so after a structural change in the same turn (a node expanded
1518 /// above the row) it still describes the old rows. On a mounted view, or
1519 /// to scroll before the first layout, use a [`handle`](Self::handle),
1520 /// which defers the computation to the next layout pass.
1521 ///
1522 /// With [`pinned_ancestors`](Self::pinned_ancestors), the top it is
1523 /// aligned to is the bottom of its own pinned ancestors.
1524 pub fn scroll_to_row(&self, row: usize) {
1525 if !self.laid_out.get() {
1526 return;
1527 }
1528 match self.pinned_reveal() {
1529 Some(pinned) => {
1530 if let Some(target) = pinned.top_for(row) {
1531 self.scroll_y
1532 .set(target.clamp(0.0, self.max_scroll_y.get()));
1533 }
1534 }
1535 None => imperative::scroll_to_row(
1536 row,
1537 &self.row_metrics,
1538 &self.scroll_y,
1539 &self.max_scroll_y,
1540 ),
1541 }
1542 }
1543
1544 /// Scroll the minimum distance needed to make `row` (a flat visible
1545 /// index) visible, now, against the viewport, row offsets and scroll range
1546 /// of the last layout pass. A no-op before the first layout pass, when the
1547 /// viewport height is not yet known. A [`handle`](Self::handle) defers the
1548 /// computation to the next layout pass instead.
1549 ///
1550 /// With [`pinned_ancestors`](Self::pinned_ancestors), "visible" means
1551 /// below the ancestors that would be pinned over it.
1552 pub fn ensure_row_visible(&self, row: usize) {
1553 Self::reveal_row(
1554 row,
1555 self.pinned_reveal().as_ref(),
1556 &self.row_metrics,
1557 &self.scroll_y,
1558 &self.max_scroll_y,
1559 self.viewport_height.get(),
1560 self.laid_out.get(),
1561 );
1562 }
1563
1564 /// The one reveal every path shares: below the pinned ancestors when
1565 /// there are any, the plain row arithmetic otherwise.
1566 fn reveal_row(
1567 row: usize,
1568 pinned: Option<&pinned::PinnedReveal>,
1569 row_metrics: &SharedRowMetrics,
1570 scroll_y: &Signal<f32>,
1571 max_scroll_y: &Signal<f32>,
1572 viewport_height: f32,
1573 laid_out: bool,
1574 ) {
1575 let Some(pinned) = pinned else {
1576 imperative::ensure_row_visible(
1577 row,
1578 row_metrics,
1579 scroll_y,
1580 max_scroll_y,
1581 viewport_height,
1582 laid_out,
1583 );
1584 return;
1585 };
1586 if !laid_out {
1587 return;
1588 }
1589 let scroll = scroll_y.get();
1590 let new_scroll = pinned.scroll_for(row, scroll, viewport_height, max_scroll_y.get());
1591 if (new_scroll - scroll).abs() > f32::EPSILON {
1592 scroll_y.set(new_scroll);
1593 }
1594 }
1595
1596 /// Scroll the row the keyboard cursor sits on into view when this view
1597 /// takes focus.
1598 ///
1599 /// Only the rows near the viewport are realized, so on a tree taller than
1600 /// the window the cursor row frequently has no widget. Everything that
1601 /// speaks for it then has nothing to speak about: no cell node exists, so
1602 /// `accessibility()` below finds nothing in `cell_map` and nominates no
1603 /// `active_descendant`, and a screen reader taking focus here is told
1604 /// nothing at all. The first arrow press steps *past* that row as well,
1605 /// because the cursor was somewhere the user was never shown.
1606 ///
1607 /// The cursor is read exactly as the shared keyboard handler reads it
1608 /// (`table_view::keyboard::build_key_handler`, `keyboard.rs:134-139`): the
1609 /// focused cell's row, else the first selected row. Anything else would
1610 /// reveal a row the next arrow press does not step from.
1611 ///
1612 /// That row index is a **flat visible** index, not a position in the
1613 /// unflattened tree: a collapsed node's descendants have no index at all
1614 /// here. Checked on both sides of the read. `focused_cell` is clamped to
1615 /// `TreeNavigator::row_count()`, which returns `TreeSource::visible_count()`
1616 /// (`tree_table_view.rs:145-147`), and the keyed selection facade builds
1617 /// its indices by scanning `0..visible_count()` through
1618 /// `SortFilterTreeModel::visible_node_id` (`data_views.rs:545-552`). On the
1619 /// spending side, `RowMetrics` is sized by `place_children` from that same
1620 /// `visible_count()`, so `row_top(i)` is the top of the *i*-th visible row.
1621 ///
1622 /// `ensure_row_visible`, the view's own imperative path, rather than
1623 /// `scroll_to_row`: a row already on screen must not jump under somebody
1624 /// who can see it. It carries the `laid_out` guard too, so a focus that
1625 /// arrives before the first real height is a no-op instead of scrolling
1626 /// against a viewport that was never measured. It is the same
1627 /// `RowMetrics::scroll_for_ensure_visible` arithmetic the keyboard runs on
1628 /// every arrow press; what the keyboard's own wrapper
1629 /// (`table_view/keyboard.rs:672`) adds on top is chasing the row into an
1630 /// *enclosing* scroll area, and that needs an `EventContext`, which an
1631 /// effect does not have. Nothing is lost: the same keyboard or programmatic
1632 /// focus change makes the framework reveal the newly focused widget in
1633 /// every ancestor scroll area itself (`focus_impl.rs:109`,
1634 /// `WidgetTree::scroll_focused_into_view`), so the enclosing viewport is
1635 /// somebody else's job here.
1636 ///
1637 /// The handles are cloned into the effect rather than reaching through
1638 /// `self`, which the closure cannot borrow.
1639 fn reveal_current_row_on_focus(&self, ctx: &mut BuildContext) {
1640 let focused_cell = self.focused_cell.clone();
1641 let selection = self.row_selection.clone();
1642 let row_metrics = self.row_metrics.clone();
1643 let scroll_y = self.scroll_y.clone();
1644 let max_scroll_y = self.max_scroll_y.clone();
1645 let viewport_height = self.viewport_height.clone();
1646 let laid_out = self.laid_out.clone();
1647 let pinned = self.pinned_reveal();
1648
1649 ctx.effect(&self.view_focused, move |focused| {
1650 if !*focused {
1651 return;
1652 }
1653 let Some(row) = focused_cell.get().map(|(row, _col)| row).or_else(|| {
1654 selection
1655 .as_ref()
1656 .and_then(|s| s.selected_indices().first().copied())
1657 }) else {
1658 return;
1659 };
1660 Self::reveal_row(
1661 row,
1662 pinned.as_ref(),
1663 &row_metrics,
1664 &scroll_y,
1665 &max_scroll_y,
1666 viewport_height.get(),
1667 laid_out.get(),
1668 );
1669 });
1670 }
1671
1672 /// Set or remove a single column's user-resized width override.
1673 /// A non-positive `width` removes the entry (the column reverts to
1674 /// its declared width policy).
1675 pub fn set_column_width(&self, col_id: &str, width: f32) {
1676 imperative::set_column_width(&self.column_widths_signal, col_id, width);
1677 }
1678
1679 /// Replace the full width-override map (typically used to restore
1680 /// a persisted layout).
1681 ///
1682 /// Equality-guarded for the same reason as
1683 /// [`TableView::set_column_widths`](crate::TableView::set_column_widths):
1684 /// the documented settings round-trip would otherwise recurse without
1685 /// bound on the first tick of a live resize drag.
1686 pub fn set_column_widths(&self, widths: HashMap<String, f32>) {
1687 imperative::set_column_widths(&self.column_widths_signal, widths);
1688 }
1689
1690 /// Replace the column-order list. Ids not declared on this table
1691 /// are silently dropped on the next layout pass.
1692 pub fn set_column_order(&self, order: Vec<String>) {
1693 imperative::set_if_changed(&self.column_order_signal, order);
1694 }
1695
1696 /// Current column pinning overrides, keyed by column id. Wins over
1697 /// each column's declared [`Column::pinned`].
1698 pub fn column_pinning_signal(&self) -> &Signal<HashMap<String, PinnedSide>> {
1699 &self.column_pinning_signal
1700 }
1701
1702 /// Put a single column on `side`: [`PinnedSide::Leading`] or
1703 /// [`PinnedSide::Trailing`] pins it, [`PinnedSide::None`] unpins it, a
1704 /// column declared pinned ([`Column::pinned`]) included.
1705 ///
1706 /// Writes [`column_pinning_signal`](Self::column_pinning_signal) the way a
1707 /// header drag does: an entry for a column moved off its declared side,
1708 /// and none for a column put back on it.
1709 pub fn set_column_pinning(&self, col_id: &str, side: PinnedSide) {
1710 let declared = self
1711 .columns
1712 .iter()
1713 .find(|c| c.spec.id == col_id)
1714 .map_or(PinnedSide::None, |c| c.spec.pinned);
1715 imperative::set_column_pinning(&self.column_pinning_signal, col_id, side, declared);
1716 }
1717
1718 /// Begin editing the cell `(row, col_id)`. Silently no-ops if `col_id`
1719 /// isn't a currently-displayed column, or if `row` is outside the visible
1720 /// range — an out-of-range target would otherwise strand `editing_cell` on
1721 /// a row nothing can match.
1722 ///
1723 /// Callable **before the view is mounted**, which is the only point at
1724 /// which a consumer can seed a freshly constructed view with an edit
1725 /// target it already holds. `display_indices` is a cache `build()` fills,
1726 /// so a pre-mount call finds it empty; the order is recomputed on demand
1727 /// in that case rather than resolving against nothing and no-opping for a
1728 /// third, undocumented reason.
1729 ///
1730 /// A [`full_width_row`](Self::full_width_row) has no cell of any column
1731 /// to edit, so a row that is one is a no-op too.
1732 pub fn begin_edit(&self, row: usize, col_id: &str) {
1733 if self.full_width_rows().is_some_and(|fw| (fw.at)(row)) {
1734 return;
1735 }
1736 let cached = self.display_indices.borrow();
1737 let recomputed;
1738 let display: &[usize] = if cached.is_empty() {
1739 recomputed = self.display_order();
1740 &recomputed
1741 } else {
1742 &cached
1743 };
1744 if let Some(target) = imperative::resolve_edit_target(
1745 row,
1746 col_id,
1747 &self.columns,
1748 display,
1749 self.source.visible_count(),
1750 ) {
1751 drop(cached);
1752 self.editing_cell.set(Some(target));
1753 }
1754 }
1755
1756 /// Close the active cell editor without committing (the field's `on_blur` still fires).
1757 pub fn end_edit(&self) {
1758 self.editing_cell.set(None);
1759 }
1760
1761 // ── Internals ──────────────────────────────────────────────────────
1762
1763 fn effective_row_height(&self) -> f32 {
1764 self.row_height.unwrap_or(self.style_row_height)
1765 }
1766
1767 /// The full-width rows and the delegate that draws them, or `None` when
1768 /// the view has none. The delegate falls back to the tree column's cell.
1769 fn full_width_rows(&self) -> Option<full_width::FullWidthRows<T>> {
1770 let is_full_width = self.full_width_row.clone()?;
1771 let delegate = match &self.full_width_delegate {
1772 Some(delegate) => delegate.clone(),
1773 None => self
1774 .columns
1775 .get(self.tree_column_decl_index())?
1776 .cell
1777 .clone(),
1778 };
1779 let source = self.source.clone();
1780 Some(full_width::FullWidthRows {
1781 at: Rc::new(move |row| {
1782 let mut hit = false;
1783 (source.read_item_fn)(row, &mut |item| hit = is_full_width(item));
1784 hit
1785 }),
1786 delegate,
1787 })
1788 }
1789
1790 /// The reveal below the pinned ancestors, while the view pins any.
1791 fn pinned_reveal(&self) -> Option<pinned::PinnedReveal> {
1792 pinned::PinnedReveal::new(
1793 self.source.clone(),
1794 self.row_metrics.clone(),
1795 self.pinned_ancestors,
1796 )
1797 }
1798
1799 fn effective_header_height(&self) -> f32 {
1800 if self.show_header {
1801 self.header_height.unwrap_or(self.style_header_height)
1802 } else {
1803 0.0
1804 }
1805 }
1806
1807 fn effective_indent(&self) -> f32 {
1808 self.indent_per_level.unwrap_or(cp::TREE_INDENT_PER_LEVEL)
1809 }
1810
1811 /// Resolve the tree column id to a declaration index. Falls back
1812 /// to column 0 when the configured id isn't found or unset.
1813 fn tree_column_decl_index(&self) -> usize {
1814 if let Some(ref id) = self.tree_column_id {
1815 for (i, col) in self.columns.iter().enumerate() {
1816 if &col.spec.id == id {
1817 return i;
1818 }
1819 }
1820 }
1821 0
1822 }
1823
1824 /// Display order as indices into `self.columns` — see
1825 /// [`layout::display_order`], shared with `TableView`.
1826 fn display_order(&self) -> Vec<usize> {
1827 let (out, boundaries) = layout::display_order(
1828 &self.columns,
1829 &self.column_order_signal.get(),
1830 &self.column_pinning_signal.get(),
1831 );
1832 // Stash the boundaries so paint / place_children / the keyboard
1833 // handler's ensure-column-visible can read them.
1834 *self.pane_boundaries.borrow_mut() = boundaries;
1835 out
1836 }
1837
1838 fn clamp_scroll(&self) {
1839 let max = self.max_scroll_y.get();
1840 let current = self.scroll_y.get();
1841 let clamped = current.clamp(0.0, max);
1842 if (clamped - current).abs() > 0.001 {
1843 self.scroll_y.set(clamped);
1844 }
1845 }
1846
1847 /// Buffered realized range — mirrors `TableView::visible_range`. Used
1848 /// only to nudge the lazy source (`request_window`/`fetch_more`); the
1849 /// pane recomputes its own copy independently for actual row
1850 /// realization.
1851 fn visible_range(&self) -> (usize, usize) {
1852 self.row_metrics.borrow_mut().visible_range(
1853 self.scroll_y.get(),
1854 self.viewport_height.get(),
1855 self.source.visible_count(),
1856 BUFFER_ROWS,
1857 )
1858 }
1859}
1860
1861impl<T: 'static> TreeTableView<T> {
1862 /// A handle that scrolls this view once it is mounted.
1863 ///
1864 /// Take it while describing the tree, before the view is handed to
1865 /// `ctx.add(...)`; it stays attached to the view across rebuilds. See
1866 /// [`TreeTableViewHandle`].
1867 pub fn handle(&self) -> TreeTableViewHandle {
1868 TreeTableViewHandle {
1869 reveal: self.reveal.clone(),
1870 }
1871 }
1872}
1873
1874/// A handle on a mounted [`TreeTableView`], to scroll it to a row from
1875/// outside the widget — a view-model revealing the node a search found, say.
1876///
1877/// Rows are addressed by their **flat visible index**, as by
1878/// [`TreeTableView::scroll_to_row`]: the position of the row among the rows
1879/// currently shown, counting the children of expanded nodes and skipping those
1880/// of collapsed ones. With [`pinned_ancestors`](TreeTableView::pinned_ancestors)
1881/// the row is revealed below the ancestors pinned over it.
1882///
1883/// Cheap to clone, valid before the view is mounted, and it survives the
1884/// view's rebuilds. Its methods never compute an offset when they are called:
1885/// they record the request and ask for a layout pass, and the view applies it
1886/// there, after row measurement and scroll anchoring have settled. So a
1887/// structural change made in the same turn (a node expanded above the target,
1888/// say) is accounted for, a request made before mount is applied at the first
1889/// layout, and a later request replaces an earlier pending one. A request on a
1890/// view with no rows is dropped; a row past the end reveals the last row.
1891///
1892/// ```rust
1893/// # use teksilo_data::TreeModel;
1894/// # use teksilo_widgets::TreeTableView;
1895/// # let model: TreeModel<String> = TreeModel::new();
1896/// let view = TreeTableView::new(model);
1897/// let handle = view.handle();
1898/// // … add the columns, `ctx.add(view)`; later, after expanding the row's
1899/// // ancestors:
1900/// handle.ensure_row_visible(42);
1901/// ```
1902#[derive(Clone)]
1903pub struct TreeTableViewHandle {
1904 reveal: SharedRowReveal,
1905}
1906
1907impl std::fmt::Debug for TreeTableViewHandle {
1908 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1909 f.debug_struct("TreeTableViewHandle")
1910 .field("pending", &self.reveal.pending())
1911 .finish()
1912 }
1913}
1914
1915impl TreeTableViewHandle {
1916 /// Scroll so that `row` (a flat visible index) is aligned to the top of
1917 /// the viewport, as far as the scroll range allows, at the next layout
1918 /// pass.
1919 pub fn scroll_to_row(&self, row: usize) {
1920 self.reveal.request(row, RevealAlign::Top);
1921 }
1922
1923 /// Scroll the minimum distance that makes `row` (a flat visible index)
1924 /// fully visible, at the next layout pass; no scroll when it already is.
1925 pub fn ensure_row_visible(&self, row: usize) {
1926 self.reveal.request(row, RevealAlign::Nearest);
1927 }
1928}
1929
1930impl<T: 'static> std::fmt::Debug for TreeTableView<T> {
1931 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1932 f.debug_struct("TreeTableView")
1933 .field("rows", &self.source.visible_count())
1934 .field("columns", &self.columns.len())
1935 .field("tree_column", &self.tree_column_id)
1936 .field("scroll_bar_style", &self.scroll_bar_style)
1937 .finish()
1938 }
1939}
1940
1941#[cfg(test)]
1942mod tests;
1943
1944#[cfg(test)]
1945mod handle_tests;