fret_ui_kit/declarative/table.rs
1use fret_core::{Color, Corners, CursorIcon, Edges, KeyCode, Px, SemanticsRole};
2use fret_runtime::{CommandId, Effect, Model, ModelStore, TimerToken};
3use fret_ui::action::{PressablePointerDownResult, PressablePointerUpResult, UiActionHostExt};
4use fret_ui::element::{
5 AnyElement, ContainerProps, HoverRegionProps, LayoutStyle, Length, Overflow,
6 PointerRegionProps, PressableA11y, PressableProps, RingPlacement, RingStyle, ScrollAxis,
7 ScrollProps, SemanticsDecoration, SemanticsProps, SpacerProps, VirtualListOptions,
8};
9use fret_ui::scroll::{ScrollHandle, VirtualListScrollHandle};
10use fret_ui::{
11 ElementContext, ElementContextAccess, GlobalElementId, Theme, UiHost, scroll::ScrollStrategy,
12};
13
14use fret_core::time::Instant;
15use std::cell::{Cell, RefCell};
16use std::rc::Rc;
17use std::sync::Arc;
18use std::time::Duration;
19
20type TypeaheadLabelAt<TData> = dyn Fn(&TData, usize) -> Arc<str> + Send + Sync;
21type CopyTextAtFn = dyn Fn(&ModelStore, usize) -> Option<String> + Send + Sync;
22type RowKeyAt<TData> = dyn Fn(&TData, usize) -> RowKey;
23type HeaderLabelAt<TData> = dyn Fn(&ColumnDef<TData>) -> Arc<str>;
24type HeaderAccessoryAt<H, TData> =
25 dyn for<'a> Fn(&mut dyn ElementContextAccess<'a, H>, &ColumnDef<TData>) -> AnyElement;
26type CellAt<H, TData> =
27 dyn for<'a> Fn(&mut dyn ElementContextAccess<'a, H>, &ColumnDef<TData>, &TData) -> AnyElement;
28type GroupAggsU64 = std::collections::HashMap<RowKey, Arc<[(ColumnId, u64)]>>;
29type GroupAggsAny = std::collections::HashMap<RowKey, Arc<[(ColumnId, TanStackValue)]>>;
30type GroupAggsText = std::collections::HashMap<RowKey, Arc<[(ColumnId, Arc<str>)]>>;
31type SorterFn<TData> = dyn Fn(&TData, &TData) -> std::cmp::Ordering;
32type SorterSpec<TData> = (SortSpec, Arc<SorterFn<TData>>);
33
34/// Narrow interop bridge for table surfaces that still store view state in `Model<TableState>`.
35///
36/// This stays intentionally table-specific so `LocalState<TableState>` can participate in the
37/// public table/data-table authoring lane without widening into a crate-wide `IntoModel<T>` story.
38pub trait IntoTableStateModel {
39 fn into_table_state_model(self) -> Model<TableState>;
40}
41
42impl IntoTableStateModel for Model<TableState> {
43 fn into_table_state_model(self) -> Model<TableState> {
44 self
45 }
46}
47
48impl IntoTableStateModel for &Model<TableState> {
49 fn into_table_state_model(self) -> Model<TableState> {
50 self.clone()
51 }
52}
53
54use crate::declarative::action_hooks::ActionHooksExt;
55use crate::declarative::collection_semantics::CollectionSemanticsExt as _;
56use crate::declarative::model_watch::ModelWatchExt as _;
57use crate::ui;
58use crate::{IntoUiElement, LayoutRefinement, MetricRef, Size, Space, collect_children};
59
60use crate::headless::table::{
61 Aggregation, ColumnDef, ColumnId, ColumnResizeDirection, ColumnResizeMode, ExpandingState,
62 FilteringFnSpec, FlatRowOrderCache, FlatRowOrderDeps, GroupedColumnMode, GroupedRowKind,
63 PaginationBounds, PaginationState, Row, RowId, RowKey, SortSpec, SortToggleColumn, Table,
64 TableOptions, TableState, TanStackValue, begin_column_resize, compute_grouped_u64_aggregations,
65 drag_column_resize, end_column_resize, is_row_expanded, is_row_selected, is_some_rows_pinned,
66 pagination_bounds, sort_grouped_row_indices_in_place, toggle_sorting_state_handler_tanstack,
67};
68use crate::headless::typeahead::{TypeaheadBuffer, match_prefix_arc_str};
69
70const TABLE_TYPEAHEAD_TIMEOUT: Duration = Duration::from_millis(750);
71
72fn resolve_table_colors(theme: &Theme) -> (Color, Color, Color, Color, Color) {
73 let table_bg = theme
74 .color_by_key("table.background")
75 .or_else(|| theme.color_by_key("list.background"))
76 .or_else(|| theme.color_by_key("card"))
77 .unwrap_or_else(|| theme.color_token("card"));
78 let border = theme
79 .color_by_key("table.border")
80 .or_else(|| theme.color_by_key("border"))
81 .or_else(|| theme.color_by_key("list.border"))
82 .unwrap_or_else(|| theme.color_token("border"));
83 let header_bg = theme
84 .color_by_key("table.header.background")
85 .or_else(|| theme.color_by_key("muted"))
86 .unwrap_or(table_bg);
87 let row_hover = theme
88 .color_by_key("table.row.hover")
89 .or_else(|| theme.color_by_key("list.hover.background"))
90 .or_else(|| theme.color_by_key("list.row.hover"))
91 .or_else(|| theme.color_by_key("accent"))
92 .unwrap_or_else(|| theme.color_token("accent"));
93 let row_active = theme
94 .color_by_key("table.row.active")
95 .or_else(|| theme.color_by_key("list.active.background"))
96 .or_else(|| theme.color_by_key("list.row.active"))
97 .or_else(|| theme.color_by_key("accent"))
98 .unwrap_or_else(|| theme.color_token("accent"));
99 (table_bg, border, header_bg, row_hover, row_active)
100}
101
102fn emphasize_border(border: Color, min_alpha: f32) -> Color {
103 Color {
104 a: border.a.max(min_alpha),
105 ..border
106 }
107}
108
109fn resolve_row_height(theme: &Theme, size: Size) -> Px {
110 let base = theme
111 .metric_by_key("component.table.row_height")
112 .or_else(|| theme.metric_by_key("component.list.row_height"))
113 .unwrap_or_else(|| size.list_row_h(theme));
114 Px(base.0.max(0.0))
115}
116
117fn resolve_cell_padding_x(theme: &Theme) -> Px {
118 MetricRef::space(Space::N2p5).resolve(theme)
119}
120
121fn resolve_cell_padding_y(theme: &Theme) -> Px {
122 MetricRef::space(Space::N1p5).resolve(theme)
123}
124
125fn sort_for_column(sorting: &[SortSpec], id: &ColumnId) -> Option<bool> {
126 sorting
127 .iter()
128 .find(|s| s.column.as_ref() == id.as_ref())
129 .map(|s| s.desc)
130}
131
132#[cfg(test)]
133fn next_sort_for_column(current: Option<bool>) -> Option<bool> {
134 match current {
135 None => Some(false),
136 Some(false) => Some(true),
137 Some(true) => None,
138 }
139}
140
141#[cfg(test)]
142fn apply_single_sort_toggle(state: &mut TableState, col_id: &ColumnId) {
143 let current = sort_for_column(&state.sorting, col_id);
144 let next = next_sort_for_column(current);
145 state.sorting.clear();
146 if let Some(desc) = next {
147 state.sorting.push(SortSpec {
148 column: col_id.clone(),
149 desc,
150 });
151 }
152 state.pagination.page_index = 0;
153}
154
155fn with_table_view_column_constraints<TData>(
156 mut col: ColumnDef<TData>,
157 props: &TableViewProps,
158) -> ColumnDef<TData> {
159 let min_w = col.min_size.max(props.min_column_width.0).max(0.0);
160 col.min_size = min_w;
161 col.max_size = col.max_size.max(min_w);
162 if !col.columns.is_empty() {
163 col.columns = col
164 .columns
165 .into_iter()
166 .map(|c| with_table_view_column_constraints(c, props))
167 .collect();
168 }
169 col
170}
171
172fn retained_table_row_fill_layout() -> LayoutStyle {
173 LayoutStyle {
174 size: fret_ui::element::SizeStyle {
175 width: Length::Fill,
176 ..Default::default()
177 },
178 flex: fret_ui::element::FlexItemStyle {
179 grow: 1.0,
180 shrink: 1.0,
181 basis: Length::Px(Px(0.0)),
182 ..Default::default()
183 },
184 ..Default::default()
185 }
186}
187
188fn table_scroll_fill_layout() -> LayoutStyle {
189 let mut layout = LayoutStyle::default();
190 layout.size.width = Length::Fill;
191 layout.size.height = Length::Fill;
192 layout.flex.grow = 1.0;
193 layout.flex.shrink = 1.0;
194 layout.flex.basis = Length::Px(Px(0.0));
195 layout
196}
197
198fn table_clip_fill_layout() -> LayoutStyle {
199 let mut layout = table_scroll_fill_layout();
200 layout.overflow = Overflow::Clip;
201 layout
202}
203
204fn table_fixed_column_layout(col_w: Px) -> LayoutStyle {
205 LayoutStyle {
206 size: fret_ui::element::SizeStyle {
207 width: Length::Px(col_w),
208 min_width: Some(Length::Px(col_w)),
209 max_width: Some(Length::Px(col_w)),
210 ..Default::default()
211 },
212 flex: fret_ui::element::FlexItemStyle {
213 shrink: 0.0,
214 ..Default::default()
215 },
216 ..Default::default()
217 }
218}
219
220fn table_fixed_column_fill_layout(col_w: Px) -> LayoutStyle {
221 let mut layout = table_fixed_column_layout(col_w);
222 layout.size.height = Length::Fill;
223 layout
224}
225
226fn table_fixed_column_clip_fill_layout(col_w: Px) -> LayoutStyle {
227 let mut layout = table_fixed_column_fill_layout(col_w);
228 layout.overflow = Overflow::Clip;
229 layout
230}
231
232fn table_wrap_horizontal_scroll<H: UiHost>(
233 cx: &mut ElementContext<'_, H>,
234 scroll_handle: Option<ScrollHandle>,
235 row: AnyElement,
236) -> AnyElement {
237 if let Some(scroll_handle) = scroll_handle {
238 cx.scroll(
239 ScrollProps {
240 axis: ScrollAxis::X,
241 scroll_handle: Some(scroll_handle),
242 layout: table_scroll_fill_layout(),
243 ..Default::default()
244 },
245 |_| vec![row],
246 )
247 } else {
248 row
249 }
250}
251
252fn take_single_root_test_id(children: &mut [AnyElement]) -> Option<Arc<str>> {
253 let mut found: Option<(usize, Arc<str>)> = None;
254
255 for (idx, child) in children.iter().enumerate() {
256 let Some(test_id) = child
257 .semantics_decoration
258 .as_ref()
259 .and_then(|decoration| decoration.test_id.as_ref())
260 .cloned()
261 else {
262 continue;
263 };
264
265 if found.is_some() {
266 return None;
267 }
268
269 found = Some((idx, test_id));
270 }
271
272 let (idx, test_id) = found?;
273 if let Some(decoration) = children[idx].semantics_decoration.as_mut() {
274 decoration.test_id = None;
275 }
276
277 Some(test_id)
278}
279
280fn table_wrapper_test_id(
281 children: &mut [AnyElement],
282 explicit: Option<Arc<str>>,
283) -> Option<Arc<str>> {
284 explicit.or_else(|| take_single_root_test_id(children))
285}
286
287#[allow(clippy::too_many_arguments)]
288fn retained_table_render_row_visuals<H: UiHost + 'static, TData: 'static>(
289 cx: &mut ElementContext<'_, H>,
290 data: Arc<[TData]>,
291 data_index: usize,
292 row_key: RowKey,
293 bg: Option<Color>,
294 props: TableViewProps,
295 border: Color,
296 cell_px: Px,
297 cell_py: Px,
298 key_handler: fret_ui::action::OnKeyDown,
299 columns: Arc<[ColumnDef<TData>]>,
300 col_widths: Arc<[Px]>,
301 cell_at: Arc<CellAt<H, TData>>,
302 row_cell_test_id_prefix: Option<Arc<str>>,
303 left_col_indices: Arc<[usize]>,
304 center_col_indices: Arc<[usize]>,
305 right_col_indices: Arc<[usize]>,
306 scroll_x: ScrollHandle,
307) -> AnyElement {
308 cx.key_on_key_down_for(cx.root_id(), key_handler);
309
310 cx.container(
311 ContainerProps {
312 background: bg,
313 layout: retained_table_row_fill_layout(),
314 ..Default::default()
315 },
316 move |cx| {
317 let render_row_group =
318 |cx: &mut ElementContext<'_, H>,
319 col_indices: &[usize],
320 scroll_x_for_group: Option<ScrollHandle>| {
321 let columns = columns.clone();
322 let col_widths = col_widths.clone();
323 let cell_at = cell_at.clone();
324 let row_cell_test_id_prefix = row_cell_test_id_prefix.clone();
325 let data = data.clone();
326 let props = props.clone();
327
328 let row = ui::h_row(move |cx| {
329 let original = &data[data_index];
330
331 col_indices
332 .iter()
333 .map(|col_idx| {
334 let col = &columns[*col_idx];
335 let col_w = col_widths[*col_idx];
336 let cell = (cell_at)(cx, col, original);
337
338 let cell_test_id = row_cell_test_id_prefix.as_ref().map(|prefix| {
339 Arc::<str>::from(format!(
340 "{prefix}{row}-cell-{col}",
341 row = row_key.0,
342 col = col.id.as_ref()
343 ))
344 });
345
346 let cell = cx.container(
347 ContainerProps {
348 border: if props.optimize_grid_lines {
349 Edges::default()
350 } else {
351 Edges {
352 right: Px(1.0),
353 ..Default::default()
354 }
355 },
356 border_color: if props.optimize_grid_lines {
357 None
358 } else {
359 Some(border)
360 },
361 padding: Edges::symmetric(cell_px, cell_py).into(),
362 layout: table_fixed_column_layout(col_w),
363 ..Default::default()
364 },
365 move |_cx| vec![cell],
366 );
367
368 if let Some(test_id) = cell_test_id {
369 cx.semantics(
370 SemanticsProps {
371 test_id: Some(test_id),
372 ..Default::default()
373 },
374 move |_cx| vec![cell],
375 )
376 } else {
377 cell
378 }
379 })
380 .collect::<Vec<_>>()
381 })
382 .gap(Space::N0)
383 .justify_start()
384 .items_center()
385 .into_element(cx);
386
387 table_wrap_horizontal_scroll(cx, scroll_x_for_group, row)
388 };
389
390 let left = render_row_group(cx, left_col_indices.as_ref(), None);
391 let center = render_row_group(cx, center_col_indices.as_ref(), Some(scroll_x.clone()));
392 let right = render_row_group(cx, right_col_indices.as_ref(), None);
393
394 vec![
395 ui::h_row(|_cx| [left, center, right])
396 .gap(Space::N0)
397 .justify_start()
398 .items_stretch()
399 .layout(LayoutRefinement::default().w_full())
400 .into_element(cx),
401 ]
402 },
403 )
404}
405
406#[derive(Debug, Clone)]
407pub struct TableViewProps {
408 pub size: Size,
409 pub row_height: Option<Px>,
410 /// Optional fixed header height (defaults to `row_height` when unset).
411 ///
412 /// This enables shadcn-style tables where the header row height differs from body row height.
413 pub header_height: Option<Px>,
414 /// Controls whether the virtualized body rows are treated as fixed-height or measured.
415 ///
416 /// - `Fixed` (default): fast path; row containers are forced to `row_height` and the virtualizer
417 /// skips per-row measurement work.
418 /// - `Measured`: enables variable-height rows (e.g. wrapping Markdown) by letting the runtime
419 /// measure visible rows and write sizes back into the virtualizer.
420 pub row_measure_mode: TableRowMeasureMode,
421 pub overscan: usize,
422 /// Optional retained-subtree budget for overscan window shifts (retained host path).
423 ///
424 /// When `None`, the default heuristic is `overscan * 2`.
425 ///
426 /// Larger values reduce remount/layout churn when the window oscillates across boundaries
427 /// (e.g. scroll bounce patterns), at the cost of retaining more offscreen subtrees.
428 pub keep_alive: Option<usize>,
429 pub default_column_width: Px,
430 pub min_column_width: Px,
431 /// When `true`, clicking a sortable header updates `TableState.sorting`.
432 ///
433 /// This is a UI-side interaction toggle; sorting math still lives in the headless engine.
434 pub enable_sorting: bool,
435 pub enable_column_resizing: bool,
436 pub column_resize_mode: ColumnResizeMode,
437 pub column_resize_direction: ColumnResizeDirection,
438 pub enable_column_grouping: bool,
439 pub grouped_column_mode: GroupedColumnMode,
440 pub enable_row_selection: bool,
441 pub single_row_selection: bool,
442 /// When `true` (default), pointer-activating a row toggles its selection state.
443 ///
444 /// Set this to `false` for shadcn-style recipes where selection is driven by an explicit
445 /// checkbox column (and row clicks should not toggle selection).
446 pub pointer_row_selection: bool,
447 /// Pointer selection policy (when `pointer_row_selection` is enabled).
448 pub pointer_row_selection_policy: PointerRowSelectionPolicy,
449 /// When `false`, pinned rows are only rendered if they are part of the current
450 /// filtered/sorted/paginated row model (TanStack `keepPinnedRows=false`).
451 ///
452 /// When `true` (default), pinned rows can remain visible even when they are outside the
453 /// current row model (TanStack default).
454 pub keep_pinned_rows: bool,
455 /// When `false`, the table does not render an outer border/radius frame.
456 ///
457 /// This is useful when embedding the table inside a higher-level component that owns the
458 /// surrounding chrome (e.g. a shadcn recipe with its own border + radius).
459 pub draw_frame: bool,
460 /// When enabled, paints table cell backgrounds/borders in a separate layer from cell content.
461 ///
462 /// This is a targeted performance knob intended to reduce renderer pipeline switches for
463 /// text-heavy tables by avoiding per-cell interleaving of quads and text draws.
464 ///
465 /// Note: this may increase UI tree complexity because it introduces an overlay layer per row.
466 pub optimize_paint_order: bool,
467 /// When enabled, draws only coarse column-group separators instead of per-cell vertical grid lines.
468 ///
469 /// This reduces quad count and renderer state churn for wide tables, but it also changes the visual
470 /// semantics of the grid (column-level separators are removed).
471 ///
472 /// Limitations / caveats:
473 ///
474 /// - Default: `false`.
475 /// - This intentionally trades per-column dividers for only `{left|center|right}` group dividers.
476 /// - It is not a stable styling contract. Prefer keeping it disabled unless profiling shows that
477 /// per-cell dividers dominate quad count and pipeline switches for your workload.
478 /// - This may be replaced by a formal style option (e.g. `TableGridLines`) or removed entirely
479 /// once a better default grid strategy exists.
480 pub optimize_grid_lines: bool,
481 /// Grouped-mode row pinning display policy.
482 ///
483 /// TanStack `table-core` exposes pinning via `getTopRows/getCenterRows/getBottomRows`, and the
484 /// most common UI recipe is to render pinned rows in dedicated top/bottom bands (removing them
485 /// from the paged center rows). `PromotePinnedRows` matches that TanStack-typical behavior and
486 /// is the default.
487 pub grouped_row_pinning_policy: GroupedRowPinningPolicy,
488}
489
490#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
491pub enum TableRowMeasureMode {
492 /// Fixed-height body rows (fast path).
493 #[default]
494 Fixed,
495 /// Variable-height body rows (measurement + write-back).
496 Measured,
497}
498
499#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
500pub enum PointerRowSelectionPolicy {
501 /// Legacy behavior: a row click toggles membership in `row_selection`.
502 #[default]
503 Toggle,
504 /// List-like behavior:
505 /// - no modifiers: exclusive selection (clears then selects)
506 /// - Ctrl/Meta: additive toggle
507 /// - Shift: range selection from anchor
508 ListLike,
509}
510
511#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
512pub enum GroupedRowPinningPolicy {
513 /// Promote pinned rows into top/bottom bands and remove duplicates from the paged center rows.
514 #[default]
515 PromotePinnedRows,
516 /// Keep pinned leaf rows inside their grouped hierarchy and keep the paged center rows
517 /// unchanged (no promotion).
518 PreserveHierarchy,
519}
520
521impl Default for TableViewProps {
522 fn default() -> Self {
523 Self {
524 size: Size::Medium,
525 row_height: None,
526 header_height: None,
527 row_measure_mode: TableRowMeasureMode::Fixed,
528 overscan: 2,
529 keep_alive: None,
530 default_column_width: Px(160.0),
531 min_column_width: Px(40.0),
532 enable_sorting: true,
533 enable_column_resizing: true,
534 column_resize_mode: ColumnResizeMode::OnEnd,
535 column_resize_direction: ColumnResizeDirection::Ltr,
536 enable_column_grouping: true,
537 grouped_column_mode: GroupedColumnMode::Reorder,
538 enable_row_selection: true,
539 single_row_selection: true,
540 pointer_row_selection: true,
541 pointer_row_selection_policy: PointerRowSelectionPolicy::Toggle,
542 keep_pinned_rows: true,
543 draw_frame: true,
544 optimize_paint_order: false,
545 optimize_grid_lines: false,
546 grouped_row_pinning_policy: GroupedRowPinningPolicy::PromotePinnedRows,
547 }
548 }
549}
550
551#[derive(Debug, Clone, Default, PartialEq, Eq)]
552pub struct TableViewOutput {
553 /// Total row count after filters (and grouping expansion), before pagination is applied.
554 pub filtered_row_count: usize,
555 pub pagination: PaginationBounds,
556}
557
558/// Debug/test-only anchors for virtualized table harnesses.
559///
560/// These ids are intended for scripted diagnostics and geometry assertions:
561/// - `header_row_test_id` targets the fixed header viewport row.
562/// - `header_cell_test_id_prefix` targets table-owned header cell layout wrappers.
563/// - `row_test_id_prefix` targets table-owned body row / cell layout wrappers.
564#[derive(Debug, Clone, Default)]
565pub struct TableDebugIds {
566 pub header_row_test_id: Option<Arc<str>>,
567 pub header_cell_test_id_prefix: Option<Arc<str>>,
568 pub row_test_id_prefix: Option<Arc<str>>,
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 const SOURCE: &str = include_str!("table.rs");
575 use fret_app::App;
576 use fret_core::{
577 AppWindowId, Event, Modifiers, MouseButton, PathCommand, PointerEvent, PointerId,
578 PointerType, SvgId, SvgService, TextBlobId, TextConstraints, TextInput, TextMetrics,
579 TextService,
580 };
581 use fret_core::{PathConstraints, PathId, PathMetrics, PathService, PathStyle};
582 use fret_core::{Point, Px, Rect, TextWrap};
583 use fret_ui::ThemeConfig;
584 use fret_ui::{Theme, UiTree, VirtualListScrollHandle};
585
586 #[test]
587 fn table_surfaces_keep_a_narrow_table_state_bridge() {
588 assert!(
589 SOURCE.contains("pub trait IntoTableStateModel {"),
590 "table surfaces should keep a dedicated TableState bridge instead of widening into a generic model conversion story"
591 );
592 assert!(
593 SOURCE.contains("pub fn table_virtualized<H: UiHost, TData, IHeader, TH, ICell, TC>(")
594 && SOURCE.contains("state: impl IntoTableStateModel,"),
595 "table_virtualized should accept the dedicated table-state bridge"
596 );
597 assert!(
598 SOURCE.contains("pub fn table_virtualized_retained_v0<H: UiHost + 'static, TData>("),
599 "retained table surface should accept the dedicated table-state bridge"
600 );
601 assert!(
602 !SOURCE.contains(
603 "pub fn table_virtualized<H: UiHost, TData, IHeader, TH, ICell, TC>(\n cx: &mut ElementContext<'_, H>,\n data: &[TData],\n columns: &[ColumnDef<TData>],\n state: Model<TableState>,"
604 ),
605 "table_virtualized should not regress to a raw Model<TableState>-only signature"
606 );
607 }
608
609 #[test]
610 fn retained_table_callbacks_prefer_explicit_context_access_capability() {
611 assert!(
612 SOURCE.contains("dyn for<'a> Fn(&mut dyn ElementContextAccess<'a, H>, &ColumnDef<TData>) -> AnyElement;")
613 || SOURCE.contains(
614 "dyn for<'a> Fn(\n &mut dyn ElementContextAccess<'a, H>,\n &ColumnDef<TData>,\n ) -> AnyElement;"
615 ),
616 "retained table header accessories should accept explicit context access capability"
617 );
618 assert!(
619 SOURCE.contains("dyn for<'a> Fn(&mut dyn ElementContextAccess<'a, H>, &ColumnDef<TData>, &TData) -> AnyElement;")
620 || SOURCE.contains(
621 "dyn for<'a> Fn(\n &mut dyn ElementContextAccess<'a, H>,\n &ColumnDef<TData>,\n &TData,\n ) -> AnyElement;"
622 ),
623 "retained table cell renderers should accept explicit context access capability"
624 );
625 }
626
627 #[test]
628 fn table_debug_ids_expose_explicit_header_row_anchor() {
629 assert!(
630 SOURCE.contains("pub struct TableDebugIds {"),
631 "table harnesses should use a structured debug-id surface"
632 );
633 assert!(
634 SOURCE.contains("pub header_row_test_id: Option<Arc<str>>,"),
635 "table debug ids should expose an explicit header-row anchor"
636 );
637 assert!(
638 SOURCE.contains("debug_ids: TableDebugIds,"),
639 "table surfaces should accept a shared structured debug-id contract"
640 );
641 }
642
643 #[test]
644 fn table_virtualized_hoists_single_root_renderer_test_ids_to_layout_anchors() {
645 assert!(
646 SOURCE.contains(
647 "fn take_single_root_test_id(children: &mut [AnyElement]) -> Option<Arc<str>> {"
648 ),
649 "table_virtualized should keep the single-root test-id hoist helper close to the table surface"
650 );
651 assert!(
652 SOURCE.contains(
653 "fn table_wrapper_test_id(\n children: &mut [AnyElement],\n explicit: Option<Arc<str>>,\n) -> Option<Arc<str>> {"
654 ),
655 "table_virtualized should resolve explicit debug ids before falling back to renderer-root hoists"
656 );
657 assert!(
658 SOURCE.match_indices("table_wrapper_test_id(").count() >= 4,
659 "table_virtualized should hoist marked header/cell renderer roots onto stable layout anchors"
660 );
661 }
662
663 #[test]
664 fn table_virtualized_retained_accepts_capability_first_cell_renderer() {
665 let window = AppWindowId::default();
666 let mut app = App::new();
667 let mut ui: UiTree<App> = UiTree::new();
668 ui.set_window(window);
669 ui.set_debug_enabled(true);
670
671 Theme::with_global_mut(&mut app, |theme| {
672 theme.apply_config(&ThemeConfig {
673 name: "Test".to_string(),
674 ..ThemeConfig::default()
675 });
676 });
677
678 let mut state_value = TableState::default();
679 state_value.pagination.page_size = 1;
680 let state = app.models_mut().insert(state_value);
681
682 let data: Arc<[u32]> = Arc::from(vec![0u32]);
683 let columns: Arc<[ColumnDef<u32>]> = Arc::from(vec![{
684 let mut col = ColumnDef::new("name");
685 col.size = 220.0;
686 col
687 }]);
688 let scroll = VirtualListScrollHandle::new();
689
690 let bounds = Rect::new(
691 Point::new(Px(0.0), Px(0.0)),
692 fret_core::Size::new(Px(320.0), Px(240.0)),
693 );
694 let mut services = FakeServices;
695
696 let render = |ui: &mut UiTree<App>,
697 app: &mut App,
698 services: &mut FakeServices|
699 -> fret_core::NodeId {
700 fret_ui::declarative::render_root(ui, app, services, window, bounds, "test", |cx| {
701 vec![table_virtualized_retained_v0(
702 cx,
703 data.clone(),
704 columns.clone(),
705 state.clone(),
706 &scroll,
707 0,
708 Arc::new(|_row: &u32, index: usize| RowKey::from_index(index)),
709 None,
710 TableViewProps::default(),
711 Arc::new(|col: &ColumnDef<u32>| Arc::from(col.id.as_ref())),
712 None,
713 Arc::new(retained_table_capability_test_cell),
714 TableDebugIds {
715 header_cell_test_id_prefix: Some(Arc::<str>::from(
716 "table-retained-capability-header-",
717 )),
718 row_test_id_prefix: Some(Arc::<str>::from(
719 "table-retained-capability-row-",
720 )),
721 ..Default::default()
722 },
723 )]
724 })
725 };
726
727 for _ in 0..2 {
728 let root = render(&mut ui, &mut app, &mut services);
729 ui.set_root(root);
730 ui.request_semantics_snapshot();
731 ui.layout_all(&mut app, &mut services, bounds, 1.0);
732 let mut scene = fret_core::Scene::default();
733 ui.paint_all(&mut app, &mut services, bounds, &mut scene, 1.0);
734 }
735
736 let snap = ui
737 .semantics_snapshot()
738 .expect("expected semantics snapshot after retained table render");
739
740 assert!(
741 snap.nodes.iter().any(
742 |node| node.test_id.as_deref() == Some("table-retained-capability-cell-name-0")
743 ),
744 "expected retained table capability-first cell renderer to contribute its semantics marker"
745 );
746 }
747
748 #[test]
749 fn single_sort_toggle_cycles_and_resets_page_index() {
750 let id: ColumnId = Arc::from("col");
751 let mut state = TableState::default();
752 state.pagination.page_index = 3;
753 assert!(state.sorting.is_empty());
754
755 apply_single_sort_toggle(&mut state, &id);
756 assert_eq!(state.pagination.page_index, 0);
757 assert_eq!(state.sorting.len(), 1);
758 assert_eq!(state.sorting[0].column.as_ref(), id.as_ref());
759 assert!(!state.sorting[0].desc);
760
761 state.pagination.page_index = 2;
762 apply_single_sort_toggle(&mut state, &id);
763 assert_eq!(state.pagination.page_index, 0);
764 assert_eq!(state.sorting.len(), 1);
765 assert!(state.sorting[0].desc);
766
767 state.pagination.page_index = 1;
768 apply_single_sort_toggle(&mut state, &id);
769 assert_eq!(state.pagination.page_index, 0);
770 assert!(state.sorting.is_empty());
771 }
772
773 #[test]
774 fn collect_leaf_keys_skips_group_rows() {
775 let meta = vec![
776 TableNavRowMeta {
777 row_key: RowKey(1),
778 kind: TableNavRowKind::Leaf,
779 data_index: Some(0),
780 label: Arc::from("Alpha"),
781 },
782 TableNavRowMeta {
783 row_key: RowKey(2),
784 kind: TableNavRowKind::Group,
785 data_index: None,
786 label: Arc::from("Group"),
787 },
788 TableNavRowMeta {
789 row_key: RowKey(3),
790 kind: TableNavRowKind::Leaf,
791 data_index: Some(1),
792 label: Arc::from("Beta"),
793 },
794 ];
795
796 assert_eq!(table_collect_leaf_keys(&meta), vec![RowKey(1), RowKey(3)]);
797 assert_eq!(
798 table_collect_leaf_keys_in_range(&meta, 0, 2),
799 vec![RowKey(1), RowKey(3)]
800 );
801 assert_eq!(
802 table_collect_leaf_keys_in_range(&meta, 1, 1),
803 Vec::<RowKey>::new()
804 );
805 assert_eq!(
806 table_collect_leaf_keys_in_range(&meta, 99, 100),
807 vec![RowKey(3)]
808 );
809 }
810
811 #[test]
812 fn apply_row_pinning_to_paged_rows_surfaces_pinned_outside_page_and_dedupes() {
813 let visible_all = vec![
814 DisplayRow::Leaf {
815 data_index: 0,
816 row_key: RowKey(1),
817 depth: 0,
818 },
819 DisplayRow::Leaf {
820 data_index: 1,
821 row_key: RowKey(2),
822 depth: 0,
823 },
824 DisplayRow::Leaf {
825 data_index: 2,
826 row_key: RowKey(3),
827 depth: 0,
828 },
829 ];
830
831 let page_rows = vec![visible_all[1].clone(), visible_all[2].clone()];
832
833 let row_pinning = crate::headless::table::RowPinningState {
834 top: vec![RowKey(1)],
835 bottom: vec![RowKey(3)],
836 };
837
838 let out = apply_row_pinning_to_paged_rows(&visible_all, &page_rows, &row_pinning);
839 let keys = out.into_iter().map(|r| r.row_key()).collect::<Vec<_>>();
840 assert_eq!(keys, vec![RowKey(1), RowKey(2), RowKey(3)]);
841 }
842
843 #[test]
844 fn grouped_row_pinning_policy_preserve_hierarchy_keeps_page_rows_center_unchanged() {
845 let visible_all = vec![
846 DisplayRow::Leaf {
847 data_index: 0,
848 row_key: RowKey(1),
849 depth: 0,
850 },
851 DisplayRow::Leaf {
852 data_index: 1,
853 row_key: RowKey(2),
854 depth: 0,
855 },
856 DisplayRow::Leaf {
857 data_index: 2,
858 row_key: RowKey(3),
859 depth: 0,
860 },
861 ];
862
863 let page_rows_center = vec![visible_all[1].clone(), visible_all[2].clone()];
864 let row_pinning = crate::headless::table::RowPinningState {
865 top: vec![RowKey(1)],
866 bottom: vec![RowKey(3)],
867 };
868
869 let out = apply_grouped_row_pinning_policy(
870 &visible_all,
871 &page_rows_center,
872 &row_pinning,
873 GroupedRowPinningPolicy::PreserveHierarchy,
874 );
875 let keys = out.into_iter().map(|r| r.row_key()).collect::<Vec<_>>();
876 assert_eq!(keys, vec![RowKey(2), RowKey(3)]);
877 }
878
879 #[test]
880 fn grouped_row_pinning_policy_promote_pinned_rows_matches_legacy_behavior() {
881 let visible_all = vec![
882 DisplayRow::Leaf {
883 data_index: 0,
884 row_key: RowKey(1),
885 depth: 0,
886 },
887 DisplayRow::Leaf {
888 data_index: 1,
889 row_key: RowKey(2),
890 depth: 0,
891 },
892 DisplayRow::Leaf {
893 data_index: 2,
894 row_key: RowKey(3),
895 depth: 0,
896 },
897 ];
898
899 let page_rows_center = vec![visible_all[1].clone(), visible_all[2].clone()];
900 let row_pinning = crate::headless::table::RowPinningState {
901 top: vec![RowKey(1)],
902 bottom: vec![RowKey(3)],
903 };
904
905 let out = apply_grouped_row_pinning_policy(
906 &visible_all,
907 &page_rows_center,
908 &row_pinning,
909 GroupedRowPinningPolicy::PromotePinnedRows,
910 );
911 let keys = out.into_iter().map(|r| r.row_key()).collect::<Vec<_>>();
912 assert_eq!(keys, vec![RowKey(1), RowKey(2), RowKey(3)]);
913 }
914
915 #[derive(Default)]
916 struct FakeServices;
917
918 impl TextService for FakeServices {
919 fn prepare(
920 &mut self,
921 _input: &TextInput,
922 _constraints: TextConstraints,
923 ) -> (TextBlobId, TextMetrics) {
924 (
925 TextBlobId::default(),
926 TextMetrics {
927 size: fret_core::Size::new(Px(0.0), Px(0.0)),
928 baseline: Px(0.0),
929 },
930 )
931 }
932
933 fn release(&mut self, _blob: TextBlobId) {}
934 }
935
936 impl PathService for FakeServices {
937 fn prepare(
938 &mut self,
939 _commands: &[PathCommand],
940 _style: PathStyle,
941 _constraints: PathConstraints,
942 ) -> (PathId, PathMetrics) {
943 (PathId::default(), PathMetrics::default())
944 }
945
946 fn release(&mut self, _path: PathId) {}
947 }
948
949 impl SvgService for FakeServices {
950 fn register_svg(&mut self, _bytes: &[u8]) -> SvgId {
951 SvgId::default()
952 }
953
954 fn unregister_svg(&mut self, _svg: SvgId) -> bool {
955 true
956 }
957 }
958
959 impl fret_core::MaterialService for FakeServices {
960 fn register_material(
961 &mut self,
962 _desc: fret_core::MaterialDescriptor,
963 ) -> Result<fret_core::MaterialId, fret_core::MaterialRegistrationError> {
964 Err(fret_core::MaterialRegistrationError::Unsupported)
965 }
966
967 fn unregister_material(&mut self, _id: fret_core::MaterialId) -> bool {
968 true
969 }
970 }
971
972 fn retained_table_capability_test_cell<'a>(
973 cx: &mut dyn ElementContextAccess<'a, App>,
974 col: &ColumnDef<u32>,
975 row: &u32,
976 ) -> AnyElement {
977 let cx = cx.elements();
978 let label = format!("{}-{row}", col.id.as_ref());
979 let test_id = Arc::<str>::from(format!("table-retained-capability-cell-{label}"));
980 cx.semantics(
981 SemanticsProps {
982 test_id: Some(test_id),
983 ..Default::default()
984 },
985 move |cx| vec![cx.text(label.clone())],
986 )
987 }
988
989 fn capture_layout_sidecar(
990 ui: &UiTree<App>,
991 app: &mut App,
992 window: AppWindowId,
993 root: fret_core::NodeId,
994 bounds: Rect,
995 ) -> serde_json::Value {
996 let nonce = std::time::SystemTime::now()
997 .duration_since(std::time::UNIX_EPOCH)
998 .expect("system clock should be after unix epoch")
999 .as_nanos();
1000 let out_dir = std::env::temp_dir().join(format!(
1001 "fret-ui-kit-table-layout-sidecar-{}-{nonce}",
1002 std::process::id()
1003 ));
1004 let _ = std::fs::remove_dir_all(&out_dir);
1005
1006 let path = ui
1007 .debug_write_layout_sidecar_taffy_v1_json(
1008 app, window, root, bounds, 1.0, None, &out_dir, 0,
1009 )
1010 .expect("layout sidecar should be written");
1011
1012 let sidecar: serde_json::Value =
1013 serde_json::from_slice(&std::fs::read(&path).expect("sidecar should be readable"))
1014 .expect("sidecar json should parse");
1015 let _ = std::fs::remove_dir_all(&out_dir);
1016 sidecar
1017 }
1018
1019 fn layout_sidecar_abs_rect(sidecar: &serde_json::Value, test_id: &str) -> Rect {
1020 let mut candidates: Vec<&serde_json::Value> = sidecar["taffy"]["nodes"]
1021 .as_array()
1022 .into_iter()
1023 .flat_map(|nodes| nodes.iter())
1024 .collect();
1025 if let Some(roots) = sidecar["taffy"]["roots"].as_array() {
1026 for root in roots {
1027 if let Some(nodes) = root["dump"]["nodes"].as_array() {
1028 candidates.extend(nodes.iter());
1029 }
1030 }
1031 }
1032
1033 let matches = candidates
1034 .iter()
1035 .filter(|node| node["debug"]["test_id"].as_str() == Some(test_id))
1036 .copied()
1037 .collect::<Vec<_>>();
1038 let matched = matches
1039 .into_iter()
1040 .max_by(|a, b| {
1041 let area = |node: &serde_json::Value| {
1042 let rect = &node["abs_rect"];
1043 rect["w"].as_f64().unwrap_or(0.0) * rect["h"].as_f64().unwrap_or(0.0)
1044 };
1045 area(a)
1046 .partial_cmp(&area(b))
1047 .unwrap_or(std::cmp::Ordering::Equal)
1048 })
1049 .unwrap_or_else(|| {
1050 let available = candidates
1051 .iter()
1052 .filter_map(|node| node["debug"]["test_id"].as_str())
1053 .collect::<Vec<_>>();
1054 panic!(
1055 "expected layout sidecar node with test_id `{test_id}`; available_test_ids={available:?}"
1056 );
1057 });
1058 let abs = &matched["abs_rect"];
1059 Rect::new(
1060 Point::new(
1061 Px(abs["x"].as_f64().expect("abs_rect.x should be a number") as f32),
1062 Px(abs["y"].as_f64().expect("abs_rect.y should be a number") as f32),
1063 ),
1064 fret_core::Size::new(
1065 Px(abs["w"].as_f64().expect("abs_rect.w should be a number") as f32),
1066 Px(abs["h"].as_f64().expect("abs_rect.h should be a number") as f32),
1067 ),
1068 )
1069 }
1070
1071 #[test]
1072 fn table_virtualized_copyable_reports_availability_and_emits_clipboard_text() {
1073 let window = AppWindowId::default();
1074 let mut app = App::new();
1075 let mut caps = fret_runtime::PlatformCapabilities::default();
1076 caps.clipboard.text.read = true;
1077 caps.clipboard.text.write = true;
1078 app.set_global(caps);
1079
1080 let mut ui: UiTree<App> = UiTree::new();
1081 ui.set_window(window);
1082
1083 Theme::with_global_mut(&mut app, |theme| {
1084 theme.apply_config(&ThemeConfig {
1085 name: "Test".to_string(),
1086 ..ThemeConfig::default()
1087 });
1088 });
1089
1090 let state = app.models_mut().insert(TableState::default());
1091 let data = vec![0u32, 1u32, 2u32];
1092 let columns = vec![ColumnDef::new("col")];
1093 let scroll = VirtualListScrollHandle::new();
1094
1095 let bounds = Rect::new(
1096 Point::new(Px(0.0), Px(0.0)),
1097 fret_core::Size::new(Px(320.0), Px(200.0)),
1098 );
1099 let mut services = FakeServices;
1100
1101 let render = |ui: &mut UiTree<App>,
1102 app: &mut App,
1103 services: &mut FakeServices|
1104 -> fret_core::NodeId {
1105 fret_ui::declarative::render_root(ui, app, services, window, bounds, "test", |cx| {
1106 vec![table_virtualized_copyable(
1107 cx,
1108 &data,
1109 &columns,
1110 state.clone(),
1111 &scroll,
1112 0,
1113 &|_row, i| RowKey::from_index(i),
1114 None,
1115 TableViewProps::default(),
1116 Arc::new(|_models, i| Some(format!("Row {i}"))),
1117 |_row| None,
1118 |cx, _col, _sort| [cx.text("Header")],
1119 |cx, row, _col| [cx.text(format!("Cell {}", row.index))],
1120 None,
1121 TableDebugIds::default(),
1122 )]
1123 })
1124 };
1125
1126 // VirtualList computes the visible window based on viewport metrics populated during layout,
1127 // so it takes two frames for the first set of rows to mount.
1128 let mut root = fret_core::NodeId::default();
1129 for _ in 0..2 {
1130 root = render(&mut ui, &mut app, &mut services);
1131 ui.set_root(root);
1132 ui.layout_all(&mut app, &mut services, bounds, 1.0);
1133 let mut scene = fret_core::Scene::default();
1134 ui.paint_all(&mut app, &mut services, bounds, &mut scene, 1.0);
1135 }
1136
1137 let table_node = ui.children(root)[0];
1138 ui.set_focus(Some(table_node));
1139
1140 let copy = CommandId::from("edit.copy");
1141 assert!(
1142 !ui.is_command_available(&mut app, ©),
1143 "expected edit.copy to be unavailable when selection is empty"
1144 );
1145 assert!(
1146 ui.dispatch_command(&mut app, &mut services, ©),
1147 "expected edit.copy to be handled by the table surface"
1148 );
1149 let effects = app.flush_effects();
1150 assert!(
1151 !effects
1152 .iter()
1153 .any(|e| matches!(e, fret_runtime::Effect::ClipboardWriteText { .. })),
1154 "expected edit.copy to not emit ClipboardWriteText when selection is empty"
1155 );
1156
1157 let _ = app.models_mut().update(&state, |st| {
1158 st.row_selection.insert(RowKey::from_index(1));
1159 });
1160
1161 assert!(
1162 ui.is_command_available(&mut app, ©),
1163 "expected edit.copy to be available when selection is non-empty"
1164 );
1165 assert!(
1166 ui.dispatch_command(&mut app, &mut services, ©),
1167 "expected edit.copy to be handled by the table surface"
1168 );
1169 let effects = app.flush_effects();
1170 assert!(
1171 effects.iter().any(|e| {
1172 matches!(e, fret_runtime::Effect::ClipboardWriteText { text, .. } if text == "Row 1")
1173 }),
1174 "expected edit.copy to emit ClipboardWriteText for the selected row"
1175 );
1176 }
1177
1178 #[test]
1179 fn table_virtualized_clamps_cell_width_for_wide_text() {
1180 let window = AppWindowId::default();
1181 let mut app = App::new();
1182 let mut ui: UiTree<App> = UiTree::new();
1183 ui.set_window(window);
1184
1185 Theme::with_global_mut(&mut app, |theme| {
1186 theme.apply_config(&ThemeConfig {
1187 name: "Test".to_string(),
1188 ..ThemeConfig::default()
1189 });
1190 });
1191
1192 let mut state_value = TableState::default();
1193 state_value.pagination.page_size = 1;
1194 let state = app.models_mut().insert(state_value);
1195
1196 let data = vec![0u32];
1197 let mut col = ColumnDef::new("col");
1198 col.size = 80.0;
1199 let columns = vec![col];
1200 let scroll = VirtualListScrollHandle::new();
1201
1202 let bounds = Rect::new(
1203 Point::new(Px(0.0), Px(0.0)),
1204 fret_core::Size::new(Px(320.0), Px(200.0)),
1205 );
1206 let mut services = FakeServices;
1207
1208 let render = |ui: &mut UiTree<App>,
1209 app: &mut App,
1210 services: &mut FakeServices|
1211 -> fret_core::NodeId {
1212 fret_ui::declarative::render_root(ui, app, services, window, bounds, "test", |cx| {
1213 vec![table_virtualized(
1214 cx,
1215 &data,
1216 &columns,
1217 state.clone(),
1218 &scroll,
1219 0,
1220 &|_row, i| RowKey::from_index(i),
1221 None,
1222 TableViewProps::default(),
1223 |_row| None,
1224 move |cx, _col, _sort| {
1225 let header = cx.semantics(
1226 SemanticsProps {
1227 test_id: Some(Arc::<str>::from("table-test-header")),
1228 ..Default::default()
1229 },
1230 |cx| {
1231 vec![cx.container(
1232 ContainerProps {
1233 layout: LayoutStyle {
1234 size: fret_ui::element::SizeStyle {
1235 width: Length::Fill,
1236 height: Length::Fill,
1237 ..Default::default()
1238 },
1239 overflow: Overflow::Clip,
1240 ..Default::default()
1241 },
1242 ..Default::default()
1243 },
1244 |cx| vec![crate::ui::text("Header").into_element(cx)],
1245 )]
1246 },
1247 );
1248 [header]
1249 },
1250 |cx, row, _col| {
1251 let long = format!("Row{}-{}", row.index, "x".repeat(4096));
1252 let cell = crate::ui::text(long).wrap(TextWrap::Grapheme);
1253 let cell = cx.container(
1254 ContainerProps {
1255 layout: LayoutStyle {
1256 size: fret_ui::element::SizeStyle {
1257 width: Length::Fill,
1258 height: Length::Fill,
1259 ..Default::default()
1260 },
1261 overflow: Overflow::Clip,
1262 ..Default::default()
1263 },
1264 ..Default::default()
1265 },
1266 |cx| vec![cell.into_element(cx)],
1267 );
1268
1269 [cx.semantics(
1270 SemanticsProps {
1271 test_id: Some(Arc::<str>::from("table-test-cell")),
1272 ..Default::default()
1273 },
1274 move |_cx| vec![cell],
1275 )]
1276 },
1277 None,
1278 TableDebugIds::default(),
1279 )]
1280 })
1281 };
1282
1283 // VirtualList computes the visible window based on viewport metrics populated during layout,
1284 // so it takes two frames for the first set of rows to mount.
1285 for _ in 0..3 {
1286 let root = render(&mut ui, &mut app, &mut services);
1287 ui.set_root(root);
1288 ui.request_semantics_snapshot();
1289 ui.layout_all(&mut app, &mut services, bounds, 1.0);
1290 let mut scene = fret_core::Scene::default();
1291 ui.paint_all(&mut app, &mut services, bounds, &mut scene, 1.0);
1292 }
1293
1294 let snap = ui
1295 .semantics_snapshot()
1296 .expect("expected a semantics snapshot");
1297
1298 let cell_bounds = snap
1299 .nodes
1300 .iter()
1301 .find(|n| n.test_id.as_deref() == Some("table-test-cell"))
1302 .map(|n| n.bounds)
1303 .expect("expected to find table-test-cell");
1304
1305 assert!(
1306 cell_bounds.size.width.0 <= 80.0,
1307 "expected the cell subtree to be clamped to the column width (got {:.2}px)",
1308 cell_bounds.size.width.0
1309 );
1310 }
1311
1312 #[test]
1313 fn table_virtualized_alignment_gate_header_matches_rows_under_overflow_and_variable_height() {
1314 let window = AppWindowId::default();
1315 let mut app = App::new();
1316 let mut ui: UiTree<App> = UiTree::new();
1317 ui.set_window(window);
1318
1319 Theme::with_global_mut(&mut app, |theme| {
1320 theme.apply_config(&ThemeConfig {
1321 name: "Test".to_string(),
1322 ..ThemeConfig::default()
1323 });
1324 });
1325
1326 let mut state_value = TableState::default();
1327 state_value.pagination.page_size = 3;
1328 let state = app.models_mut().insert(state_value);
1329
1330 let data = vec![0u32, 1u32, 2u32];
1331 let columns = vec![
1332 {
1333 let mut col = ColumnDef::new("name");
1334 col.size = 220.0;
1335 col
1336 },
1337 {
1338 let mut col = ColumnDef::new("status");
1339 col.size = 140.0;
1340 col
1341 },
1342 {
1343 let mut col = ColumnDef::new("cpu%");
1344 col.size = 90.0;
1345 col
1346 },
1347 {
1348 let mut col = ColumnDef::new("mem_mb");
1349 col.size = 110.0;
1350 col
1351 },
1352 ];
1353 let scroll = VirtualListScrollHandle::new();
1354
1355 let bounds = Rect::new(
1356 Point::new(Px(0.0), Px(0.0)),
1357 fret_core::Size::new(Px(320.0), Px(240.0)),
1358 );
1359 let mut services = FakeServices;
1360
1361 let props = TableViewProps {
1362 draw_frame: false,
1363 row_measure_mode: TableRowMeasureMode::Measured,
1364 ..Default::default()
1365 };
1366
1367 let render = |ui: &mut UiTree<App>,
1368 app: &mut App,
1369 services: &mut FakeServices|
1370 -> fret_core::NodeId {
1371 fret_ui::declarative::render_root(ui, app, services, window, bounds, "test", |cx| {
1372 vec![table_virtualized(
1373 cx,
1374 &data,
1375 &columns,
1376 state.clone(),
1377 &scroll,
1378 0,
1379 &|_row, i| RowKey::from_index(i),
1380 None,
1381 props.clone(),
1382 |_row| None,
1383 |cx, col, _sort| {
1384 let label = Arc::<str>::from(col.id.as_ref());
1385 [cx.container(
1386 ContainerProps {
1387 layout: table_clip_fill_layout(),
1388 ..Default::default()
1389 },
1390 move |_cx| vec![crate::ui::text(label.clone()).into_element(_cx)],
1391 )]
1392 },
1393 |cx, row, col| {
1394 let text = match col.id.as_ref() {
1395 "name" => {
1396 if row.index == 1 {
1397 format!("Row {} (details)\nMore text to force wrap", row.index)
1398 } else {
1399 format!("Row {}", row.index)
1400 }
1401 }
1402 "status" => "Running".to_string(),
1403 "cpu%" => "42%".to_string(),
1404 "mem_mb" => "256 MB".to_string(),
1405 _ => "?".to_string(),
1406 };
1407 let cell = crate::ui::text(text).wrap(TextWrap::Grapheme);
1408 [cx.container(
1409 ContainerProps {
1410 layout: table_clip_fill_layout(),
1411 ..Default::default()
1412 },
1413 move |_cx| vec![cell.clone().into_element(_cx)],
1414 )]
1415 },
1416 None,
1417 TableDebugIds {
1418 header_cell_test_id_prefix: Some(Arc::<str>::from("table-align-header-")),
1419 row_test_id_prefix: Some(Arc::<str>::from("table-align-row-")),
1420 ..Default::default()
1421 },
1422 )]
1423 })
1424 };
1425
1426 // VirtualList computes the visible window based on viewport metrics populated during layout,
1427 // so it takes two frames for the first set of rows to mount.
1428 let mut root = fret_core::NodeId::default();
1429 for _ in 0..2 {
1430 root = render(&mut ui, &mut app, &mut services);
1431 ui.set_root(root);
1432 ui.request_semantics_snapshot();
1433 ui.layout_all(&mut app, &mut services, bounds, 1.0);
1434 let mut scene = fret_core::Scene::default();
1435 ui.paint_all(&mut app, &mut services, bounds, &mut scene, 1.0);
1436 }
1437
1438 let snap = ui
1439 .semantics_snapshot()
1440 .expect("expected semantics snapshot after table render");
1441 let col_ids = ["name", "status", "cpu%", "mem_mb"];
1442 for col_id in col_ids {
1443 let header_test_id = format!("table-align-header-{col_id}");
1444 let header_count = snap
1445 .nodes
1446 .iter()
1447 .filter(|node| node.test_id.as_deref() == Some(header_test_id.as_str()))
1448 .count();
1449 assert_eq!(
1450 header_count, 1,
1451 "expected exactly one semantics node for hoisted header anchor {header_test_id}"
1452 );
1453
1454 for row in 0..3 {
1455 let cell_test_id = format!("table-align-row-{row}-cell-{col_id}");
1456 let cell_count = snap
1457 .nodes
1458 .iter()
1459 .filter(|node| node.test_id.as_deref() == Some(cell_test_id.as_str()))
1460 .count();
1461 assert_eq!(
1462 cell_count, 1,
1463 "expected exactly one semantics node for hoisted cell anchor {cell_test_id}"
1464 );
1465 }
1466 }
1467
1468 let sidecar = capture_layout_sidecar(&ui, &mut app, window, root, bounds);
1469
1470 // `table_virtualized` composes header/body content under slightly different chrome
1471 // (e.g. grid line dividers vs resize handles). We want a strict gate for x alignment
1472 // (columns must not shift across rows), while allowing a small tolerance for the
1473 // content-box width when borders are involved.
1474 let eps_x = 0.5;
1475 let eps_w = 1.0;
1476 for col_id in col_ids {
1477 let header = layout_sidecar_abs_rect(&sidecar, &format!("table-align-header-{col_id}"));
1478 for row in 0..3 {
1479 let cell = layout_sidecar_abs_rect(
1480 &sidecar,
1481 &format!("table-align-row-{row}-cell-{col_id}"),
1482 );
1483 assert!(
1484 (cell.origin.x.0 - header.origin.x.0).abs() <= eps_x,
1485 "expected header/cell x alignment for col={col_id} row={row} (header_x={:.2}px cell_x={:.2}px)",
1486 header.origin.x.0,
1487 cell.origin.x.0
1488 );
1489 assert!(
1490 (cell.size.width.0 - header.size.width.0).abs() <= eps_w,
1491 "expected header/cell width alignment for col={col_id} row={row} (header_w={:.2}px cell_w={:.2}px)",
1492 header.size.width.0,
1493 cell.size.width.0
1494 );
1495 }
1496 }
1497 }
1498
1499 #[test]
1500 fn table_virtualized_pointer_select_does_not_shift_row_bounds() {
1501 let window = AppWindowId::default();
1502 let mut app = App::new();
1503 let mut ui: UiTree<App> = UiTree::new();
1504 ui.set_window(window);
1505
1506 Theme::with_global_mut(&mut app, |theme| {
1507 theme.apply_config(&ThemeConfig {
1508 name: "Test".to_string(),
1509 ..ThemeConfig::default()
1510 });
1511 });
1512
1513 let mut state_value = TableState::default();
1514 state_value.pagination.page_size = 5;
1515 let state = app.models_mut().insert(state_value);
1516
1517 let data = vec![0u32, 1u32, 2u32, 3u32, 4u32];
1518 let mut col = ColumnDef::new("name");
1519 col.size = 220.0;
1520 let columns = vec![col];
1521 let scroll = VirtualListScrollHandle::new();
1522
1523 let bounds = Rect::new(
1524 Point::new(Px(0.0), Px(0.0)),
1525 fret_core::Size::new(Px(320.0), Px(200.0)),
1526 );
1527 let mut services = FakeServices;
1528
1529 let props = TableViewProps {
1530 draw_frame: false,
1531 enable_column_resizing: false,
1532 enable_row_selection: true,
1533 single_row_selection: true,
1534 row_height: Some(Px(40.0)),
1535 header_height: Some(Px(40.0)),
1536 ..Default::default()
1537 };
1538
1539 let render = |ui: &mut UiTree<App>,
1540 app: &mut App,
1541 services: &mut FakeServices|
1542 -> fret_core::NodeId {
1543 fret_ui::declarative::render_root(ui, app, services, window, bounds, "test", |cx| {
1544 vec![table_virtualized(
1545 cx,
1546 &data,
1547 &columns,
1548 state.clone(),
1549 &scroll,
1550 0,
1551 &|_row, i| RowKey::from_index(i),
1552 None,
1553 props.clone(),
1554 |_row| None,
1555 |cx, _col, _sort| [cx.text("")],
1556 |cx, row, _col| {
1557 let mut fill = LayoutStyle::default();
1558 fill.size.width = Length::Fill;
1559 fill.size.height = Length::Fill;
1560 let marker = cx.container(
1561 ContainerProps {
1562 layout: fill,
1563 ..Default::default()
1564 },
1565 |_| Vec::new(),
1566 );
1567 let _ = row;
1568 [marker]
1569 },
1570 None,
1571 TableDebugIds {
1572 row_test_id_prefix: Some(Arc::<str>::from("table-test-row-")),
1573 ..Default::default()
1574 },
1575 )]
1576 })
1577 };
1578
1579 for _ in 0..2 {
1580 let root = render(&mut ui, &mut app, &mut services);
1581 ui.set_root(root);
1582 ui.request_semantics_snapshot();
1583 ui.layout_all(&mut app, &mut services, bounds, 1.0);
1584 let mut scene = fret_core::Scene::default();
1585 ui.paint_all(&mut app, &mut services, bounds, &mut scene, 1.0);
1586 }
1587
1588 let snap = ui
1589 .semantics_snapshot()
1590 .expect("expected a semantics snapshot");
1591 let before = snap
1592 .nodes
1593 .iter()
1594 .find(|n| n.test_id.as_deref() == Some("table-test-row-1"))
1595 .map(|n| n.bounds)
1596 .expect("expected marker node");
1597
1598 let click_pos = Point::new(
1599 Px(before.origin.x.0 + before.size.width.0 * 0.5),
1600 Px(before.origin.y.0 + before.size.height.0 * 0.5),
1601 );
1602
1603 ui.dispatch_event(
1604 &mut app,
1605 &mut services,
1606 &Event::Pointer(PointerEvent::Down {
1607 position: click_pos,
1608 button: MouseButton::Left,
1609 modifiers: Modifiers::default(),
1610 click_count: 1,
1611 pointer_id: PointerId(0),
1612 pointer_type: PointerType::Mouse,
1613 }),
1614 );
1615 ui.dispatch_event(
1616 &mut app,
1617 &mut services,
1618 &Event::Pointer(PointerEvent::Up {
1619 position: click_pos,
1620 button: MouseButton::Left,
1621 modifiers: Modifiers::default(),
1622 click_count: 1,
1623 is_click: true,
1624 pointer_id: PointerId(0),
1625 pointer_type: PointerType::Mouse,
1626 }),
1627 );
1628
1629 for _ in 0..2 {
1630 let root = render(&mut ui, &mut app, &mut services);
1631 ui.set_root(root);
1632 ui.request_semantics_snapshot();
1633 ui.layout_all(&mut app, &mut services, bounds, 1.0);
1634 let mut scene = fret_core::Scene::default();
1635 ui.paint_all(&mut app, &mut services, bounds, &mut scene, 1.0);
1636 }
1637
1638 let snap = ui
1639 .semantics_snapshot()
1640 .expect("expected a semantics snapshot");
1641 let after = snap
1642 .nodes
1643 .iter()
1644 .find(|n| n.test_id.as_deref() == Some("table-test-row-1"))
1645 .map(|n| n.bounds)
1646 .expect("expected marker node");
1647
1648 assert!(
1649 (after.origin.y.0 - before.origin.y.0).abs() <= 0.1,
1650 "expected row marker to keep stable y; before={:?} after={:?}",
1651 before,
1652 after
1653 );
1654 }
1655
1656 #[test]
1657 fn table_virtualized_can_disable_pointer_row_selection() {
1658 let window = AppWindowId::default();
1659 let mut app = App::new();
1660 let mut ui: UiTree<App> = UiTree::new();
1661 ui.set_window(window);
1662
1663 Theme::with_global_mut(&mut app, |theme| {
1664 theme.apply_config(&ThemeConfig {
1665 name: "Test".to_string(),
1666 ..ThemeConfig::default()
1667 });
1668 });
1669
1670 let mut state_value = TableState::default();
1671 state_value.pagination.page_size = 5;
1672 let state = app.models_mut().insert(state_value);
1673
1674 let data = vec![0u32, 1u32, 2u32, 3u32, 4u32];
1675 let mut col = ColumnDef::new("name");
1676 col.size = 220.0;
1677 let columns = vec![col];
1678 let scroll = VirtualListScrollHandle::new();
1679
1680 let bounds = Rect::new(
1681 Point::new(Px(0.0), Px(0.0)),
1682 fret_core::Size::new(Px(320.0), Px(200.0)),
1683 );
1684 let mut services = FakeServices;
1685
1686 let props = TableViewProps {
1687 draw_frame: false,
1688 enable_column_resizing: false,
1689 enable_row_selection: true,
1690 pointer_row_selection: false,
1691 single_row_selection: true,
1692 row_height: Some(Px(40.0)),
1693 header_height: Some(Px(40.0)),
1694 ..Default::default()
1695 };
1696
1697 let render = |ui: &mut UiTree<App>,
1698 app: &mut App,
1699 services: &mut FakeServices|
1700 -> fret_core::NodeId {
1701 fret_ui::declarative::render_root(ui, app, services, window, bounds, "test", |cx| {
1702 vec![table_virtualized(
1703 cx,
1704 &data,
1705 &columns,
1706 state.clone(),
1707 &scroll,
1708 0,
1709 &|_row, i| RowKey::from_index(i),
1710 None,
1711 props.clone(),
1712 |_row| None,
1713 |cx, _col, _sort| [cx.text("")],
1714 |cx, row, _col| {
1715 let mut fill = LayoutStyle::default();
1716 fill.size.width = Length::Fill;
1717 fill.size.height = Length::Fill;
1718 let marker = cx.container(
1719 ContainerProps {
1720 layout: fill,
1721 ..Default::default()
1722 },
1723 |_| Vec::new(),
1724 );
1725 let _ = row;
1726 [marker]
1727 },
1728 None,
1729 TableDebugIds {
1730 row_test_id_prefix: Some(Arc::<str>::from("table-test-row-")),
1731 ..Default::default()
1732 },
1733 )]
1734 })
1735 };
1736
1737 for _ in 0..2 {
1738 let root = render(&mut ui, &mut app, &mut services);
1739 ui.set_root(root);
1740 ui.request_semantics_snapshot();
1741 ui.layout_all(&mut app, &mut services, bounds, 1.0);
1742 let mut scene = fret_core::Scene::default();
1743 ui.paint_all(&mut app, &mut services, bounds, &mut scene, 1.0);
1744 }
1745
1746 let snap = ui
1747 .semantics_snapshot()
1748 .expect("expected a semantics snapshot");
1749 let marker_bounds = snap
1750 .nodes
1751 .iter()
1752 .find(|n| n.test_id.as_deref() == Some("table-test-row-1"))
1753 .map(|n| n.bounds)
1754 .expect("expected marker node");
1755
1756 let click_pos = Point::new(
1757 Px(marker_bounds.origin.x.0 + marker_bounds.size.width.0 * 0.5),
1758 Px(marker_bounds.origin.y.0 + marker_bounds.size.height.0 * 0.5),
1759 );
1760
1761 ui.dispatch_event(
1762 &mut app,
1763 &mut services,
1764 &Event::Pointer(PointerEvent::Down {
1765 position: click_pos,
1766 button: MouseButton::Left,
1767 modifiers: Modifiers::default(),
1768 click_count: 1,
1769 pointer_id: PointerId(0),
1770 pointer_type: PointerType::Mouse,
1771 }),
1772 );
1773 ui.dispatch_event(
1774 &mut app,
1775 &mut services,
1776 &Event::Pointer(PointerEvent::Up {
1777 position: click_pos,
1778 button: MouseButton::Left,
1779 modifiers: Modifiers::default(),
1780 click_count: 1,
1781 is_click: true,
1782 pointer_id: PointerId(0),
1783 pointer_type: PointerType::Mouse,
1784 }),
1785 );
1786
1787 let selection = app
1788 .models()
1789 .read(&state, |st| st.row_selection.clone())
1790 .ok()
1791 .unwrap_or_default();
1792 assert!(
1793 !selection.contains(&RowKey::from_index(1)),
1794 "expected row click not to toggle selection when pointer_row_selection=false"
1795 );
1796 }
1797
1798 #[test]
1799 fn table_virtualized_nested_pressable_remains_hittable_when_pointer_row_selection_disabled() {
1800 use std::cell::Cell;
1801 use std::rc::Rc;
1802
1803 let window = AppWindowId::default();
1804 let mut app = App::new();
1805 let mut ui: UiTree<App> = UiTree::new();
1806 ui.set_window(window);
1807
1808 Theme::with_global_mut(&mut app, |theme| {
1809 theme.apply_config(&ThemeConfig {
1810 name: "Test".to_string(),
1811 ..ThemeConfig::default()
1812 });
1813 });
1814
1815 let mut state_value = TableState::default();
1816 state_value.pagination.page_size = 3;
1817 let state = app.models_mut().insert(state_value);
1818 let child_activated = app.models_mut().insert(false);
1819 let child_element: Rc<Cell<Option<fret_ui::GlobalElementId>>> = Rc::new(Cell::new(None));
1820
1821 let data = vec![0u32, 1u32, 2u32];
1822 let columns = vec![
1823 {
1824 let mut col = ColumnDef::new("name");
1825 col.size = 180.0;
1826 col
1827 },
1828 {
1829 let mut col = ColumnDef::new("actions");
1830 col.size = 80.0;
1831 col
1832 },
1833 ];
1834 let scroll = VirtualListScrollHandle::new();
1835
1836 let bounds = Rect::new(
1837 Point::new(Px(0.0), Px(0.0)),
1838 fret_core::Size::new(Px(320.0), Px(180.0)),
1839 );
1840 let mut services = FakeServices;
1841
1842 let props = TableViewProps {
1843 draw_frame: false,
1844 enable_column_resizing: false,
1845 enable_row_selection: true,
1846 pointer_row_selection: false,
1847 single_row_selection: true,
1848 row_height: Some(Px(40.0)),
1849 header_height: Some(Px(40.0)),
1850 ..Default::default()
1851 };
1852
1853 let render = |ui: &mut UiTree<App>,
1854 app: &mut App,
1855 services: &mut FakeServices|
1856 -> fret_core::NodeId {
1857 let child_element = child_element.clone();
1858 fret_ui::declarative::render_root(ui, app, services, window, bounds, "test", |cx| {
1859 vec![table_virtualized(
1860 cx,
1861 &data,
1862 &columns,
1863 state.clone(),
1864 &scroll,
1865 0,
1866 &|_row, i| RowKey::from_index(i),
1867 None,
1868 props.clone(),
1869 |_row| None,
1870 |cx, col, _sort| [cx.text(col.id.as_ref())],
1871 |cx, row, col| match col.id.as_ref() {
1872 "name" => [cx.text(format!("Row {}", row.index))],
1873 "actions" if row.index == 1 => {
1874 let child_element = child_element.clone();
1875 let child_activated = child_activated.clone();
1876 [cx.pressable_with_id(
1877 PressableProps {
1878 focusable: false,
1879 layout: LayoutStyle {
1880 size: fret_ui::element::SizeStyle {
1881 width: Length::Px(Px(24.0)),
1882 height: Length::Px(Px(24.0)),
1883 ..Default::default()
1884 },
1885 ..Default::default()
1886 },
1887 a11y: PressableA11y {
1888 role: Some(SemanticsRole::Button),
1889 test_id: Some(Arc::<str>::from("table-test-child-button")),
1890 ..Default::default()
1891 },
1892 ..Default::default()
1893 },
1894 move |cx, _st, id| {
1895 child_element.set(Some(id));
1896 let child_activated = child_activated.clone();
1897 cx.pressable_on_activate(Arc::new(
1898 move |host, _acx, _reason| {
1899 let _ = host
1900 .models_mut()
1901 .update(&child_activated, |value| *value = true);
1902 },
1903 ));
1904 vec![cx.spacer(SpacerProps::default())]
1905 },
1906 )]
1907 }
1908 "actions" => [cx.text("-")],
1909 _ => [cx.text("?")],
1910 },
1911 None,
1912 TableDebugIds::default(),
1913 )]
1914 })
1915 };
1916
1917 for _ in 0..2 {
1918 let root = render(&mut ui, &mut app, &mut services);
1919 ui.set_root(root);
1920 ui.request_semantics_snapshot();
1921 ui.layout_all(&mut app, &mut services, bounds, 1.0);
1922 let mut scene = fret_core::Scene::default();
1923 ui.paint_all(&mut app, &mut services, bounds, &mut scene, 1.0);
1924 }
1925
1926 let child_element = child_element
1927 .get()
1928 .expect("expected nested child pressable element");
1929 let child_node = fret_ui::elements::node_for_element(&mut app, window, child_element)
1930 .expect("expected nested child pressable node");
1931 let child_bounds = ui
1932 .debug_node_bounds(child_node)
1933 .expect("expected nested child bounds");
1934 assert!(
1935 child_bounds.size.width.0 > 0.0 && child_bounds.size.height.0 > 0.0,
1936 "expected nested child pressable to have non-zero bounds, got {child_bounds:?}"
1937 );
1938 let click_pos = Point::new(
1939 Px(child_bounds.origin.x.0 + child_bounds.size.width.0 * 0.5),
1940 Px(child_bounds.origin.y.0 + child_bounds.size.height.0 * 0.5),
1941 );
1942
1943 let hit = ui.debug_hit_test_routing(click_pos);
1944 let hit_node = hit.hit.expect("expected nested child hit");
1945 let path = ui.debug_node_path(hit_node);
1946 let child_path = ui.debug_node_path(child_node);
1947 let child_path_debug = child_path
1948 .iter()
1949 .map(|node| {
1950 (
1951 *node,
1952 ui.debug_node_bounds(*node),
1953 ui.debug_node_clips_hit_test(*node),
1954 ui.debug_node_can_scroll_descendant_into_view(*node),
1955 )
1956 })
1957 .collect::<Vec<_>>();
1958
1959 ui.dispatch_event(
1960 &mut app,
1961 &mut services,
1962 &Event::Pointer(PointerEvent::Down {
1963 position: click_pos,
1964 button: MouseButton::Left,
1965 modifiers: Modifiers::default(),
1966 click_count: 1,
1967 pointer_id: PointerId(0),
1968 pointer_type: PointerType::Mouse,
1969 }),
1970 );
1971 ui.dispatch_event(
1972 &mut app,
1973 &mut services,
1974 &Event::Pointer(PointerEvent::Up {
1975 position: click_pos,
1976 button: MouseButton::Left,
1977 modifiers: Modifiers::default(),
1978 click_count: 1,
1979 is_click: true,
1980 pointer_id: PointerId(0),
1981 pointer_type: PointerType::Mouse,
1982 }),
1983 );
1984
1985 assert_eq!(
1986 app.models().get_copied(&child_activated),
1987 Some(true),
1988 "expected nested child pressable to activate; hit={hit:?} path={path:?} child={child_node:?} child_path={child_path:?} child_path_debug={child_path_debug:?} child_bounds={child_bounds:?}"
1989 );
1990
1991 let selection = app
1992 .models()
1993 .read(&state, |st| st.row_selection.clone())
1994 .ok()
1995 .unwrap_or_default();
1996 assert!(
1997 selection.is_empty(),
1998 "expected nested child pressable click not to toggle row selection when pointer_row_selection=false"
1999 );
2000 }
2001
2002 #[test]
2003 fn table_virtualized_pointer_row_selection_policy_list_like() {
2004 let window = AppWindowId::default();
2005 let mut app = App::new();
2006 let mut ui: UiTree<App> = UiTree::new();
2007 ui.set_window(window);
2008
2009 Theme::with_global_mut(&mut app, |theme| {
2010 theme.apply_config(&ThemeConfig {
2011 name: "Test".to_string(),
2012 ..ThemeConfig::default()
2013 });
2014 });
2015
2016 let mut state_value = TableState::default();
2017 state_value.pagination.page_size = 5;
2018 let state = app.models_mut().insert(state_value);
2019
2020 let data = vec![0u32, 1u32, 2u32, 3u32, 4u32];
2021 let mut col = ColumnDef::new("name");
2022 col.size = 220.0;
2023 let columns = vec![col];
2024 let scroll = VirtualListScrollHandle::new();
2025
2026 let bounds = Rect::new(
2027 Point::new(Px(0.0), Px(0.0)),
2028 fret_core::Size::new(Px(320.0), Px(240.0)),
2029 );
2030 let mut services = FakeServices;
2031
2032 let props = TableViewProps {
2033 draw_frame: false,
2034 enable_column_resizing: false,
2035 enable_row_selection: true,
2036 pointer_row_selection: true,
2037 pointer_row_selection_policy: PointerRowSelectionPolicy::ListLike,
2038 single_row_selection: false,
2039 row_height: Some(Px(40.0)),
2040 header_height: Some(Px(40.0)),
2041 ..Default::default()
2042 };
2043
2044 let render = |ui: &mut UiTree<App>,
2045 app: &mut App,
2046 services: &mut FakeServices|
2047 -> fret_core::NodeId {
2048 fret_ui::declarative::render_root(ui, app, services, window, bounds, "test", |cx| {
2049 vec![table_virtualized(
2050 cx,
2051 &data,
2052 &columns,
2053 state.clone(),
2054 &scroll,
2055 0,
2056 &|_row, i| RowKey::from_index(i),
2057 None,
2058 props.clone(),
2059 |_row| None,
2060 |cx, _col, _sort| [cx.text("")],
2061 |cx, row, _col| {
2062 let mut fill = LayoutStyle::default();
2063 fill.size.width = Length::Fill;
2064 fill.size.height = Length::Fill;
2065 let marker = cx.container(
2066 ContainerProps {
2067 layout: fill,
2068 ..Default::default()
2069 },
2070 |_| Vec::new(),
2071 );
2072 let _ = row;
2073 [marker]
2074 },
2075 None,
2076 TableDebugIds {
2077 row_test_id_prefix: Some(Arc::<str>::from("table-test-row-")),
2078 ..Default::default()
2079 },
2080 )]
2081 })
2082 };
2083
2084 // VirtualList computes the visible window based on viewport metrics populated during layout,
2085 // so it takes two frames for the first set of rows to mount.
2086 for _ in 0..2 {
2087 let root = render(&mut ui, &mut app, &mut services);
2088 ui.set_root(root);
2089 ui.request_semantics_snapshot();
2090 ui.layout_all(&mut app, &mut services, bounds, 1.0);
2091 let mut scene = fret_core::Scene::default();
2092 ui.paint_all(&mut app, &mut services, bounds, &mut scene, 1.0);
2093 }
2094
2095 let click_pos_for_row: Vec<Point> = {
2096 let snap = ui
2097 .semantics_snapshot()
2098 .expect("expected a semantics snapshot");
2099 (0..5)
2100 .map(|row_index| {
2101 let id = format!("table-test-row-{row_index}");
2102 let bounds = snap
2103 .nodes
2104 .iter()
2105 .find(|n| n.test_id.as_deref() == Some(id.as_str()))
2106 .map(|n| n.bounds)
2107 .unwrap_or_else(|| panic!("expected marker node {id}"));
2108 Point::new(
2109 Px(bounds.origin.x.0 + bounds.size.width.0 * 0.5),
2110 Px(bounds.origin.y.0 + bounds.size.height.0 * 0.5),
2111 )
2112 })
2113 .collect()
2114 };
2115
2116 let click_row = |ui: &mut UiTree<App>,
2117 app: &mut App,
2118 services: &mut FakeServices,
2119 row_index: usize,
2120 modifiers: Modifiers| {
2121 let click_pos = click_pos_for_row[row_index];
2122 ui.dispatch_event(
2123 app,
2124 services,
2125 &Event::Pointer(PointerEvent::Down {
2126 position: click_pos,
2127 button: MouseButton::Left,
2128 modifiers,
2129 click_count: 1,
2130 pointer_id: PointerId(0),
2131 pointer_type: PointerType::Mouse,
2132 }),
2133 );
2134 ui.dispatch_event(
2135 app,
2136 services,
2137 &Event::Pointer(PointerEvent::Up {
2138 position: click_pos,
2139 button: MouseButton::Left,
2140 modifiers,
2141 click_count: 1,
2142 is_click: true,
2143 pointer_id: PointerId(0),
2144 pointer_type: PointerType::Mouse,
2145 }),
2146 );
2147 };
2148
2149 let assert_selected = |app: &App, expected: &[RowKey]| {
2150 let selection = app
2151 .models()
2152 .read(&state, |st| st.row_selection.clone())
2153 .ok()
2154 .unwrap_or_default();
2155 assert_eq!(
2156 selection.len(),
2157 expected.len(),
2158 "expected selection len {} but got {}",
2159 expected.len(),
2160 selection.len()
2161 );
2162 for k in expected {
2163 assert!(selection.contains(k), "expected selection to contain {k:?}");
2164 }
2165 };
2166
2167 click_row(&mut ui, &mut app, &mut services, 1, Modifiers::default());
2168 assert_selected(&app, &[RowKey::from_index(1)]);
2169
2170 click_row(
2171 &mut ui,
2172 &mut app,
2173 &mut services,
2174 3,
2175 Modifiers {
2176 shift: true,
2177 ..Default::default()
2178 },
2179 );
2180 assert_selected(
2181 &app,
2182 &[
2183 RowKey::from_index(1),
2184 RowKey::from_index(2),
2185 RowKey::from_index(3),
2186 ],
2187 );
2188
2189 click_row(
2190 &mut ui,
2191 &mut app,
2192 &mut services,
2193 4,
2194 Modifiers {
2195 ctrl: true,
2196 ..Default::default()
2197 },
2198 );
2199 assert_selected(
2200 &app,
2201 &[
2202 RowKey::from_index(1),
2203 RowKey::from_index(2),
2204 RowKey::from_index(3),
2205 RowKey::from_index(4),
2206 ],
2207 );
2208
2209 click_row(&mut ui, &mut app, &mut services, 2, Modifiers::default());
2210 assert_selected(&app, &[RowKey::from_index(2)]);
2211 }
2212
2213 #[test]
2214 fn table_virtualized_allows_header_height_override() {
2215 let window = AppWindowId::default();
2216 let mut app = App::new();
2217 let mut ui: UiTree<App> = UiTree::new();
2218 ui.set_window(window);
2219
2220 Theme::with_global_mut(&mut app, |theme| {
2221 theme.apply_config(&ThemeConfig {
2222 name: "Test".to_string(),
2223 ..ThemeConfig::default()
2224 });
2225 });
2226
2227 let mut state_value = TableState::default();
2228 state_value.pagination.page_size = 1;
2229 let state = app.models_mut().insert(state_value);
2230
2231 let data: Arc<[u32]> = Arc::from(vec![0u32]);
2232 let columns: Arc<[ColumnDef<u32>]> = Arc::from(vec![{
2233 let mut col = ColumnDef::new("name");
2234 col.size = 220.0;
2235 col
2236 }]);
2237 let scroll = VirtualListScrollHandle::new();
2238
2239 let bounds = Rect::new(
2240 Point::new(Px(0.0), Px(0.0)),
2241 fret_core::Size::new(Px(320.0), Px(240.0)),
2242 );
2243 let mut services = FakeServices;
2244
2245 let props = TableViewProps {
2246 draw_frame: false,
2247 row_height: Some(Px(36.0)),
2248 header_height: Some(Px(40.0)),
2249 ..Default::default()
2250 };
2251
2252 let render = |ui: &mut UiTree<App>,
2253 app: &mut App,
2254 services: &mut FakeServices|
2255 -> fret_core::NodeId {
2256 fret_ui::declarative::render_root(ui, app, services, window, bounds, "test", |cx| {
2257 vec![table_virtualized_retained_v0(
2258 cx,
2259 data.clone(),
2260 columns.clone(),
2261 state.clone(),
2262 &scroll,
2263 0,
2264 Arc::new(|_row: &u32, index: usize| RowKey::from_index(index)),
2265 None,
2266 props.clone(),
2267 Arc::new(|col: &ColumnDef<u32>| Arc::from(col.id.as_ref())),
2268 None,
2269 Arc::new(
2270 |cx: &mut dyn ElementContextAccess<'_, App>,
2271 _col: &ColumnDef<u32>,
2272 _row: &u32| {
2273 crate::ui::text("Row 0").into_element(cx.elements())
2274 },
2275 ),
2276 TableDebugIds {
2277 header_row_test_id: Some(Arc::<str>::from("table-test-header-row")),
2278 header_cell_test_id_prefix: Some(Arc::<str>::from("table-test-header-")),
2279 row_test_id_prefix: Some(Arc::<str>::from("table-test-row-")),
2280 },
2281 )]
2282 })
2283 };
2284
2285 // VirtualList computes the visible window based on viewport metrics populated during layout,
2286 // so it takes two frames for the first set of rows to mount.
2287 for _ in 0..2 {
2288 let root = render(&mut ui, &mut app, &mut services);
2289 ui.set_root(root);
2290 ui.request_semantics_snapshot();
2291 ui.layout_all(&mut app, &mut services, bounds, 1.0);
2292 let mut scene = fret_core::Scene::default();
2293 ui.paint_all(&mut app, &mut services, bounds, &mut scene, 1.0);
2294 }
2295
2296 let snap = ui
2297 .semantics_snapshot()
2298 .expect("expected a semantics snapshot");
2299
2300 let find_height = |id: &str| -> f32 {
2301 snap.nodes
2302 .iter()
2303 .find(|n| n.test_id.as_deref() == Some(id))
2304 .map(|n| n.bounds.size.height.0)
2305 .unwrap_or_else(|| panic!("expected to find {id}"))
2306 };
2307
2308 let header_h = find_height("table-test-header-row");
2309 let body_h = find_height("table-test-row-0");
2310
2311 let eps = 2.0;
2312 assert!(
2313 (header_h - 40.0).abs() <= eps,
2314 "expected header height ~40px (got {header_h:.2}px)"
2315 );
2316 assert!(
2317 (body_h - 36.0).abs() <= eps,
2318 "expected body height ~36px (got {body_h:.2}px)"
2319 );
2320 }
2321
2322 #[test]
2323 fn table_virtualized_retained_pointer_row_selection_policy_list_like() {
2324 let window = AppWindowId::default();
2325 let mut app = App::new();
2326 let mut ui: UiTree<App> = UiTree::new();
2327 ui.set_window(window);
2328
2329 Theme::with_global_mut(&mut app, |theme| {
2330 theme.apply_config(&ThemeConfig {
2331 name: "Test".to_string(),
2332 ..ThemeConfig::default()
2333 });
2334 });
2335
2336 let mut state_value = TableState::default();
2337 state_value.pagination.page_size = 5;
2338 let state = app.models_mut().insert(state_value);
2339
2340 let data: Arc<[u32]> = Arc::from((0u32..5).collect::<Vec<_>>());
2341 let columns: Arc<[ColumnDef<u32>]> = Arc::from(vec![{
2342 let mut col = ColumnDef::new("name");
2343 col.size = 220.0;
2344 col
2345 }]);
2346
2347 let scroll = VirtualListScrollHandle::new();
2348 let bounds = Rect::new(
2349 Point::new(Px(0.0), Px(0.0)),
2350 fret_core::Size::new(Px(320.0), Px(240.0)),
2351 );
2352 let mut services = FakeServices;
2353
2354 let props = TableViewProps {
2355 draw_frame: false,
2356 enable_column_resizing: false,
2357 enable_row_selection: true,
2358 pointer_row_selection: true,
2359 pointer_row_selection_policy: PointerRowSelectionPolicy::ListLike,
2360 single_row_selection: false,
2361 row_height: Some(Px(40.0)),
2362 header_height: Some(Px(40.0)),
2363 ..Default::default()
2364 };
2365
2366 let render = |ui: &mut UiTree<App>,
2367 app: &mut App,
2368 services: &mut FakeServices|
2369 -> fret_core::NodeId {
2370 fret_ui::declarative::render_root(ui, app, services, window, bounds, "test", |cx| {
2371 vec![table_virtualized_retained_v0(
2372 cx,
2373 data.clone(),
2374 columns.clone(),
2375 state.clone(),
2376 &scroll,
2377 0,
2378 Arc::new(|_row: &u32, index: usize| RowKey::from_index(index)),
2379 None,
2380 props.clone(),
2381 Arc::new(|col: &ColumnDef<u32>| Arc::from(col.id.as_ref())),
2382 None,
2383 Arc::new(
2384 |cx: &mut dyn ElementContextAccess<'_, App>,
2385 _col: &ColumnDef<u32>,
2386 row: &u32| {
2387 crate::ui::text(format!("Row {row}")).into_element(cx.elements())
2388 },
2389 ),
2390 TableDebugIds {
2391 header_cell_test_id_prefix: Some(Arc::<str>::from("table-test-header-")),
2392 row_test_id_prefix: Some(Arc::<str>::from("table-test-row-")),
2393 ..Default::default()
2394 },
2395 )]
2396 })
2397 };
2398
2399 // VirtualList computes the visible window based on viewport metrics populated during layout,
2400 // so it takes two frames for the first set of rows to mount.
2401 for _ in 0..2 {
2402 let root = render(&mut ui, &mut app, &mut services);
2403 ui.set_root(root);
2404 ui.request_semantics_snapshot();
2405 ui.layout_all(&mut app, &mut services, bounds, 1.0);
2406 let mut scene = fret_core::Scene::default();
2407 ui.paint_all(&mut app, &mut services, bounds, &mut scene, 1.0);
2408 }
2409
2410 let click_pos_for_row: Vec<Point> = {
2411 let snap = ui
2412 .semantics_snapshot()
2413 .expect("expected a semantics snapshot");
2414 (0..5)
2415 .map(|row_index| {
2416 let id = format!("table-test-row-{row_index}");
2417 let bounds = snap
2418 .nodes
2419 .iter()
2420 .find(|n| n.test_id.as_deref() == Some(id.as_str()))
2421 .map(|n| n.bounds)
2422 .unwrap_or_else(|| panic!("expected row node {id}"));
2423 Point::new(
2424 Px(bounds.origin.x.0 + bounds.size.width.0 * 0.5),
2425 Px(bounds.origin.y.0 + bounds.size.height.0 * 0.5),
2426 )
2427 })
2428 .collect()
2429 };
2430
2431 let click_row = |ui: &mut UiTree<App>,
2432 app: &mut App,
2433 services: &mut FakeServices,
2434 row_index: usize,
2435 modifiers: Modifiers| {
2436 let click_pos = click_pos_for_row[row_index];
2437 ui.dispatch_event(
2438 app,
2439 services,
2440 &Event::Pointer(PointerEvent::Down {
2441 position: click_pos,
2442 button: MouseButton::Left,
2443 modifiers,
2444 click_count: 1,
2445 pointer_id: PointerId(0),
2446 pointer_type: PointerType::Mouse,
2447 }),
2448 );
2449 ui.dispatch_event(
2450 app,
2451 services,
2452 &Event::Pointer(PointerEvent::Up {
2453 position: click_pos,
2454 button: MouseButton::Left,
2455 modifiers,
2456 click_count: 1,
2457 is_click: true,
2458 pointer_id: PointerId(0),
2459 pointer_type: PointerType::Mouse,
2460 }),
2461 );
2462 };
2463
2464 let assert_selected = |app: &App, expected: &[RowKey]| {
2465 let selection = app
2466 .models()
2467 .read(&state, |st| st.row_selection.clone())
2468 .ok()
2469 .unwrap_or_default();
2470 assert_eq!(
2471 selection.len(),
2472 expected.len(),
2473 "expected selection len {} but got {}",
2474 expected.len(),
2475 selection.len()
2476 );
2477 for k in expected {
2478 assert!(selection.contains(k), "expected selection to contain {k:?}");
2479 }
2480 };
2481
2482 click_row(&mut ui, &mut app, &mut services, 1, Modifiers::default());
2483 assert_selected(&app, &[RowKey::from_index(1)]);
2484
2485 click_row(
2486 &mut ui,
2487 &mut app,
2488 &mut services,
2489 3,
2490 Modifiers {
2491 shift: true,
2492 ..Default::default()
2493 },
2494 );
2495 assert_selected(
2496 &app,
2497 &[
2498 RowKey::from_index(1),
2499 RowKey::from_index(2),
2500 RowKey::from_index(3),
2501 ],
2502 );
2503
2504 click_row(
2505 &mut ui,
2506 &mut app,
2507 &mut services,
2508 4,
2509 Modifiers {
2510 ctrl: true,
2511 ..Default::default()
2512 },
2513 );
2514 assert_selected(
2515 &app,
2516 &[
2517 RowKey::from_index(1),
2518 RowKey::from_index(2),
2519 RowKey::from_index(3),
2520 RowKey::from_index(4),
2521 ],
2522 );
2523
2524 click_row(&mut ui, &mut app, &mut services, 2, Modifiers::default());
2525 assert_selected(&app, &[RowKey::from_index(2)]);
2526 }
2527
2528 #[test]
2529 fn table_virtualized_retained_nested_pressable_remains_hittable_when_pointer_row_selection_disabled()
2530 {
2531 use std::cell::Cell;
2532 use std::rc::Rc;
2533
2534 let window = AppWindowId::default();
2535 let mut app = App::new();
2536 let mut ui: UiTree<App> = UiTree::new();
2537 ui.set_window(window);
2538 ui.set_debug_enabled(true);
2539
2540 Theme::with_global_mut(&mut app, |theme| {
2541 theme.apply_config(&ThemeConfig {
2542 name: "Test".to_string(),
2543 ..ThemeConfig::default()
2544 });
2545 });
2546
2547 let mut state_value = TableState::default();
2548 state_value.pagination.page_size = 3;
2549 let state = app.models_mut().insert(state_value);
2550 let child_activated = app.models_mut().insert(false);
2551 let child_element: Rc<Cell<Option<fret_ui::GlobalElementId>>> = Rc::new(Cell::new(None));
2552
2553 let data: Arc<[u32]> = Arc::from(vec![0u32, 1u32, 2u32]);
2554 let columns: Arc<[ColumnDef<u32>]> = Arc::from(vec![
2555 {
2556 let mut col = ColumnDef::new("name");
2557 col.size = 180.0;
2558 col
2559 },
2560 {
2561 let mut col = ColumnDef::new("actions");
2562 col.size = 80.0;
2563 col
2564 },
2565 ]);
2566 let scroll = VirtualListScrollHandle::new();
2567
2568 let bounds = Rect::new(
2569 Point::new(Px(0.0), Px(0.0)),
2570 fret_core::Size::new(Px(320.0), Px(180.0)),
2571 );
2572 let mut services = FakeServices;
2573
2574 let props = TableViewProps {
2575 draw_frame: false,
2576 enable_column_resizing: false,
2577 enable_row_selection: true,
2578 pointer_row_selection: false,
2579 single_row_selection: true,
2580 row_height: Some(Px(40.0)),
2581 header_height: Some(Px(40.0)),
2582 ..Default::default()
2583 };
2584
2585 let render = |ui: &mut UiTree<App>,
2586 app: &mut App,
2587 services: &mut FakeServices|
2588 -> fret_core::NodeId {
2589 let child_element = child_element.clone();
2590 let child_activated = child_activated.clone();
2591 fret_ui::declarative::render_root(ui, app, services, window, bounds, "test", |cx| {
2592 vec![table_virtualized_retained_v0(
2593 cx,
2594 data.clone(),
2595 columns.clone(),
2596 state.clone(),
2597 &scroll,
2598 0,
2599 Arc::new(|_row: &u32, index: usize| RowKey::from_index(index)),
2600 None,
2601 props.clone(),
2602 Arc::new(|col: &ColumnDef<u32>| Arc::from(col.id.as_ref())),
2603 None,
2604 Arc::new(
2605 move |cx: &mut dyn ElementContextAccess<'_, App>,
2606 col: &ColumnDef<u32>,
2607 row: &u32| {
2608 let cx = cx.elements();
2609 match col.id.as_ref() {
2610 "name" => crate::ui::text(format!("Row {row}")).into_element(cx),
2611 "actions" if *row == 1 => {
2612 let child_element = child_element.clone();
2613 let child_activated = child_activated.clone();
2614 cx.pressable_with_id(
2615 PressableProps {
2616 focusable: false,
2617 layout: LayoutStyle {
2618 size: fret_ui::element::SizeStyle {
2619 width: Length::Px(Px(24.0)),
2620 height: Length::Px(Px(24.0)),
2621 ..Default::default()
2622 },
2623 ..Default::default()
2624 },
2625 a11y: PressableA11y {
2626 role: Some(SemanticsRole::Button),
2627 test_id: Some(Arc::<str>::from(
2628 "table-retained-test-child-button",
2629 )),
2630 ..Default::default()
2631 },
2632 ..Default::default()
2633 },
2634 move |cx, _st, id| {
2635 child_element.set(Some(id));
2636 let child_activated = child_activated.clone();
2637 cx.pressable_on_activate(Arc::new(
2638 move |host, _acx, _reason| {
2639 let _ = host
2640 .models_mut()
2641 .update(&child_activated, |value| {
2642 *value = true
2643 });
2644 },
2645 ));
2646 vec![cx.spacer(SpacerProps::default())]
2647 },
2648 )
2649 }
2650 "actions" => cx.text("-"),
2651 _ => cx.text("?"),
2652 }
2653 },
2654 ),
2655 TableDebugIds {
2656 header_cell_test_id_prefix: Some(Arc::<str>::from(
2657 "table-retained-test-header-",
2658 )),
2659 row_test_id_prefix: Some(Arc::<str>::from("table-retained-test-row-")),
2660 ..Default::default()
2661 },
2662 )]
2663 })
2664 };
2665
2666 for _ in 0..2 {
2667 let root = render(&mut ui, &mut app, &mut services);
2668 ui.set_root(root);
2669 ui.request_semantics_snapshot();
2670 ui.layout_all(&mut app, &mut services, bounds, 1.0);
2671 let mut scene = fret_core::Scene::default();
2672 ui.paint_all(&mut app, &mut services, bounds, &mut scene, 1.0);
2673 }
2674
2675 let child_element = child_element
2676 .get()
2677 .expect("expected nested child pressable element");
2678 let child_node = fret_ui::elements::node_for_element(&mut app, window, child_element)
2679 .expect("expected nested child pressable node");
2680 let child_bounds = ui
2681 .debug_node_bounds(child_node)
2682 .expect("expected nested child bounds");
2683 assert!(
2684 child_bounds.size.width.0 > 0.0 && child_bounds.size.height.0 > 0.0,
2685 "expected nested child pressable to have non-zero bounds, got {child_bounds:?}"
2686 );
2687 let click_pos = Point::new(
2688 Px(child_bounds.origin.x.0 + child_bounds.size.width.0 * 0.5),
2689 Px(child_bounds.origin.y.0 + child_bounds.size.height.0 * 0.5),
2690 );
2691
2692 let hit = ui.debug_hit_test_routing(click_pos);
2693 let hit_node = hit.hit.expect("expected nested child hit");
2694 let path = ui.debug_node_path(hit_node);
2695 let child_path = ui.debug_node_path(child_node);
2696 let child_path_debug = child_path
2697 .iter()
2698 .map(|node| {
2699 (
2700 *node,
2701 ui.debug_node_bounds(*node),
2702 ui.debug_node_clips_hit_test(*node),
2703 ui.debug_node_can_scroll_descendant_into_view(*node),
2704 )
2705 })
2706 .collect::<Vec<_>>();
2707
2708 ui.dispatch_event(
2709 &mut app,
2710 &mut services,
2711 &Event::Pointer(PointerEvent::Down {
2712 position: click_pos,
2713 button: MouseButton::Left,
2714 modifiers: Modifiers::default(),
2715 click_count: 1,
2716 pointer_id: PointerId(0),
2717 pointer_type: PointerType::Mouse,
2718 }),
2719 );
2720 ui.dispatch_event(
2721 &mut app,
2722 &mut services,
2723 &Event::Pointer(PointerEvent::Up {
2724 position: click_pos,
2725 button: MouseButton::Left,
2726 modifiers: Modifiers::default(),
2727 click_count: 1,
2728 is_click: true,
2729 pointer_id: PointerId(0),
2730 pointer_type: PointerType::Mouse,
2731 }),
2732 );
2733
2734 assert_eq!(
2735 app.models().get_copied(&child_activated),
2736 Some(true),
2737 "expected nested child pressable to activate; hit={hit:?} path={path:?} child={child_node:?} child_path={child_path:?} child_path_debug={child_path_debug:?} child_bounds={child_bounds:?}"
2738 );
2739
2740 let selection = app
2741 .models()
2742 .read(&state, |st| st.row_selection.clone())
2743 .ok()
2744 .unwrap_or_default();
2745 assert!(
2746 selection.is_empty(),
2747 "expected nested child pressable click not to toggle row selection when pointer_row_selection=false"
2748 );
2749 }
2750
2751 #[test]
2752 fn table_active_descendant_semantics_resolves_from_declarative_active_row_relation() {
2753 let window = AppWindowId::default();
2754 let mut app = App::new();
2755 let mut ui: UiTree<App> = UiTree::new();
2756 ui.set_window(window);
2757
2758 Theme::with_global_mut(&mut app, |theme| {
2759 theme.apply_config(&ThemeConfig {
2760 name: "Test".to_string(),
2761 ..ThemeConfig::default()
2762 });
2763 });
2764
2765 let mut state_value = TableState::default();
2766 state_value.pagination.page_size = 3;
2767 let state = app.models_mut().insert(state_value);
2768
2769 let data: Arc<[u32]> = Arc::from(vec![0u32, 1u32, 2u32]);
2770 let columns: Arc<[ColumnDef<u32>]> = Arc::from(vec![{
2771 let mut col = ColumnDef::new("name");
2772 col.size = 220.0;
2773 col
2774 }]);
2775 let scroll = VirtualListScrollHandle::new();
2776
2777 let bounds = Rect::new(
2778 Point::new(Px(0.0), Px(0.0)),
2779 fret_core::Size::new(Px(320.0), Px(240.0)),
2780 );
2781 let mut services = FakeServices;
2782
2783 let props = TableViewProps {
2784 draw_frame: false,
2785 row_height: Some(Px(36.0)),
2786 header_height: Some(Px(40.0)),
2787 ..Default::default()
2788 };
2789
2790 let render = |ui: &mut UiTree<App>,
2791 app: &mut App,
2792 services: &mut FakeServices|
2793 -> fret_core::NodeId {
2794 fret_ui::declarative::render_root(ui, app, services, window, bounds, "test", |cx| {
2795 vec![table_virtualized_retained_v0(
2796 cx,
2797 data.clone(),
2798 columns.clone(),
2799 state.clone(),
2800 &scroll,
2801 0,
2802 Arc::new(|_row: &u32, index: usize| RowKey::from_index(index)),
2803 None,
2804 props.clone(),
2805 Arc::new(|col: &ColumnDef<u32>| Arc::from(col.id.as_ref())),
2806 None,
2807 Arc::new(
2808 |cx: &mut dyn ElementContextAccess<'_, App>,
2809 _col: &ColumnDef<u32>,
2810 row: &u32| {
2811 crate::ui::text(format!("Row {row}")).into_element(cx.elements())
2812 },
2813 ),
2814 TableDebugIds {
2815 row_test_id_prefix: Some(Arc::<str>::from("table-active-desc-row-")),
2816 ..Default::default()
2817 },
2818 )]
2819 })
2820 };
2821
2822 let pump =
2823 |ui: &mut UiTree<App>, app: &mut App, services: &mut FakeServices, frames: usize| {
2824 for _ in 0..frames {
2825 let root = render(ui, app, services);
2826 ui.set_root(root);
2827 ui.request_semantics_snapshot();
2828 ui.layout_all(app, services, bounds, 1.0);
2829 let mut scene = fret_core::Scene::default();
2830 ui.paint_all(app, services, bounds, &mut scene, 1.0);
2831 }
2832 };
2833
2834 let row_center = |snap: &fret_core::SemanticsSnapshot, row_index: usize| {
2835 let id = format!("table-active-desc-row-{row_index}");
2836 let bounds = snap
2837 .nodes
2838 .iter()
2839 .find(|node| node.test_id.as_deref() == Some(id.as_str()))
2840 .map(|node| node.bounds)
2841 .unwrap_or_else(|| panic!("expected row semantics node `{id}`"));
2842 Point::new(
2843 Px(bounds.origin.x.0 + bounds.size.width.0 * 0.5),
2844 Px(bounds.origin.y.0 + bounds.size.height.0 * 0.5),
2845 )
2846 };
2847
2848 pump(&mut ui, &mut app, &mut services, 2);
2849
2850 let initial_snap = ui
2851 .semantics_snapshot()
2852 .expect("expected semantics snapshot after initial table render");
2853 let click_pos = row_center(initial_snap, 1);
2854 ui.dispatch_event(
2855 &mut app,
2856 &mut services,
2857 &Event::Pointer(PointerEvent::Down {
2858 position: click_pos,
2859 button: MouseButton::Left,
2860 modifiers: Modifiers::default(),
2861 click_count: 1,
2862 pointer_id: PointerId(0),
2863 pointer_type: PointerType::Mouse,
2864 }),
2865 );
2866 ui.dispatch_event(
2867 &mut app,
2868 &mut services,
2869 &Event::Pointer(PointerEvent::Up {
2870 position: click_pos,
2871 button: MouseButton::Left,
2872 modifiers: Modifiers::default(),
2873 click_count: 1,
2874 is_click: true,
2875 pointer_id: PointerId(0),
2876 pointer_type: PointerType::Mouse,
2877 }),
2878 );
2879
2880 // Frame N+1 records the active row element while rebuilding the list body.
2881 // Frame N+2 lets the parent list semantics resolve the declarative relation against the
2882 // now-mounted row node for the current frame.
2883 pump(&mut ui, &mut app, &mut services, 2);
2884
2885 let snap = ui
2886 .semantics_snapshot()
2887 .expect("expected semantics snapshot after activating a row");
2888 let row = snap
2889 .nodes
2890 .iter()
2891 .find(|node| node.test_id.as_deref() == Some("table-active-desc-row-1"))
2892 .expect("expected active row semantics node");
2893 let list = snap
2894 .nodes
2895 .iter()
2896 .find(|node| node.role == SemanticsRole::List)
2897 .expect("expected table list semantics node after activation");
2898
2899 assert_eq!(
2900 list.active_descendant,
2901 Some(row.id),
2902 "expected table list semantics to resolve active_descendant to the mounted active row node"
2903 );
2904 }
2905
2906 #[test]
2907 fn table_virtualized_retained_colpin_alignment_gate_across_pin_resize_and_overflow() {
2908 let window = AppWindowId::default();
2909 let mut app = App::new();
2910 let mut ui: UiTree<App> = UiTree::new();
2911 ui.set_window(window);
2912
2913 Theme::with_global_mut(&mut app, |theme| {
2914 theme.apply_config(&ThemeConfig {
2915 name: "Test".to_string(),
2916 ..ThemeConfig::default()
2917 });
2918 });
2919
2920 let mut initial = TableState::default();
2921 initial.pagination.page_size = 8;
2922 initial.column_pinning.left = vec!["a".into()];
2923 initial.column_pinning.right = vec!["d".into()];
2924 let state = app.models_mut().insert(initial);
2925
2926 let data: Arc<[u32]> = Arc::from((0u32..32).collect::<Vec<_>>());
2927 let mut col_a = ColumnDef::new("a");
2928 col_a.size = 120.0;
2929 let mut col_b = ColumnDef::new("b");
2930 col_b.size = 280.0;
2931 let mut col_c = ColumnDef::new("c");
2932 col_c.size = 240.0;
2933 let mut col_d = ColumnDef::new("d");
2934 col_d.size = 140.0;
2935 let columns: Arc<[ColumnDef<u32>]> = Arc::from(vec![col_a, col_b, col_c, col_d]);
2936
2937 let scroll = VirtualListScrollHandle::new();
2938 let bounds = Rect::new(
2939 Point::new(Px(0.0), Px(0.0)),
2940 fret_core::Size::new(Px(360.0), Px(220.0)),
2941 );
2942 let mut services = FakeServices;
2943
2944 let render = |ui: &mut UiTree<App>,
2945 app: &mut App,
2946 services: &mut FakeServices|
2947 -> fret_core::NodeId {
2948 fret_ui::declarative::render_root(ui, app, services, window, bounds, "test", |cx| {
2949 let props = TableViewProps {
2950 overscan: 4,
2951 enable_column_grouping: false,
2952 ..Default::default()
2953 };
2954
2955 let table = table_virtualized_retained_v0(
2956 cx,
2957 data.clone(),
2958 columns.clone(),
2959 state.clone(),
2960 &scroll,
2961 0,
2962 Arc::new(|_row: &u32, index: usize| RowKey::from_index(index)),
2963 None,
2964 props,
2965 Arc::new(|col: &ColumnDef<u32>| Arc::from(col.id.as_ref())),
2966 None,
2967 Arc::new(
2968 |cx: &mut dyn ElementContextAccess<'_, App>,
2969 col: &ColumnDef<u32>,
2970 row: &u32| {
2971 let cx = cx.elements();
2972 cx.text(format!("{}-{row}", col.id.as_ref()))
2973 },
2974 ),
2975 TableDebugIds {
2976 header_cell_test_id_prefix: Some(Arc::<str>::from(
2977 "table-retained-colpin-header-",
2978 )),
2979 row_test_id_prefix: Some(Arc::<str>::from("table-retained-colpin-row-")),
2980 ..Default::default()
2981 },
2982 );
2983
2984 vec![cx.semantics(
2985 SemanticsProps {
2986 test_id: Some(Arc::<str>::from("table-retained-colpin-root")),
2987 ..Default::default()
2988 },
2989 move |_cx| vec![table],
2990 )]
2991 })
2992 };
2993
2994 let pump = |ui: &mut UiTree<App>,
2995 app: &mut App,
2996 services: &mut FakeServices,
2997 root: &mut fret_core::NodeId| {
2998 for _ in 0..2 {
2999 *root = render(ui, app, services);
3000 ui.set_root(*root);
3001 ui.request_semantics_snapshot();
3002 ui.layout_all(app, services, bounds, 1.0);
3003 let mut scene = fret_core::Scene::default();
3004 ui.paint_all(app, services, bounds, &mut scene, 1.0);
3005 }
3006 };
3007
3008 let find_bounds = |snap: &fret_core::SemanticsSnapshot, id: &str| {
3009 snap.nodes
3010 .iter()
3011 .find(|n| n.test_id.as_deref() == Some(id))
3012 .map(|n| n.bounds)
3013 .unwrap_or_else(|| panic!("expected semantics node `{id}`"))
3014 };
3015
3016 let assert_aligned = |snap: &fret_core::SemanticsSnapshot, col: &str| {
3017 let header_id = format!("table-retained-colpin-header-{col}");
3018 let row_id = format!("table-retained-colpin-row-0-cell-{col}");
3019 let header = find_bounds(snap, &header_id);
3020 let body = find_bounds(snap, &row_id);
3021 let dx = (header.origin.x.0 - body.origin.x.0).abs();
3022 let dw = (header.size.width.0 - body.size.width.0).abs();
3023 assert!(
3024 dx <= 1.0,
3025 "expected header/body x alignment for `{col}` (dx={dx:.2})"
3026 );
3027 assert!(
3028 dw <= 1.0,
3029 "expected header/body width alignment for `{col}` (dw={dw:.2})"
3030 );
3031 };
3032
3033 let mut root = fret_core::NodeId::default();
3034 pump(&mut ui, &mut app, &mut services, &mut root);
3035
3036 let mut snap = ui
3037 .semantics_snapshot()
3038 .expect("expected semantics snapshot after initial render");
3039 for col in ["a", "b", "c", "d"] {
3040 assert_aligned(snap, col);
3041 }
3042
3043 let _ = app.models_mut().update(&state, |st| {
3044 st.column_pinning.left = vec!["a".into()];
3045 st.column_pinning.right = vec!["d".into()];
3046 st.column_sizing.insert("b".into(), 320.0);
3047 st.column_sizing.insert("c".into(), 260.0);
3048 });
3049 pump(&mut ui, &mut app, &mut services, &mut root);
3050 snap = ui
3051 .semantics_snapshot()
3052 .expect("expected semantics snapshot after resize update");
3053 for col in ["a", "b", "c", "d"] {
3054 assert_aligned(snap, col);
3055 }
3056
3057 let root_bounds = find_bounds(snap, "table-retained-colpin-root");
3058 let center_bounds = find_bounds(snap, "table-retained-colpin-header-c");
3059 let root_right = root_bounds.origin.x.0 + root_bounds.size.width.0;
3060 let center_right = center_bounds.origin.x.0 + center_bounds.size.width.0;
3061 assert!(
3062 center_right > root_right + 1.0,
3063 "expected center region overflow to exist (root_right={root_right:.2}, center_right={center_right:.2})"
3064 );
3065
3066 let _ = app.models_mut().update(&state, |st| {
3067 st.column_pinning.left = vec!["a".into()];
3068 st.column_pinning.right = vec!["c".into(), "d".into()];
3069 });
3070 pump(&mut ui, &mut app, &mut services, &mut root);
3071 snap = ui
3072 .semantics_snapshot()
3073 .expect("expected semantics snapshot after pin/unpin update");
3074
3075 for col in ["a", "b", "c", "d"] {
3076 assert_aligned(snap, col);
3077 }
3078 }
3079
3080 #[test]
3081 fn table_virtualized_retained_colpin_alignment_gate_measured_rows_do_not_shrink_width() {
3082 let window = AppWindowId::default();
3083 let mut app = App::new();
3084 let mut ui: UiTree<App> = UiTree::new();
3085 ui.set_window(window);
3086
3087 Theme::with_global_mut(&mut app, |theme| {
3088 theme.apply_config(&ThemeConfig {
3089 name: "Test".to_string(),
3090 ..ThemeConfig::default()
3091 });
3092 });
3093
3094 let mut initial = TableState::default();
3095 initial.pagination.page_size = 8;
3096 initial.column_pinning.left = vec!["a".into()];
3097 initial.column_pinning.right = vec!["d".into()];
3098 let state = app.models_mut().insert(initial);
3099
3100 let data: Arc<[u32]> = Arc::from((0u32..32).collect::<Vec<_>>());
3101 let mut col_a = ColumnDef::new("a");
3102 col_a.size = 120.0;
3103 let mut col_b = ColumnDef::new("b");
3104 col_b.size = 280.0;
3105 let mut col_c = ColumnDef::new("c");
3106 col_c.size = 240.0;
3107 let mut col_d = ColumnDef::new("d");
3108 col_d.size = 140.0;
3109 let columns: Arc<[ColumnDef<u32>]> = Arc::from(vec![col_a, col_b, col_c, col_d]);
3110
3111 let scroll = VirtualListScrollHandle::new();
3112 let bounds = Rect::new(
3113 Point::new(Px(0.0), Px(0.0)),
3114 fret_core::Size::new(Px(360.0), Px(220.0)),
3115 );
3116 let mut services = FakeServices;
3117
3118 let render = |ui: &mut UiTree<App>,
3119 app: &mut App,
3120 services: &mut FakeServices|
3121 -> fret_core::NodeId {
3122 fret_ui::declarative::render_root(ui, app, services, window, bounds, "test", |cx| {
3123 let props = TableViewProps {
3124 overscan: 4,
3125 enable_column_grouping: false,
3126 row_height: Some(Px(28.0)),
3127 row_measure_mode: TableRowMeasureMode::Measured,
3128 ..Default::default()
3129 };
3130
3131 let table = table_virtualized_retained_v0(
3132 cx,
3133 data.clone(),
3134 columns.clone(),
3135 state.clone(),
3136 &scroll,
3137 0,
3138 Arc::new(|_row: &u32, index: usize| RowKey::from_index(index)),
3139 None,
3140 props,
3141 Arc::new(|col: &ColumnDef<u32>| Arc::from(col.id.as_ref())),
3142 None,
3143 Arc::new(
3144 |cx: &mut dyn ElementContextAccess<'_, App>,
3145 col: &ColumnDef<u32>,
3146 row: &u32| {
3147 let cx = cx.elements();
3148 if *row == 0 && col.id.as_ref() == "b" {
3149 ui::v_stack(|cx| [cx.text("b-0"), cx.text("extra line")])
3150 .gap(Space::N0)
3151 .into_element(cx)
3152 } else {
3153 cx.text(format!("{}-{row}", col.id.as_ref()))
3154 }
3155 },
3156 ),
3157 TableDebugIds {
3158 header_cell_test_id_prefix: Some(Arc::<str>::from(
3159 "table-retained-colpin-header-",
3160 )),
3161 row_test_id_prefix: Some(Arc::<str>::from("table-retained-colpin-row-")),
3162 ..Default::default()
3163 },
3164 );
3165
3166 vec![cx.semantics(
3167 SemanticsProps {
3168 test_id: Some(Arc::<str>::from("table-retained-colpin-root")),
3169 ..Default::default()
3170 },
3171 move |_cx| vec![table],
3172 )]
3173 })
3174 };
3175
3176 let pump = |ui: &mut UiTree<App>,
3177 app: &mut App,
3178 services: &mut FakeServices,
3179 root: &mut fret_core::NodeId| {
3180 for _ in 0..2 {
3181 *root = render(ui, app, services);
3182 ui.set_root(*root);
3183 ui.request_semantics_snapshot();
3184 ui.layout_all(app, services, bounds, 1.0);
3185 let mut scene = fret_core::Scene::default();
3186 ui.paint_all(app, services, bounds, &mut scene, 1.0);
3187 }
3188 };
3189
3190 let find_bounds = |snap: &fret_core::SemanticsSnapshot, id: &str| {
3191 snap.nodes
3192 .iter()
3193 .find(|n| n.test_id.as_deref() == Some(id))
3194 .map(|n| n.bounds)
3195 .unwrap_or_else(|| panic!("expected semantics node `{id}`"))
3196 };
3197
3198 let assert_aligned = |snap: &fret_core::SemanticsSnapshot, col: &str| {
3199 let header_id = format!("table-retained-colpin-header-{col}");
3200 let row_id = format!("table-retained-colpin-row-0-cell-{col}");
3201 let header = find_bounds(snap, &header_id);
3202 let body = find_bounds(snap, &row_id);
3203 let dx = (header.origin.x.0 - body.origin.x.0).abs();
3204 let dw = (header.size.width.0 - body.size.width.0).abs();
3205 assert!(
3206 dx <= 1.0,
3207 "expected header/body x alignment for `{col}` (dx={dx:.2})"
3208 );
3209 assert!(
3210 dw <= 1.0,
3211 "expected header/body width alignment for `{col}` (dw={dw:.2})"
3212 );
3213 };
3214
3215 let mut root = fret_core::NodeId::default();
3216 pump(&mut ui, &mut app, &mut services, &mut root);
3217
3218 let snap = ui
3219 .semantics_snapshot()
3220 .expect("expected semantics snapshot after initial render");
3221
3222 for col in ["a", "b", "c", "d"] {
3223 assert_aligned(snap, col);
3224 }
3225 }
3226}
3227
3228#[derive(Debug, Clone)]
3229enum DisplayRow {
3230 Leaf {
3231 data_index: usize,
3232 row_key: RowKey,
3233 depth: usize,
3234 },
3235 Group {
3236 grouping_column: ColumnId,
3237 row_key: RowKey,
3238 depth: usize,
3239 label: Arc<str>,
3240 expanded: bool,
3241 aggregations: Arc<[(ColumnId, Arc<str>)]>,
3242 },
3243}
3244
3245impl DisplayRow {
3246 fn row_key(&self) -> RowKey {
3247 match self {
3248 DisplayRow::Leaf { row_key, .. } | DisplayRow::Group { row_key, .. } => *row_key,
3249 }
3250 }
3251}
3252
3253fn apply_row_pinning_to_paged_rows(
3254 visible_all: &[DisplayRow],
3255 page_rows: &[DisplayRow],
3256 row_pinning: &crate::headless::table::RowPinningState,
3257) -> Vec<DisplayRow> {
3258 if row_pinning.top.is_empty() && row_pinning.bottom.is_empty() {
3259 return page_rows.to_vec();
3260 }
3261
3262 let mut pinned: std::collections::HashSet<RowKey> = Default::default();
3263 pinned.extend(row_pinning.top.iter().copied());
3264 pinned.extend(row_pinning.bottom.iter().copied());
3265
3266 let mut by_key: std::collections::HashMap<RowKey, DisplayRow> =
3267 std::collections::HashMap::new();
3268 for row in visible_all {
3269 by_key.entry(row.row_key()).or_insert_with(|| row.clone());
3270 }
3271
3272 let mut out: Vec<DisplayRow> =
3273 Vec::with_capacity(row_pinning.top.len() + page_rows.len() + row_pinning.bottom.len());
3274
3275 for row_key in &row_pinning.top {
3276 if let Some(row) = by_key.get(row_key) {
3277 out.push(row.clone());
3278 }
3279 }
3280
3281 out.extend(
3282 page_rows
3283 .iter()
3284 .filter(|row| !pinned.contains(&row.row_key()))
3285 .cloned(),
3286 );
3287
3288 for row_key in &row_pinning.bottom {
3289 if let Some(row) = by_key.get(row_key) {
3290 out.push(row.clone());
3291 }
3292 }
3293
3294 out
3295}
3296
3297fn apply_grouped_row_pinning_policy(
3298 visible_all: &[DisplayRow],
3299 page_rows_center: &[DisplayRow],
3300 row_pinning: &crate::headless::table::RowPinningState,
3301 policy: GroupedRowPinningPolicy,
3302) -> Vec<DisplayRow> {
3303 match policy {
3304 GroupedRowPinningPolicy::PreserveHierarchy => page_rows_center.to_vec(),
3305 GroupedRowPinningPolicy::PromotePinnedRows => {
3306 apply_row_pinning_to_paged_rows(visible_all, page_rows_center, row_pinning)
3307 }
3308 }
3309}
3310
3311#[derive(Debug, Clone, PartialEq)]
3312struct GroupedBaseDeps {
3313 items_revision: u64,
3314 data_len: usize,
3315 columns_fingerprint: u64,
3316 grouping: Vec<ColumnId>,
3317 column_filters: crate::headless::table::ColumnFiltersState,
3318 global_filter: crate::headless::table::GlobalFilterState,
3319}
3320
3321#[derive(Debug, Clone, PartialEq)]
3322struct GroupedDisplayDeps {
3323 base: GroupedBaseDeps,
3324 sorting: crate::headless::table::SortingState,
3325 expanding: ExpandingState,
3326 row_pinning: crate::headless::table::RowPinningState,
3327 grouped_row_pinning_policy: GroupedRowPinningPolicy,
3328 page_index: usize,
3329 page_size: usize,
3330}
3331
3332#[derive(Debug, Default)]
3333struct GroupedDisplayCache {
3334 base_deps: Option<GroupedBaseDeps>,
3335 grouped: crate::headless::table::GroupedRowModel,
3336 row_index_by_key: std::collections::HashMap<RowKey, usize>,
3337 group_labels: std::collections::HashMap<RowKey, Arc<str>>,
3338 group_aggs_u64: std::collections::HashMap<RowKey, Arc<[(ColumnId, u64)]>>,
3339 group_aggs_any: std::collections::HashMap<RowKey, Arc<[(ColumnId, TanStackValue)]>>,
3340 group_aggs_text: GroupAggsText,
3341
3342 deps: Option<GroupedDisplayDeps>,
3343 page_rows: Vec<DisplayRow>,
3344 output: TableViewOutput,
3345}
3346
3347fn fnv1a64_bytes(bytes: &[u8]) -> u64 {
3348 const OFFSET: u64 = 0xcbf29ce484222325;
3349 const PRIME: u64 = 0x00000100000001B3;
3350
3351 let mut h = OFFSET;
3352 for &b in bytes {
3353 h ^= b as u64;
3354 h = h.wrapping_mul(PRIME);
3355 }
3356 h
3357}
3358
3359fn columns_fingerprint<TData>(columns: &[ColumnDef<TData>]) -> u64 {
3360 let mut h = fnv1a64_bytes(b"fret.table.columns.v1");
3361 for c in columns {
3362 h ^= fnv1a64_bytes(c.id.as_bytes());
3363 h = h.wrapping_mul(0x00000100000001B3);
3364 h ^= c.enable_grouping as u64;
3365 h = h.wrapping_mul(0x00000100000001B3);
3366 h ^= c.facet_key_fn.is_some() as u64;
3367 h = h.wrapping_mul(0x00000100000001B3);
3368 h ^= c.facet_str_fn.is_some() as u64;
3369 h = h.wrapping_mul(0x00000100000001B3);
3370 h ^= c.value_u64_fn.is_some() as u64;
3371 h = h.wrapping_mul(0x00000100000001B3);
3372 h ^= match c.aggregation {
3373 Aggregation::None => 0,
3374 Aggregation::Count => 1,
3375 Aggregation::SumU64 => 2,
3376 Aggregation::MinU64 => 3,
3377 Aggregation::MaxU64 => 4,
3378 Aggregation::MeanU64 => 5,
3379 };
3380 h = h.wrapping_mul(0x00000100000001B3);
3381 }
3382 h
3383}
3384
3385#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3386enum TableNavRowKind {
3387 Leaf,
3388 Group,
3389}
3390
3391#[derive(Debug, Clone, PartialEq, Eq)]
3392struct TableNavRowMeta {
3393 row_key: RowKey,
3394 kind: TableNavRowKind,
3395 data_index: Option<usize>,
3396 label: Arc<str>,
3397}
3398
3399fn table_collect_leaf_keys(meta: &[TableNavRowMeta]) -> Vec<RowKey> {
3400 meta.iter()
3401 .filter(|m| m.kind == TableNavRowKind::Leaf)
3402 .map(|m| m.row_key)
3403 .collect()
3404}
3405
3406fn table_collect_leaf_keys_in_range(meta: &[TableNavRowMeta], a: usize, b: usize) -> Vec<RowKey> {
3407 if meta.is_empty() {
3408 return Vec::new();
3409 }
3410
3411 let a = a.min(meta.len().saturating_sub(1));
3412 let b = b.min(meta.len().saturating_sub(1));
3413 let (a, b) = if a <= b { (a, b) } else { (b, a) };
3414
3415 meta.iter()
3416 .enumerate()
3417 .filter_map(|(idx, m)| {
3418 if idx >= a && idx <= b && m.kind == TableNavRowKind::Leaf {
3419 Some(m.row_key)
3420 } else {
3421 None
3422 }
3423 })
3424 .collect()
3425}
3426
3427struct TableKeyboardNavState {
3428 active_index: Rc<Cell<Option<usize>>>,
3429 anchor_index: Rc<Cell<Option<RowKey>>>,
3430 row_meta: Rc<RefCell<Arc<[TableNavRowMeta]>>>,
3431 active_element: Rc<Cell<Option<fret_ui::GlobalElementId>>>,
3432 active_command: Rc<RefCell<Option<CommandId>>>,
3433 typeahead: Rc<RefCell<TypeaheadBuffer>>,
3434 typeahead_timer: Rc<Cell<Option<TimerToken>>>,
3435}
3436
3437impl Default for TableKeyboardNavState {
3438 fn default() -> Self {
3439 Self {
3440 active_index: Rc::default(),
3441 anchor_index: Rc::default(),
3442 row_meta: Rc::default(),
3443 active_element: Rc::default(),
3444 active_command: Rc::default(),
3445 typeahead: Rc::new(RefCell::new(TypeaheadBuffer::new(0))),
3446 typeahead_timer: Rc::default(),
3447 }
3448 }
3449}
3450
3451#[allow(clippy::too_many_arguments)]
3452#[track_caller]
3453pub fn table_virtualized<H: UiHost, TData, IHeader, TH, ICell, TC>(
3454 cx: &mut ElementContext<'_, H>,
3455 data: &[TData],
3456 columns: &[ColumnDef<TData>],
3457 state: impl IntoTableStateModel,
3458 vertical_scroll: &VirtualListScrollHandle,
3459 items_revision: u64,
3460 row_key_at: &RowKeyAt<TData>,
3461 typeahead_label_at: Option<Arc<TypeaheadLabelAt<TData>>>,
3462 props: TableViewProps,
3463 on_row_activate: impl Fn(&Row<'_, TData>) -> Option<CommandId>,
3464 render_header_cell: impl FnMut(
3465 &mut ElementContext<'_, H>,
3466 &ColumnDef<TData>,
3467 Option<bool>,
3468 ) -> IHeader,
3469 render_cell: impl FnMut(&mut ElementContext<'_, H>, &Row<'_, TData>, &ColumnDef<TData>) -> ICell,
3470 output: Option<Model<TableViewOutput>>,
3471 debug_ids: TableDebugIds,
3472) -> AnyElement
3473where
3474 IHeader: IntoIterator<Item = TH>,
3475 TH: IntoUiElement<H>,
3476 ICell: IntoIterator<Item = TC>,
3477 TC: IntoUiElement<H>,
3478{
3479 let state = state.into_table_state_model();
3480 table_virtualized_impl(
3481 cx,
3482 data,
3483 columns,
3484 state,
3485 vertical_scroll,
3486 items_revision,
3487 row_key_at,
3488 typeahead_label_at,
3489 props,
3490 None,
3491 on_row_activate,
3492 render_header_cell,
3493 render_cell,
3494 output,
3495 debug_ids,
3496 )
3497}
3498
3499/// Virtualized table helper that participates in cross-surface clipboard commands (`edit.copy`).
3500///
3501/// `copy_text_at` receives the data index for the selected/active leaf row.
3502#[allow(clippy::too_many_arguments)]
3503#[track_caller]
3504pub fn table_virtualized_copyable<H: UiHost, TData, IHeader, TH, ICell, TC>(
3505 cx: &mut ElementContext<'_, H>,
3506 data: &[TData],
3507 columns: &[ColumnDef<TData>],
3508 state: impl IntoTableStateModel,
3509 vertical_scroll: &VirtualListScrollHandle,
3510 items_revision: u64,
3511 row_key_at: &RowKeyAt<TData>,
3512 typeahead_label_at: Option<Arc<TypeaheadLabelAt<TData>>>,
3513 props: TableViewProps,
3514 copy_text_at: Arc<CopyTextAtFn>,
3515 on_row_activate: impl Fn(&Row<'_, TData>) -> Option<CommandId>,
3516 render_header_cell: impl FnMut(
3517 &mut ElementContext<'_, H>,
3518 &ColumnDef<TData>,
3519 Option<bool>,
3520 ) -> IHeader,
3521 render_cell: impl FnMut(&mut ElementContext<'_, H>, &Row<'_, TData>, &ColumnDef<TData>) -> ICell,
3522 output: Option<Model<TableViewOutput>>,
3523 debug_ids: TableDebugIds,
3524) -> AnyElement
3525where
3526 IHeader: IntoIterator<Item = TH>,
3527 TH: IntoUiElement<H>,
3528 ICell: IntoIterator<Item = TC>,
3529 TC: IntoUiElement<H>,
3530{
3531 let state = state.into_table_state_model();
3532 table_virtualized_impl(
3533 cx,
3534 data,
3535 columns,
3536 state,
3537 vertical_scroll,
3538 items_revision,
3539 row_key_at,
3540 typeahead_label_at,
3541 props,
3542 Some(copy_text_at),
3543 on_row_activate,
3544 render_header_cell,
3545 render_cell,
3546 output,
3547 debug_ids,
3548 )
3549}
3550
3551/// Retained-host virtualized table helper (virt-003 / ADR 0177).
3552///
3553/// This is an opt-in surface intended for perf/correctness harnesses. v0 is intentionally minimal:
3554/// - fixed-height or measured body rows (controlled by `props.row_measure_mode`)
3555/// - flat (non-grouped) tables only
3556/// - sorting (including multi-sort state) is supported
3557/// - focusable "List" semantics with keyboard navigation and typeahead (opt-in labels)
3558///
3559/// Typeahead labels:
3560/// - When `typeahead_label_at` is `Some`, it is used to compute per-row labels for prefix
3561/// typeahead navigation.
3562/// - The `usize` argument is the row's `data_index` into `data`.
3563///
3564/// The key benefit is that overscan window boundary updates can attach/detach body row subtrees
3565/// without forcing a parent cache-root rerender under view-cache reuse.
3566#[allow(clippy::too_many_arguments)]
3567#[track_caller]
3568pub fn table_virtualized_retained_v0<H: UiHost + 'static, TData>(
3569 cx: &mut ElementContext<'_, H>,
3570 data: Arc<[TData]>,
3571 columns: Arc<[ColumnDef<TData>]>,
3572 state: impl IntoTableStateModel,
3573 vertical_scroll: &VirtualListScrollHandle,
3574 items_revision: u64,
3575 row_key_at: Arc<RowKeyAt<TData>>,
3576 typeahead_label_at: Option<Arc<TypeaheadLabelAt<TData>>>,
3577 props: TableViewProps,
3578 header_label: Arc<HeaderLabelAt<TData>>,
3579 header_accessory_at: Option<Arc<HeaderAccessoryAt<H, TData>>>,
3580 cell_at: Arc<CellAt<H, TData>>,
3581 debug_ids: TableDebugIds,
3582) -> AnyElement
3583where
3584 TData: 'static,
3585{
3586 let state = state.into_table_state_model();
3587 let TableDebugIds {
3588 header_row_test_id: debug_header_row_test_id,
3589 header_cell_test_id_prefix: debug_header_cell_test_id_prefix,
3590 row_test_id_prefix: debug_row_test_id_prefix,
3591 } = debug_ids;
3592
3593 #[derive(Debug, Clone, Copy)]
3594 struct RowEntry {
3595 key: RowKey,
3596 data_index: usize,
3597 }
3598
3599 #[derive(Default)]
3600 struct RetainedTableRowsState {
3601 last_items_revision: Option<u64>,
3602 entries: Arc<[RowEntry]>,
3603 }
3604
3605 let theme = Theme::global(&*cx.app);
3606 let (table_bg, border, header_bg, row_hover, row_active) = resolve_table_colors(theme);
3607 let ring = theme
3608 .color_by_key("ring")
3609 .or_else(|| theme.color_by_key("focus.ring"))
3610 .or_else(|| theme.color_by_key("primary"))
3611 .unwrap_or(row_active);
3612 let ring = emphasize_border(ring, 0.9);
3613 let row_hover_bg = Color {
3614 a: row_hover.a.min(0.12),
3615 ..row_hover
3616 };
3617 let row_active_bg = Color {
3618 a: row_active.a.min(0.18),
3619 ..row_active
3620 };
3621 let radius = theme.metric_token("metric.radius.md");
3622
3623 let row_h = props
3624 .row_height
3625 .unwrap_or_else(|| resolve_row_height(theme, props.size));
3626 let header_h = props.header_height.unwrap_or(row_h);
3627
3628 let cell_px = resolve_cell_padding_x(theme);
3629 let cell_py = resolve_cell_padding_y(theme);
3630
3631 let state_value = cx.watch_model(&state).layout().cloned_or_default();
3632 let sorting = state_value.sorting.clone();
3633
3634 let empty: &[TData] = &[];
3635 let mut sizing_state = state_value.clone();
3636 if !props.enable_column_grouping {
3637 sizing_state.grouping.clear();
3638 }
3639
3640 let sizing_columns: Vec<ColumnDef<TData>> = columns
3641 .iter()
3642 .cloned()
3643 .map(|c| with_table_view_column_constraints(c, &props))
3644 .collect();
3645
3646 let sizing_options = TableOptions {
3647 grouped_column_mode: props.grouped_column_mode,
3648 ..Default::default()
3649 };
3650
3651 let sizing_table = Table::builder(empty)
3652 .columns(sizing_columns)
3653 .state(sizing_state)
3654 .options(sizing_options)
3655 .build();
3656 let core_snapshot = sizing_table.core_model_snapshot();
3657
3658 let columns: Arc<[ColumnDef<TData>]> = Arc::from(sizing_table.columns().to_vec());
3659
3660 let visible_columns: Arc<[ColumnDef<TData>]> = Arc::from(
3661 core_snapshot
3662 .leaf_columns
3663 .visible
3664 .iter()
3665 .filter_map(|id| sizing_table.column(id.as_ref()).cloned())
3666 .collect::<Vec<_>>(),
3667 );
3668
3669 let col_widths: Arc<[Px]> = Arc::from(
3670 visible_columns
3671 .iter()
3672 .map(|col| {
3673 let w = core_snapshot
3674 .leaf_column_sizing
3675 .size
3676 .get(&col.id)
3677 .copied()
3678 .unwrap_or(col.size);
3679 Px(w)
3680 })
3681 .collect::<Vec<_>>(),
3682 );
3683
3684 let mut visible_column_index_by_id: std::collections::HashMap<ColumnId, usize> =
3685 std::collections::HashMap::new();
3686 for (idx, col) in visible_columns.iter().enumerate() {
3687 visible_column_index_by_id.insert(col.id.clone(), idx);
3688 }
3689
3690 let left_col_indices: Arc<[usize]> = Arc::from(
3691 core_snapshot
3692 .leaf_columns
3693 .left_visible
3694 .iter()
3695 .filter_map(|id| visible_column_index_by_id.get(id).copied())
3696 .collect::<Vec<_>>(),
3697 );
3698 let center_col_indices: Arc<[usize]> = Arc::from(
3699 core_snapshot
3700 .leaf_columns
3701 .center_visible
3702 .iter()
3703 .filter_map(|id| visible_column_index_by_id.get(id).copied())
3704 .collect::<Vec<_>>(),
3705 );
3706 let right_col_indices: Arc<[usize]> = Arc::from(
3707 core_snapshot
3708 .leaf_columns
3709 .right_visible
3710 .iter()
3711 .filter_map(|id| visible_column_index_by_id.get(id).copied())
3712 .collect::<Vec<_>>(),
3713 );
3714
3715 let scroll_x = cx.slot_state(ScrollHandle::default, |h| h.clone());
3716
3717 let entries = cx.slot_state(RetainedTableRowsState::default, |st| {
3718 if st.last_items_revision != Some(items_revision) {
3719 st.last_items_revision = Some(items_revision);
3720
3721 let mut entries: Vec<RowEntry> = (0..data.len())
3722 .map(|i| RowEntry {
3723 key: (row_key_at)(&data[i], i),
3724 data_index: i,
3725 })
3726 .collect();
3727
3728 if !sorting.is_empty() {
3729 let mut sorters: Vec<SorterSpec<TData>> = Vec::new();
3730 for spec in &sorting {
3731 if let Some(col) = columns
3732 .iter()
3733 .find(|c| c.id.as_ref() == spec.column.as_ref())
3734 && let Some(cmp) = col.sort_cmp.as_ref()
3735 {
3736 sorters.push((spec.clone(), Arc::clone(cmp)));
3737 }
3738 }
3739
3740 if !sorters.is_empty() {
3741 entries.sort_by(|a, b| {
3742 let a_row = &data[a.data_index];
3743 let b_row = &data[b.data_index];
3744 for (spec, cmp) in &sorters {
3745 let ord = (cmp)(a_row, b_row);
3746 let ord = if spec.desc { ord.reverse() } else { ord };
3747 if ord != std::cmp::Ordering::Equal {
3748 return ord;
3749 }
3750 }
3751
3752 a.key.cmp(&b.key)
3753 });
3754 }
3755 }
3756
3757 st.entries = Arc::from(entries);
3758 }
3759
3760 st.entries.clone()
3761 });
3762
3763 let mut fill_layout = LayoutStyle::default();
3764 fill_layout.size.width = Length::Fill;
3765 fill_layout.size.height = Length::Fill;
3766 fill_layout.flex.grow = 1.0;
3767 fill_layout.flex.basis = Length::Px(Px(0.0));
3768
3769 let mut options = VirtualListOptions::new(row_h, props.overscan);
3770 options.items_revision = items_revision;
3771 options.keep_alive = props
3772 .keep_alive
3773 .unwrap_or_else(|| props.overscan.saturating_mul(2));
3774 match props.row_measure_mode {
3775 TableRowMeasureMode::Fixed => {
3776 options.measure_mode = fret_ui::element::VirtualListMeasureMode::Fixed;
3777 options.key_cache = fret_ui::element::VirtualListKeyCacheMode::VisibleOnly;
3778 }
3779 TableRowMeasureMode::Measured => {
3780 options.measure_mode = fret_ui::element::VirtualListMeasureMode::Measured;
3781 options.key_cache = fret_ui::element::VirtualListKeyCacheMode::AllKeys;
3782 }
3783 }
3784
3785 let header = {
3786 let state = state.clone();
3787 let header_label = Arc::clone(&header_label);
3788 let debug_header_row_test_id = debug_header_row_test_id.clone();
3789 let debug_header_cell_test_id_prefix = debug_header_cell_test_id_prefix.clone();
3790 let visible_columns = visible_columns.clone();
3791 let col_widths = col_widths.clone();
3792 let left_col_indices = left_col_indices.clone();
3793 let center_col_indices = center_col_indices.clone();
3794 let right_col_indices = right_col_indices.clone();
3795 let scroll_x = scroll_x.clone();
3796 let sorting = sorting.clone();
3797 let data_for_header = data.clone();
3798
3799 let header = cx.container(
3800 ContainerProps {
3801 background: Some(header_bg),
3802 border: Edges {
3803 bottom: Px(1.0),
3804 ..Default::default()
3805 },
3806 border_color: Some(border),
3807 corner_radii: Corners {
3808 top_left: radius,
3809 top_right: radius,
3810 ..Default::default()
3811 },
3812 layout: LayoutStyle {
3813 size: fret_ui::element::SizeStyle {
3814 width: Length::Fill,
3815 height: Length::Px(header_h),
3816 min_height: Some(Length::Px(header_h)),
3817 max_height: Some(Length::Px(header_h)),
3818 ..Default::default()
3819 },
3820 flex: fret_ui::element::FlexItemStyle {
3821 shrink: 0.0,
3822 basis: Length::Px(header_h),
3823 ..Default::default()
3824 },
3825 ..Default::default()
3826 },
3827 ..Default::default()
3828 },
3829 move |cx| {
3830 let render_header_group =
3831 |cx: &mut ElementContext<'_, H>,
3832 col_indices: Arc<[usize]>,
3833 scroll_x_for_group: Option<ScrollHandle>| {
3834 let visible_columns = visible_columns.clone();
3835 let col_widths = col_widths.clone();
3836 let header_label = header_label.clone();
3837 let header_accessory_at = header_accessory_at.clone();
3838 let debug_header_cell_test_id_prefix =
3839 debug_header_cell_test_id_prefix.clone();
3840 let sorting = sorting.clone();
3841 let state = state.clone();
3842 let enable_sorting = props.enable_sorting;
3843 let data = data_for_header.clone();
3844
3845 let row = ui::h_row(move |cx| {
3846 col_indices
3847 .iter()
3848 .map(|col_idx| {
3849 let col = &visible_columns[*col_idx];
3850 let col_w = col_widths[*col_idx];
3851 let label = (header_label)(col);
3852 let header_accessory_at = header_accessory_at.clone();
3853 let sort_state = sort_for_column(&sorting, &col.id);
3854 let col_id = col.id.clone();
3855 let state = state.clone();
3856 let sorting_for_cell = sorting.clone();
3857 let enabled = enable_sorting
3858 && col.enable_sorting
3859 && (col.sort_cmp.is_some() || col.sort_value.is_some());
3860 let sort_options = TableOptions {
3861 enable_sorting,
3862 ..TableOptions::default()
3863 };
3864 let sort_toggle_column = SortToggleColumn {
3865 id: col_id.clone(),
3866 enable_sorting: col.enable_sorting,
3867 enable_multi_sort: col.enable_multi_sort,
3868 sort_desc_first: col.sort_desc_first,
3869 has_sort_value_source: col.sort_cmp.is_some()
3870 || col.sort_value.is_some(),
3871 };
3872 let auto_sort_dir_desc = col
3873 .sort_value
3874 .as_ref()
3875 .and_then(|f| data.first().map(|r| f(r)))
3876 .map(|v| !matches!(v, TanStackValue::String(_)))
3877 .unwrap_or(false);
3878 let debug_test_id: Option<Arc<str>> =
3879 debug_header_cell_test_id_prefix.as_ref().map(
3880 |prefix| {
3881 Arc::<str>::from(format!(
3882 "{prefix}{id}",
3883 id = col_id.as_ref()
3884 ))
3885 },
3886 );
3887
3888 cx.container(
3889 ContainerProps {
3890 border: if props.optimize_grid_lines {
3891 Edges::default()
3892 } else {
3893 Edges {
3894 right: Px(1.0),
3895 ..Default::default()
3896 }
3897 },
3898 border_color: if props.optimize_grid_lines {
3899 None
3900 } else {
3901 Some(border)
3902 },
3903 layout: table_fixed_column_fill_layout(col_w),
3904 ..Default::default()
3905 },
3906 move |cx| {
3907 vec![cx.pressable(
3908 PressableProps {
3909 layout: {
3910 let mut layout = LayoutStyle::default();
3911 layout.size.width = Length::Fill;
3912 layout.size.height = Length::Fill;
3913 layout
3914 },
3915 enabled,
3916 a11y: PressableA11y {
3917 role: Some(SemanticsRole::Button),
3918 label: Some(label.clone()),
3919 test_id: debug_test_id.clone(),
3920 ..Default::default()
3921 },
3922 ..Default::default()
3923 },
3924 move |cx, _st| {
3925 if enabled {
3926 let state_model_for_pointer =
3927 state.clone();
3928 let sort_toggle_column_for_pointer =
3929 sort_toggle_column.clone();
3930 let sort_options_for_pointer =
3931 sort_options;
3932 cx.pressable_on_pointer_up(Arc::new(
3933 move |host, acx, up| {
3934 if !up.is_click
3935 || up.button
3936 != fret_core::MouseButton::Left
3937 {
3938 return PressablePointerUpResult::Continue;
3939 }
3940
3941 let multi =
3942 up.modifiers.shift;
3943 let _ = host.update_model(
3944 &state_model_for_pointer,
3945 |st| {
3946 toggle_sorting_state_handler_tanstack(
3947 &mut st.sorting,
3948 &sort_toggle_column_for_pointer,
3949 sort_options_for_pointer,
3950 multi,
3951 auto_sort_dir_desc,
3952 );
3953 st.pagination.page_index = 0;
3954 },
3955 );
3956 host.notify(acx);
3957 PressablePointerUpResult::SkipActivate
3958 },
3959 ));
3960
3961 cx.pressable_update_model(
3962 &state,
3963 move |st| {
3964 toggle_sorting_state_handler_tanstack(
3965 &mut st.sorting,
3966 &sort_toggle_column,
3967 sort_options,
3968 false,
3969 auto_sort_dir_desc,
3970 );
3971 st.pagination.page_index = 0;
3972 },
3973 );
3974 }
3975
3976 let header_text: Arc<str> = match sort_state
3977 {
3978 None => label.clone(),
3979 Some(desc) => {
3980 let order = if sorting_for_cell
3981 .len()
3982 > 1
3983 {
3984 sorting_for_cell
3985 .iter()
3986 .position(|s| {
3987 s.column.as_ref()
3988 == col_id.as_ref()
3989 })
3990 .map(|v| v + 1)
3991 } else {
3992 None
3993 };
3994 match order {
3995 Some(order) => {
3996 Arc::<str>::from(format!(
3997 "{} {}{}",
3998 label,
3999 if desc { "â–¼" } else { "â–²" },
4000 order
4001 ))
4002 }
4003 None => Arc::<str>::from(format!(
4004 "{} {}",
4005 label,
4006 if desc { "â–¼" } else { "â–²" }
4007 )),
4008 }
4009 }
4010 };
4011
4012 vec![cx.container(
4013 ContainerProps {
4014 padding: Edges::symmetric(
4015 cell_px, cell_py,
4016 )
4017 .into(),
4018 layout: {
4019 let mut layout =
4020 LayoutStyle::default();
4021 layout.size.width =
4022 Length::Fill;
4023 layout.size.height =
4024 Length::Fill;
4025 layout
4026 },
4027 ..Default::default()
4028 },
4029 move |_cx| {
4030 let accessory =
4031 header_accessory_at.as_ref().map(|f| f(_cx, col));
4032 match accessory {
4033 None => {
4034 vec![_cx.text(header_text.as_ref())]
4035 }
4036 Some(accessory) => {
4037 vec![ui::h_row(move |_cx| {
4038 [
4039 _cx.text(header_text.as_ref()),
4040 _cx.spacer(
4041 SpacerProps::default(),
4042 ),
4043 accessory,
4044 ]
4045 })
4046 .gap(Space::N1)
4047 .justify_start()
4048 .items_center()
4049 .into_element(_cx)]
4050 }
4051 }
4052 },
4053 )]
4054 },
4055 )]
4056 },
4057 )
4058 })
4059 .collect::<Vec<_>>()
4060 })
4061 .gap(Space::N0)
4062 .justify_start()
4063 .items_center()
4064 .into_element(cx);
4065
4066 table_wrap_horizontal_scroll(cx, scroll_x_for_group, row)
4067 };
4068
4069 vec![ui::h_row(|cx| {
4070 let left = render_header_group(cx, left_col_indices.clone(), None);
4071 let center =
4072 render_header_group(cx, center_col_indices.clone(), Some(scroll_x.clone()));
4073 let right = render_header_group(cx, right_col_indices.clone(), None);
4074 [left, center, right]
4075 })
4076 .gap(Space::N0)
4077 .justify_start()
4078 .items_stretch()
4079 .layout(LayoutRefinement::default().w_full())
4080 .into_element(cx)]
4081 },
4082 );
4083
4084 if let Some(test_id) = debug_header_row_test_id {
4085 header.test_id(test_id)
4086 } else {
4087 header
4088 }
4089 };
4090
4091 let key_at: Arc<dyn Fn(usize) -> fret_ui::ItemKey> = {
4092 let entries = entries.clone();
4093 Arc::new(move |i| entries[i].key.0)
4094 };
4095
4096 struct RetainedTableKeyboardNavState {
4097 active_index: Rc<Cell<Option<usize>>>,
4098 anchor_index: Rc<Cell<Option<RowKey>>>,
4099 active_element: Rc<Cell<Option<fret_ui::GlobalElementId>>>,
4100 labels: Rc<RefCell<Arc<[Arc<str>]>>>,
4101 disabled: Rc<RefCell<Arc<[bool]>>>,
4102 last_labels_revision: Cell<Option<u64>>,
4103 typeahead: Rc<RefCell<TypeaheadBuffer>>,
4104 typeahead_timer: Rc<Cell<Option<TimerToken>>>,
4105 }
4106
4107 impl Default for RetainedTableKeyboardNavState {
4108 fn default() -> Self {
4109 Self {
4110 active_index: Rc::default(),
4111 anchor_index: Rc::default(),
4112 active_element: Rc::default(),
4113 labels: Rc::new(RefCell::new(Arc::from([]))),
4114 disabled: Rc::new(RefCell::new(Arc::from([]))),
4115 last_labels_revision: Cell::new(None),
4116 typeahead: Rc::new(RefCell::new(TypeaheadBuffer::new(0))),
4117 typeahead_timer: Rc::default(),
4118 }
4119 }
4120 }
4121
4122 let (
4123 active_index,
4124 anchor_index,
4125 active_element,
4126 labels,
4127 disabled,
4128 _last_labels_revision,
4129 typeahead,
4130 typeahead_timer,
4131 ) = cx.slot_state(RetainedTableKeyboardNavState::default, |nav| {
4132 if nav.last_labels_revision.get() != Some(items_revision) {
4133 nav.last_labels_revision.set(Some(items_revision));
4134
4135 if let Some(typeahead_label_at) = &typeahead_label_at {
4136 let mut next_labels: Vec<Arc<str>> = Vec::with_capacity(entries.len());
4137 let mut next_disabled: Vec<bool> = Vec::with_capacity(entries.len());
4138 for entry in entries.iter() {
4139 let i = entry.data_index;
4140 let label = (typeahead_label_at)(&data[i], i);
4141 let disabled = label.trim().is_empty();
4142 next_labels.push(label);
4143 next_disabled.push(disabled);
4144 }
4145 *nav.labels.borrow_mut() = Arc::from(next_labels);
4146 *nav.disabled.borrow_mut() = Arc::from(next_disabled);
4147 } else {
4148 let mut next_labels: Vec<Arc<str>> = Vec::with_capacity(entries.len());
4149 let mut next_disabled: Vec<bool> = Vec::with_capacity(entries.len());
4150 for entry in entries.iter() {
4151 let label: Arc<str> = Arc::from(entry.key.0.to_string());
4152 next_labels.push(label);
4153 next_disabled.push(false);
4154 }
4155 *nav.labels.borrow_mut() = Arc::from(next_labels);
4156 *nav.disabled.borrow_mut() = Arc::from(next_disabled);
4157 }
4158 }
4159
4160 (
4161 nav.active_index.clone(),
4162 nav.anchor_index.clone(),
4163 nav.active_element.clone(),
4164 nav.labels.clone(),
4165 nav.disabled.clone(),
4166 nav.last_labels_revision.clone(),
4167 nav.typeahead.clone(),
4168 nav.typeahead_timer.clone(),
4169 )
4170 });
4171
4172 let row_builder =
4173 {
4174 let state = state.clone();
4175 let entries = entries.clone();
4176 let data = data.clone();
4177 let props = props.clone();
4178 let visible_columns = visible_columns.clone();
4179 let col_widths = col_widths.clone();
4180 let cell_at = Arc::clone(&cell_at);
4181 let debug_row_test_id_prefix = debug_row_test_id_prefix.clone();
4182 let left_col_indices = left_col_indices.clone();
4183 let center_col_indices = center_col_indices.clone();
4184 let right_col_indices = right_col_indices.clone();
4185 let scroll_x = scroll_x.clone();
4186 let active_index_for_row_builder = active_index.clone();
4187 let active_element_for_row_builder = active_element.clone();
4188 let anchor_index_for_row_builder = anchor_index.clone();
4189
4190 move |key_handler: fret_ui::action::OnKeyDown, focus_target: GlobalElementId| {
4191 let active_index_for_row = active_index_for_row_builder.clone();
4192 let active_element_for_row = active_element_for_row_builder.clone();
4193 let anchor_index_for_row = anchor_index_for_row_builder.clone();
4194 Arc::new(move |cx: &mut ElementContext<'_, H>, i: usize| {
4195 let entry = entries[i];
4196 let row_key = entry.key;
4197 let data_index = entry.data_index;
4198
4199 let selected = cx
4200 .watch_model(&state)
4201 .paint()
4202 .read_ref(|s| s.row_selection.contains(&row_key))
4203 .ok()
4204 .unwrap_or(false);
4205
4206 let test_id = debug_row_test_id_prefix
4207 .as_ref()
4208 .map(|prefix| Arc::<str>::from(format!("{}{id}", prefix, id = row_key.0)));
4209
4210 let state_model = state.clone();
4211 let entries = entries.clone();
4212 let anchor_index = anchor_index_for_row.clone();
4213 let data_for_row = Arc::clone(&data);
4214 let columns_for_row = Arc::clone(&visible_columns);
4215 let col_widths_for_row = col_widths.clone();
4216 let cell_at_for_row = Arc::clone(&cell_at);
4217 let key_handler_for_row = key_handler.clone();
4218 let focus_target_for_row = focus_target;
4219 let row_cell_test_id_prefix = debug_row_test_id_prefix.clone();
4220 let left_col_indices_for_row = left_col_indices.clone();
4221 let center_col_indices_for_row = center_col_indices.clone();
4222 let right_col_indices_for_row = right_col_indices.clone();
4223 let scroll_x_for_row = scroll_x.clone();
4224 let active_index = active_index_for_row.clone();
4225 let active_element = active_element_for_row.clone();
4226 let focus_target = focus_target_for_row;
4227 let single = props.single_row_selection;
4228 let policy = props.pointer_row_selection_policy;
4229 let pointer_row_selection_enabled =
4230 props.enable_row_selection && props.pointer_row_selection;
4231 let row_wrapper_layout = retained_table_row_fill_layout();
4232
4233 let render_row_visuals =
4234 |cx: &mut ElementContext<'_, H>, hovered: bool, pressed: bool| {
4235 let bg = if selected || pressed {
4236 Some(row_active_bg)
4237 } else if hovered {
4238 Some(row_hover_bg)
4239 } else {
4240 None
4241 };
4242
4243 vec![retained_table_render_row_visuals(
4244 cx,
4245 data_for_row.clone(),
4246 data_index,
4247 row_key,
4248 bg,
4249 props.clone(),
4250 border,
4251 cell_px,
4252 cell_py,
4253 key_handler_for_row.clone(),
4254 columns_for_row.clone(),
4255 col_widths_for_row.clone(),
4256 cell_at_for_row.clone(),
4257 row_cell_test_id_prefix.clone(),
4258 left_col_indices_for_row.clone(),
4259 center_col_indices_for_row.clone(),
4260 right_col_indices_for_row.clone(),
4261 scroll_x_for_row.clone(),
4262 )]
4263 };
4264
4265 if pointer_row_selection_enabled {
4266 cx.pressable_with_id(
4267 PressableProps {
4268 enabled: true,
4269 focusable: false,
4270 a11y: PressableA11y {
4271 role: Some(SemanticsRole::ListItem),
4272 test_id,
4273 selected,
4274 ..Default::default()
4275 },
4276 ..Default::default()
4277 },
4278 move |cx, st, id| {
4279 let active_index_for_pointer_down = active_index.clone();
4280 cx.pressable_add_on_pointer_down(Arc::new(
4281 move |_host, action_cx, down| {
4282 if down.button != fret_core::MouseButton::Left {
4283 return PressablePointerDownResult::Continue;
4284 }
4285 if down
4286 .hit_pressable_target
4287 .is_some_and(|t| t != action_cx.target)
4288 {
4289 return PressablePointerDownResult::Continue;
4290 }
4291 active_index_for_pointer_down.set(Some(i));
4292 PressablePointerDownResult::Continue
4293 },
4294 ));
4295 if policy == PointerRowSelectionPolicy::ListLike {
4296 let anchor_index_for_pointer_down = anchor_index.clone();
4297 let row_key_for_anchor = row_key;
4298 cx.pressable_add_on_pointer_down(Arc::new(
4299 move |_host, action_cx, down| {
4300 if down.button != fret_core::MouseButton::Left {
4301 return PressablePointerDownResult::Continue;
4302 }
4303 if down
4304 .hit_pressable_target
4305 .is_some_and(|t| t != action_cx.target)
4306 {
4307 return PressablePointerDownResult::Continue;
4308 }
4309 let next_anchor = if down.modifiers.shift {
4310 anchor_index_for_pointer_down
4311 .get()
4312 .or(Some(row_key_for_anchor))
4313 } else {
4314 Some(row_key_for_anchor)
4315 };
4316 anchor_index_for_pointer_down.set(next_anchor);
4317 PressablePointerDownResult::Continue
4318 },
4319 ));
4320 }
4321
4322 cx.pressable_on_pointer_up(Arc::new(move |host, action_cx, up| {
4323 if up.button != fret_core::MouseButton::Left || !up.is_click {
4324 return PressablePointerUpResult::Continue;
4325 }
4326 if up
4327 .down_hit_pressable_target
4328 .is_some_and(|t| t != action_cx.target)
4329 {
4330 return PressablePointerUpResult::Continue;
4331 }
4332 host.request_focus(focus_target);
4333 let modifiers = up.modifiers;
4334 let mut range_keys: Option<Vec<RowKey>> = None;
4335 if policy == PointerRowSelectionPolicy::ListLike
4336 && !single
4337 && modifiers.shift
4338 {
4339 let anchor_key = anchor_index.get().unwrap_or(row_key);
4340 let anchor = entries
4341 .iter()
4342 .position(|entry| entry.key == anchor_key)
4343 .unwrap_or(i);
4344 let (a, b) = if anchor <= i {
4345 (anchor, i)
4346 } else {
4347 (i, anchor)
4348 };
4349 let keys = entries
4350 .iter()
4351 .enumerate()
4352 .filter_map(|(idx, entry)| {
4353 (idx >= a && idx <= b).then_some(entry.key)
4354 })
4355 .collect::<Vec<_>>();
4356 if !keys.is_empty() {
4357 range_keys = Some(keys);
4358 }
4359 }
4360
4361 let anchor_index_for_update = anchor_index.clone();
4362 let _ = host.models_mut().update(&state_model, move |st| {
4363 match policy {
4364 PointerRowSelectionPolicy::Toggle => {
4365 let selected = st.row_selection.contains(&row_key);
4366 if single {
4367 st.row_selection.clear();
4368 }
4369 if selected {
4370 st.row_selection.remove(&row_key);
4371 } else {
4372 st.row_selection.insert(row_key);
4373 }
4374 }
4375 PointerRowSelectionPolicy::ListLike => {
4376 if let Some(range_keys) = range_keys.clone() {
4377 if modifiers.ctrl || modifiers.meta {
4378 st.row_selection
4379 .extend(range_keys.iter().copied());
4380 } else {
4381 st.row_selection.clear();
4382 st.row_selection
4383 .extend(range_keys.iter().copied());
4384 }
4385 } else if !single
4386 && (modifiers.ctrl || modifiers.meta)
4387 {
4388 if st.row_selection.contains(&row_key) {
4389 st.row_selection.remove(&row_key);
4390 } else {
4391 st.row_selection.insert(row_key);
4392 }
4393 } else {
4394 st.row_selection.clear();
4395 st.row_selection.insert(row_key);
4396 }
4397 }
4398 }
4399 });
4400 if policy == PointerRowSelectionPolicy::ListLike {
4401 let next_anchor = if modifiers.shift {
4402 anchor_index_for_update.get().or(Some(row_key))
4403 } else {
4404 Some(row_key)
4405 };
4406 anchor_index_for_update.set(next_anchor);
4407 } else {
4408 anchor_index_for_update.set(Some(row_key));
4409 }
4410 host.request_redraw(action_cx.window);
4411 PressablePointerUpResult::SkipActivate
4412 }));
4413
4414 if active_index.get() == Some(i) {
4415 active_element.set(Some(id));
4416 }
4417 render_row_visuals(cx, st.hovered, st.pressed)
4418 },
4419 )
4420 } else {
4421 cx.semantics_with_id(
4422 SemanticsProps {
4423 layout: row_wrapper_layout,
4424 role: SemanticsRole::ListItem,
4425 test_id,
4426 selected,
4427 ..Default::default()
4428 },
4429 move |cx, id| {
4430 if active_index.get() == Some(i) {
4431 active_element.set(Some(id));
4432 }
4433 vec![cx.hover_region(
4434 HoverRegionProps {
4435 layout: row_wrapper_layout,
4436 },
4437 move |cx, hovered| render_row_visuals(cx, hovered, false),
4438 )]
4439 },
4440 )
4441 }
4442 })
4443 }
4444 };
4445
4446 let list = cx.semantics_with_id(
4447 SemanticsProps {
4448 role: SemanticsRole::List,
4449 focusable: true,
4450 ..Default::default()
4451 },
4452 move |cx, list_id| {
4453 let state_for_keys = state.clone();
4454 let vertical_scroll_for_keys = vertical_scroll.clone();
4455 let entries_for_keys = entries.clone();
4456 let entries_for_list = entries.clone();
4457 let labels_for_keys = labels.clone();
4458 let disabled_for_keys = disabled.clone();
4459 let active_index_for_keys = active_index.clone();
4460 let anchor_index_for_keys = anchor_index.clone();
4461 let typeahead_for_keys = typeahead.clone();
4462 let typeahead_timer_for_keys = typeahead_timer.clone();
4463
4464 let key_handler: fret_ui::action::OnKeyDown = Arc::new(move |host, action_cx, down| {
4465 let Some(len) = entries_for_keys.len().checked_sub(1) else {
4466 return false;
4467 };
4468
4469 let current = active_index_for_keys.get().unwrap_or(0).min(len);
4470
4471 let cancel_typeahead_timer =
4472 |host: &mut dyn fret_ui::action::UiActionHost,
4473 typeahead_timer: &Rc<Cell<Option<TimerToken>>>| {
4474 if let Some(token) = typeahead_timer.get() {
4475 host.push_effect(Effect::CancelTimer { token });
4476 typeahead_timer.set(None);
4477 }
4478 };
4479
4480 match down.key {
4481 KeyCode::ArrowDown => {
4482 let next = (current + 1).min(len);
4483 active_index_for_keys.set(Some(next));
4484 anchor_index_for_keys.set(Some(entries_for_keys[next].key));
4485 cancel_typeahead_timer(host, &typeahead_timer_for_keys);
4486 typeahead_for_keys.borrow_mut().clear();
4487 vertical_scroll_for_keys.scroll_to_item(next, ScrollStrategy::Nearest);
4488 host.request_redraw(action_cx.window);
4489 true
4490 }
4491 KeyCode::ArrowUp => {
4492 let next = current.saturating_sub(1);
4493 active_index_for_keys.set(Some(next));
4494 anchor_index_for_keys.set(Some(entries_for_keys[next].key));
4495 cancel_typeahead_timer(host, &typeahead_timer_for_keys);
4496 typeahead_for_keys.borrow_mut().clear();
4497 vertical_scroll_for_keys.scroll_to_item(next, ScrollStrategy::Nearest);
4498 host.request_redraw(action_cx.window);
4499 true
4500 }
4501 KeyCode::Home => {
4502 active_index_for_keys.set(Some(0));
4503 anchor_index_for_keys.set(Some(entries_for_keys[0].key));
4504 cancel_typeahead_timer(host, &typeahead_timer_for_keys);
4505 typeahead_for_keys.borrow_mut().clear();
4506 vertical_scroll_for_keys.scroll_to_item(0, ScrollStrategy::Nearest);
4507 host.request_redraw(action_cx.window);
4508 true
4509 }
4510 KeyCode::End => {
4511 active_index_for_keys.set(Some(len));
4512 anchor_index_for_keys.set(Some(entries_for_keys[len].key));
4513 cancel_typeahead_timer(host, &typeahead_timer_for_keys);
4514 typeahead_for_keys.borrow_mut().clear();
4515 vertical_scroll_for_keys.scroll_to_item(len, ScrollStrategy::Nearest);
4516 host.request_redraw(action_cx.window);
4517 true
4518 }
4519 KeyCode::Escape => {
4520 cancel_typeahead_timer(host, &typeahead_timer_for_keys);
4521 typeahead_for_keys.borrow_mut().clear();
4522 host.request_redraw(action_cx.window);
4523 true
4524 }
4525 KeyCode::Enter | KeyCode::NumpadEnter | KeyCode::Space => {
4526 if !props.enable_row_selection {
4527 return false;
4528 }
4529 let row_key = entries_for_keys[current].key;
4530 let _ = host.models_mut().update(&state_for_keys, move |st| {
4531 let selected = st.row_selection.contains(&row_key);
4532 if props.single_row_selection {
4533 st.row_selection.clear();
4534 }
4535 if selected {
4536 st.row_selection.remove(&row_key);
4537 } else {
4538 st.row_selection.insert(row_key);
4539 }
4540 });
4541 anchor_index_for_keys.set(Some(row_key));
4542 cancel_typeahead_timer(host, &typeahead_timer_for_keys);
4543 typeahead_for_keys.borrow_mut().clear();
4544 host.request_redraw(action_cx.window);
4545 true
4546 }
4547 _ => {
4548 if down.repeat {
4549 return false;
4550 }
4551 let Some(input) = fret_core::keycode_to_ascii_lowercase(down.key) else {
4552 return false;
4553 };
4554
4555 typeahead_for_keys.borrow_mut().push_char(input, 0);
4556 let typeahead_buf = typeahead_for_keys.borrow();
4557 let Some(query) = typeahead_buf.query(0) else {
4558 return false;
4559 };
4560
4561 let labels = labels_for_keys.borrow().clone();
4562 let disabled = disabled_for_keys.borrow().clone();
4563 let next =
4564 match_prefix_arc_str(&labels, &disabled, query, Some(current), true);
4565 if let Some(next) = next
4566 && next != current
4567 {
4568 active_index_for_keys.set(Some(next));
4569 anchor_index_for_keys.set(Some(entries_for_keys[next].key));
4570 // Typeahead should ensure the matched row becomes *visibly* in-view,
4571 // not just "present in overscan".
4572 vertical_scroll_for_keys.scroll_to_item(next, ScrollStrategy::Start);
4573 }
4574
4575 cancel_typeahead_timer(host, &typeahead_timer_for_keys);
4576 let token = host.next_timer_token();
4577 typeahead_timer_for_keys.set(Some(token));
4578 host.push_effect(Effect::SetTimer {
4579 window: Some(action_cx.window),
4580 token,
4581 after: TABLE_TYPEAHEAD_TIMEOUT,
4582 repeat: None,
4583 });
4584
4585 host.request_redraw(action_cx.window);
4586 true
4587 }
4588 }
4589 });
4590
4591 cx.key_on_key_down_for(list_id, key_handler.clone());
4592 let row = row_builder(key_handler, list_id);
4593
4594 {
4595 let typeahead = typeahead.clone();
4596 let typeahead_timer = typeahead_timer.clone();
4597 cx.timer_on_timer_for(
4598 list_id,
4599 Arc::new(move |host, action_cx, token| {
4600 if typeahead_timer.get() != Some(token) {
4601 return false;
4602 }
4603 typeahead_timer.set(None);
4604 typeahead.borrow_mut().clear();
4605 host.request_redraw(action_cx.window);
4606 true
4607 }),
4608 );
4609 }
4610
4611 let content =
4612 cx.pointer_region(fret_ui::element::PointerRegionProps::default(), move |cx| {
4613 let focus_id = list_id;
4614 cx.pointer_region_on_pointer_up(Arc::new(move |host, action_cx, up| {
4615 if up.button != fret_core::MouseButton::Left || !up.is_click {
4616 return false;
4617 }
4618 if up.down_hit_pressable_target.is_some() {
4619 return false;
4620 }
4621 host.request_focus(focus_id);
4622 host.request_redraw(action_cx.window);
4623 false
4624 }));
4625
4626 vec![
4627 ui::v_flex(move |cx| {
4628 [
4629 header,
4630 cx.virtual_list_keyed_retained_with_layout(
4631 fill_layout,
4632 entries_for_list.len(),
4633 options,
4634 vertical_scroll,
4635 key_at,
4636 row,
4637 ),
4638 ]
4639 })
4640 .layout(LayoutRefinement::default().w_full().h_full())
4641 .gap(Space::N0)
4642 .justify_start()
4643 .items_stretch()
4644 .into_element(cx),
4645 ]
4646 });
4647
4648 let focus_ring = RingStyle {
4649 placement: RingPlacement::Inset,
4650 width: Px(2.0),
4651 offset: Px(0.0),
4652 color: ring,
4653 offset_color: None,
4654 corner_radii: Corners::all(radius),
4655 };
4656
4657 vec![cx.container(
4658 ContainerProps {
4659 layout: fill_layout,
4660 background: Some(table_bg),
4661 border: if props.draw_frame {
4662 Edges::all(Px(1.0))
4663 } else {
4664 Edges::all(Px(0.0))
4665 },
4666 border_color: if props.draw_frame { Some(border) } else { None },
4667 focus_border_color: if props.draw_frame { Some(ring) } else { None },
4668 focus_ring: Some(focus_ring),
4669 focus_within: true,
4670 corner_radii: if props.draw_frame {
4671 Corners::all(radius)
4672 } else {
4673 Corners::all(Px(0.0))
4674 },
4675 ..Default::default()
4676 },
4677 move |_cx| vec![content],
4678 )]
4679 },
4680 );
4681
4682 if let Some(active_element) = active_element.get() {
4683 list.attach_semantics(
4684 SemanticsDecoration::default().active_descendant_element(active_element.0),
4685 )
4686 } else {
4687 list
4688 }
4689}
4690
4691#[allow(clippy::too_many_arguments)]
4692#[track_caller]
4693fn table_virtualized_impl<H: UiHost, TData, IHeader, TH, ICell, TC>(
4694 cx: &mut ElementContext<'_, H>,
4695 data: &[TData],
4696 columns: &[ColumnDef<TData>],
4697 state: Model<TableState>,
4698 vertical_scroll: &VirtualListScrollHandle,
4699 items_revision: u64,
4700 row_key_at: &RowKeyAt<TData>,
4701 typeahead_label_at: Option<Arc<TypeaheadLabelAt<TData>>>,
4702 props: TableViewProps,
4703 copy_text_at: Option<Arc<CopyTextAtFn>>,
4704 on_row_activate: impl Fn(&Row<'_, TData>) -> Option<CommandId>,
4705 mut render_header_cell: impl FnMut(
4706 &mut ElementContext<'_, H>,
4707 &ColumnDef<TData>,
4708 Option<bool>,
4709 ) -> IHeader,
4710 mut render_cell: impl FnMut(&mut ElementContext<'_, H>, &Row<'_, TData>, &ColumnDef<TData>) -> ICell,
4711 output: Option<Model<TableViewOutput>>,
4712 debug_ids: TableDebugIds,
4713) -> AnyElement
4714where
4715 IHeader: IntoIterator<Item = TH>,
4716 TH: IntoUiElement<H>,
4717 ICell: IntoIterator<Item = TC>,
4718 TC: IntoUiElement<H>,
4719{
4720 let profile = std::env::var_os("FRET_TABLE_PROFILE").is_some();
4721 let TableDebugIds {
4722 header_row_test_id: debug_header_row_test_id,
4723 header_cell_test_id_prefix: debug_header_cell_test_id_prefix,
4724 row_test_id_prefix: debug_row_test_id_prefix,
4725 } = debug_ids;
4726 let state_value = cx.watch_model(&state).layout().cloned_or_default();
4727
4728 let theme = Theme::global(&*cx.app);
4729 let (table_bg, border, header_bg, row_hover, row_active) = resolve_table_colors(theme);
4730 let resize_grip = emphasize_border(border, 0.35);
4731 let resize_preview = emphasize_border(border, 0.75);
4732 let ring = theme
4733 .color_by_key("ring")
4734 .or_else(|| theme.color_by_key("focus.ring"))
4735 .or_else(|| theme.color_by_key("primary"))
4736 .unwrap_or(row_active);
4737 let ring = emphasize_border(ring, 0.9);
4738 let row_hover_bg = Color {
4739 a: row_hover.a.min(0.12),
4740 ..row_hover
4741 };
4742 let row_active_bg = Color {
4743 a: row_active.a.min(0.18),
4744 ..row_active
4745 };
4746 let radius = theme.metric_token("metric.radius.md");
4747
4748 let row_h = props
4749 .row_height
4750 .unwrap_or_else(|| resolve_row_height(theme, props.size));
4751 let header_h = props.header_height.unwrap_or(row_h);
4752 let body_row_height = match props.row_measure_mode {
4753 TableRowMeasureMode::Fixed => Length::Px(row_h),
4754 TableRowMeasureMode::Measured => Length::Auto,
4755 };
4756 let cell_px = resolve_cell_padding_x(theme);
4757 let cell_py = resolve_cell_padding_y(theme);
4758
4759 let scroll_x = cx.slot_state(ScrollHandle::default, |h| h.clone());
4760
4761 let grouping = if props.enable_column_grouping {
4762 state_value.grouping.as_slice()
4763 } else {
4764 &[]
4765 };
4766
4767 let empty: &[TData] = &[];
4768 let mut sizing_state = state_value.clone();
4769 if !props.enable_column_grouping {
4770 sizing_state.grouping.clear();
4771 }
4772
4773 let sizing_columns: Vec<ColumnDef<TData>> = columns
4774 .iter()
4775 .cloned()
4776 .map(|c| with_table_view_column_constraints(c, &props))
4777 .collect();
4778 let sizing_options = TableOptions {
4779 grouped_column_mode: props.grouped_column_mode,
4780 ..Default::default()
4781 };
4782
4783 let sizing_table = Table::builder(empty)
4784 .columns(sizing_columns)
4785 .state(sizing_state)
4786 .options(sizing_options)
4787 .build();
4788 let core_snapshot = sizing_table.core_model_snapshot();
4789
4790 let mut column_width_by_id: std::collections::HashMap<ColumnId, Px> =
4791 std::collections::HashMap::new();
4792 for col in columns {
4793 let w = core_snapshot
4794 .leaf_column_sizing
4795 .size
4796 .get(&col.id)
4797 .copied()
4798 .unwrap_or(col.size);
4799 column_width_by_id.insert(col.id.clone(), Px(w));
4800 }
4801 let column_width_by_id: Arc<std::collections::HashMap<ColumnId, Px>> =
4802 Arc::new(column_width_by_id);
4803
4804 let col_by_id_for_layout: std::collections::HashMap<&str, &ColumnDef<TData>> =
4805 columns.iter().map(|c| (c.id.as_ref(), c)).collect();
4806
4807 let visible_columns: Vec<&ColumnDef<TData>> = core_snapshot
4808 .leaf_columns
4809 .visible
4810 .iter()
4811 .filter_map(|id| col_by_id_for_layout.get(id.as_ref()).copied())
4812 .collect();
4813 let left_cols: Vec<&ColumnDef<TData>> = core_snapshot
4814 .leaf_columns
4815 .left_visible
4816 .iter()
4817 .filter_map(|id| col_by_id_for_layout.get(id.as_ref()).copied())
4818 .collect();
4819 let center_cols: Vec<&ColumnDef<TData>> = core_snapshot
4820 .leaf_columns
4821 .center_visible
4822 .iter()
4823 .filter_map(|id| col_by_id_for_layout.get(id.as_ref()).copied())
4824 .collect();
4825 let right_cols: Vec<&ColumnDef<TData>> = core_snapshot
4826 .leaf_columns
4827 .right_visible
4828 .iter()
4829 .filter_map(|id| col_by_id_for_layout.get(id.as_ref()).copied())
4830 .collect();
4831
4832 let sorting_key = if grouping.is_empty() {
4833 state_value.sorting.clone()
4834 } else {
4835 Vec::new()
4836 };
4837
4838 let has_row_pinning =
4839 grouping.is_empty() && is_some_rows_pinned(&state_value.row_pinning, None);
4840 let row_order = if grouping.is_empty() && !has_row_pinning {
4841 Some(cx.slot_state(FlatRowOrderCache::default, |cache| {
4842 let deps = FlatRowOrderDeps {
4843 items_revision,
4844 data_len: data.len(),
4845 sorting: sorting_key.clone(),
4846 column_filters: state_value.column_filters.clone(),
4847 global_filter: state_value.global_filter.clone(),
4848 };
4849
4850 let started = Instant::now();
4851 let (order, recomputed) = cache.row_order(data, columns, deps);
4852 let elapsed = started.elapsed();
4853
4854 if profile && recomputed {
4855 tracing::info!(
4856 "table_virtualized: recompute row_order len={} sorting={} took {:.2}ms",
4857 data.len(),
4858 sorting_key.len(),
4859 elapsed.as_secs_f64() * 1000.0
4860 );
4861 }
4862
4863 order.clone()
4864 }))
4865 } else {
4866 None
4867 };
4868
4869 let page_display_rows: Vec<DisplayRow> = if grouping.is_empty() {
4870 if has_row_pinning {
4871 let options = crate::headless::table::TableOptions {
4872 keep_pinned_rows: props.keep_pinned_rows,
4873 ..Default::default()
4874 };
4875
4876 let table_pre = Table::builder(data)
4877 .columns(columns.to_vec())
4878 .global_filter_fn(FilteringFnSpec::Auto)
4879 .get_row_key(|row, idx, _parent| row_key_at(row, idx))
4880 .state(state_value.clone())
4881 .options(options)
4882 .build();
4883
4884 let total_rows = table_pre.pre_pagination_row_model().root_rows().len();
4885 let bounds = pagination_bounds(total_rows, state_value.pagination);
4886 if bounds.page_index != state_value.pagination.page_index {
4887 let _ = cx.app.models_mut().update(&state, |st| {
4888 st.pagination.page_index = bounds.page_index;
4889 });
4890 }
4891 if let Some(out) = output.clone() {
4892 let next = TableViewOutput {
4893 filtered_row_count: total_rows,
4894 pagination: bounds,
4895 };
4896 let _ = cx.app.models_mut().update(&out, |v| {
4897 if *v != next {
4898 *v = next;
4899 }
4900 });
4901 }
4902
4903 let mut render_state = state_value.clone();
4904 render_state.pagination.page_index = bounds.page_index;
4905 let table = Table::builder(data)
4906 .columns(columns.to_vec())
4907 .global_filter_fn(FilteringFnSpec::Auto)
4908 .get_row_key(|row, idx, _parent| row_key_at(row, idx))
4909 .state(render_state)
4910 .options(options)
4911 .build();
4912
4913 let core = table.core_row_model();
4914 table
4915 .top_row_keys()
4916 .into_iter()
4917 .chain(table.center_row_keys())
4918 .chain(table.bottom_row_keys())
4919 .filter_map(|row_key| {
4920 let row_index = core.row_by_key(row_key)?;
4921 let row = core.row(row_index)?;
4922 if row.parent.is_some() {
4923 return None;
4924 }
4925 let data_index = row.index;
4926 if data_index >= data.len() {
4927 return None;
4928 }
4929 Some(DisplayRow::Leaf {
4930 data_index,
4931 row_key,
4932 depth: row.depth as usize,
4933 })
4934 })
4935 .collect()
4936 } else {
4937 let row_order = row_order.expect("row_order");
4938 let total_rows = row_order.len();
4939 let bounds = pagination_bounds(total_rows, state_value.pagination);
4940 if bounds.page_index != state_value.pagination.page_index {
4941 let _ = cx.app.models_mut().update(&state, |st| {
4942 st.pagination.page_index = bounds.page_index;
4943 });
4944 }
4945 if let Some(out) = output.clone() {
4946 let next = TableViewOutput {
4947 filtered_row_count: total_rows,
4948 pagination: bounds,
4949 };
4950 let _ = cx.app.models_mut().update(&out, |v| {
4951 if *v != next {
4952 *v = next;
4953 }
4954 });
4955 }
4956
4957 let page_rows: &[usize] = if bounds.page_count == 0 {
4958 &[]
4959 } else {
4960 row_order
4961 .get(bounds.page_start..bounds.page_end)
4962 .unwrap_or_default()
4963 };
4964 page_rows
4965 .iter()
4966 .map(|&data_index| DisplayRow::Leaf {
4967 data_index,
4968 row_key: row_key_at(&data[data_index], data_index),
4969 depth: 0,
4970 })
4971 .collect()
4972 }
4973 } else {
4974 let deps = GroupedDisplayDeps {
4975 base: GroupedBaseDeps {
4976 items_revision,
4977 data_len: data.len(),
4978 columns_fingerprint: columns_fingerprint(columns),
4979 grouping: grouping.to_vec(),
4980 column_filters: state_value.column_filters.clone(),
4981 global_filter: state_value.global_filter.clone(),
4982 },
4983 sorting: state_value.sorting.clone(),
4984 expanding: state_value.expanding.clone(),
4985 row_pinning: state_value.row_pinning.clone(),
4986 grouped_row_pinning_policy: props.grouped_row_pinning_policy,
4987 page_index: state_value.pagination.page_index,
4988 page_size: state_value.pagination.page_size,
4989 };
4990
4991 let (page_rows, view_output, clamp_to_page): (
4992 Vec<DisplayRow>,
4993 TableViewOutput,
4994 Option<usize>,
4995 ) = cx.slot_state(GroupedDisplayCache::default, |cache| {
4996 if cache.deps.as_ref() == Some(&deps) {
4997 return (cache.page_rows.clone(), cache.output.clone(), None);
4998 }
4999
5000 if cache.base_deps.as_ref() == Some(&deps.base) {
5001 let grouped = &cache.grouped;
5002 let row_index_by_key = &cache.row_index_by_key;
5003 let group_labels = &cache.group_labels;
5004 let group_aggs_text = &cache.group_aggs_text;
5005 let group_aggs_u64 = &cache.group_aggs_u64;
5006 let group_aggs_any = &cache.group_aggs_any;
5007
5008 let mut visible: Vec<DisplayRow> = Vec::new();
5009 let mut roots: Vec<crate::headless::table::GroupedRowIndex> =
5010 grouped.root_rows().to_vec();
5011 sort_grouped_row_indices_in_place(
5012 grouped,
5013 &mut roots,
5014 deps.sorting.as_slice(),
5015 columns,
5016 data,
5017 row_index_by_key,
5018 group_aggs_u64,
5019 group_aggs_any,
5020 );
5021
5022 for root in roots {
5023 push_visible(
5024 grouped,
5025 root,
5026 row_index_by_key,
5027 group_labels,
5028 group_aggs_text,
5029 group_aggs_u64,
5030 group_aggs_any,
5031 deps.sorting.as_slice(),
5032 columns,
5033 data,
5034 &deps.expanding,
5035 &mut visible,
5036 );
5037 }
5038
5039 let total_rows = visible.len();
5040 let bounds = pagination_bounds(
5041 total_rows,
5042 PaginationState {
5043 page_index: deps.page_index,
5044 page_size: deps.page_size,
5045 },
5046 );
5047 cache.output = TableViewOutput {
5048 filtered_row_count: total_rows,
5049 pagination: bounds,
5050 };
5051
5052 let page_rows_center: Vec<DisplayRow> = if bounds.page_count == 0 {
5053 Vec::new()
5054 } else {
5055 visible
5056 .get(bounds.page_start..bounds.page_end)
5057 .unwrap_or_default()
5058 .to_vec()
5059 };
5060
5061 let page_rows = apply_grouped_row_pinning_policy(
5062 &visible,
5063 &page_rows_center,
5064 &deps.row_pinning,
5065 deps.grouped_row_pinning_policy,
5066 );
5067
5068 cache.deps = Some(deps.clone());
5069 cache.page_rows = page_rows.clone();
5070 return (
5071 page_rows,
5072 cache.output.clone(),
5073 (bounds.page_index != deps.page_index).then_some(bounds.page_index),
5074 );
5075 }
5076
5077 let mut row_index_by_key: std::collections::HashMap<RowKey, usize> =
5078 std::collections::HashMap::with_capacity(data.len());
5079 for (i, item) in data.iter().enumerate() {
5080 let key = row_key_at(item, i);
5081 row_index_by_key.entry(key).or_insert(i);
5082 }
5083
5084 let col_by_id: std::collections::HashMap<&str, &ColumnDef<TData>> =
5085 columns.iter().map(|c| (c.id.as_ref(), c)).collect();
5086
5087 let agg_columns: Vec<&ColumnDef<TData>> = columns
5088 .iter()
5089 .filter(|c| c.aggregation != Aggregation::None)
5090 .collect();
5091
5092 let options = crate::headless::table::TableOptions {
5093 manual_sorting: true,
5094 manual_pagination: true,
5095 manual_expanding: true,
5096 keep_pinned_rows: props.keep_pinned_rows,
5097 ..Default::default()
5098 };
5099
5100 let mut state_for_grouping = state_value.clone();
5101 state_for_grouping.sorting.clear();
5102 state_for_grouping.pagination = Default::default();
5103
5104 let table = Table::builder(data)
5105 .columns(columns.to_vec())
5106 .state(state_for_grouping)
5107 .options(options)
5108 .get_row_key(|row, index, _parent| row_key_at(row, index))
5109 .build();
5110
5111 let grouped = table.grouped_row_model().clone();
5112 fn compute_group_aggregations<TData>(
5113 model: &crate::headless::table::GroupedRowModel,
5114 data: &[TData],
5115 row_index_by_key: &std::collections::HashMap<RowKey, usize>,
5116 agg_columns: &[&ColumnDef<TData>],
5117 ) -> (GroupAggsU64, GroupAggsText) {
5118 if agg_columns.is_empty() {
5119 return (Default::default(), Default::default());
5120 }
5121 let out_u64 =
5122 compute_grouped_u64_aggregations(model, data, row_index_by_key, agg_columns);
5123
5124 let mut out_text: GroupAggsText = Default::default();
5125 for (&row_key, entries) in &out_u64 {
5126 let mut text_values: Vec<(ColumnId, Arc<str>)> =
5127 Vec::with_capacity(entries.len());
5128 for (col_id, v) in entries.iter() {
5129 text_values.push((col_id.clone(), Arc::from(v.to_string())));
5130 }
5131 out_text.insert(row_key, Arc::from(text_values.into_boxed_slice()));
5132 }
5133
5134 (out_u64, out_text)
5135 }
5136
5137 fn group_label_for_key<TData>(
5138 kind: &GroupedRowKind,
5139 data: &[TData],
5140 row_index_by_key: &std::collections::HashMap<RowKey, usize>,
5141 col_by_id: &std::collections::HashMap<&str, &ColumnDef<TData>>,
5142 ) -> Arc<str> {
5143 let GroupedRowKind::Group {
5144 grouping_column,
5145 grouping_value,
5146 first_leaf_row_key,
5147 leaf_row_count,
5148 } = kind
5149 else {
5150 return Arc::from("");
5151 };
5152
5153 let mut value: Arc<str> = Arc::from(format!("{:x}", grouping_value));
5154 if let Some(column) = col_by_id.get(grouping_column.as_ref()).copied()
5155 && let Some(f) = column.facet_str_fn.as_ref()
5156 && let Some(&i) = row_index_by_key.get(first_leaf_row_key)
5157 {
5158 value = Arc::from(f(&data[i]));
5159 }
5160
5161 Arc::from(format!("{value} ({leaf_row_count})"))
5162 }
5163
5164 fn push_visible<TData>(
5165 model: &crate::headless::table::GroupedRowModel,
5166 index: crate::headless::table::GroupedRowIndex,
5167 row_index_by_key: &std::collections::HashMap<RowKey, usize>,
5168 group_labels: &std::collections::HashMap<RowKey, Arc<str>>,
5169 group_aggs_text: &GroupAggsText,
5170 group_aggs_u64: &GroupAggsU64,
5171 group_aggs_any: &GroupAggsAny,
5172 sorting: &[SortSpec],
5173 columns: &[ColumnDef<TData>],
5174 data: &[TData],
5175 expanded: &ExpandingState,
5176 out: &mut Vec<DisplayRow>,
5177 ) {
5178 let Some(row) = model.row(index) else {
5179 return;
5180 };
5181
5182 match &row.kind {
5183 GroupedRowKind::Group {
5184 grouping_column, ..
5185 } => {
5186 let expanded_here = is_row_expanded(row.key, expanded);
5187 let grouping_column = grouping_column.clone();
5188 let aggregations = group_aggs_text
5189 .get(&row.key)
5190 .cloned()
5191 .unwrap_or_else(|| Arc::from([]));
5192 out.push(DisplayRow::Group {
5193 grouping_column,
5194 row_key: row.key,
5195 depth: row.depth,
5196 label: group_labels
5197 .get(&row.key)
5198 .cloned()
5199 .unwrap_or_else(|| Arc::from("")),
5200 expanded: expanded_here,
5201 aggregations,
5202 });
5203
5204 if expanded_here {
5205 let mut children: Option<Vec<crate::headless::table::GroupedRowIndex>> =
5206 None;
5207
5208 if !sorting.is_empty() {
5209 let mut owned = row.sub_rows.clone();
5210 sort_grouped_row_indices_in_place(
5211 model,
5212 &mut owned,
5213 sorting,
5214 columns,
5215 data,
5216 row_index_by_key,
5217 group_aggs_u64,
5218 group_aggs_any,
5219 );
5220 children = Some(owned);
5221 }
5222
5223 let child_iter: Box<
5224 dyn Iterator<Item = crate::headless::table::GroupedRowIndex>,
5225 > = if let Some(children) = children {
5226 Box::new(children.into_iter())
5227 } else {
5228 Box::new(row.sub_rows.iter().copied())
5229 };
5230
5231 for child in child_iter {
5232 push_visible(
5233 model,
5234 child,
5235 row_index_by_key,
5236 group_labels,
5237 group_aggs_text,
5238 group_aggs_u64,
5239 group_aggs_any,
5240 sorting,
5241 columns,
5242 data,
5243 expanded,
5244 out,
5245 );
5246 }
5247 }
5248 }
5249 GroupedRowKind::Leaf { row_key } => {
5250 let Some(&data_index) = row_index_by_key.get(row_key) else {
5251 return;
5252 };
5253 out.push(DisplayRow::Leaf {
5254 data_index,
5255 row_key: *row_key,
5256 depth: row.depth,
5257 });
5258 }
5259 }
5260 }
5261
5262 let (group_aggs_u64, group_aggs_text) =
5263 compute_group_aggregations(&grouped, data, &row_index_by_key, &agg_columns);
5264 let group_aggs_any = table.grouped_aggregations_any().clone();
5265
5266 let mut group_labels: std::collections::HashMap<RowKey, Arc<str>> = Default::default();
5267 for &node in grouped.flat_rows() {
5268 let Some(row) = grouped.row(node) else {
5269 continue;
5270 };
5271 if matches!(row.kind, GroupedRowKind::Group { .. }) {
5272 group_labels.insert(
5273 row.key,
5274 group_label_for_key(&row.kind, data, &row_index_by_key, &col_by_id),
5275 );
5276 }
5277 }
5278
5279 let mut visible: Vec<DisplayRow> = Vec::new();
5280 let mut roots: Vec<crate::headless::table::GroupedRowIndex> =
5281 grouped.root_rows().to_vec();
5282 sort_grouped_row_indices_in_place(
5283 &grouped,
5284 &mut roots,
5285 deps.sorting.as_slice(),
5286 columns,
5287 data,
5288 &row_index_by_key,
5289 &group_aggs_u64,
5290 &group_aggs_any,
5291 );
5292
5293 for root in roots {
5294 push_visible(
5295 &grouped,
5296 root,
5297 &row_index_by_key,
5298 &group_labels,
5299 &group_aggs_text,
5300 &group_aggs_u64,
5301 &group_aggs_any,
5302 deps.sorting.as_slice(),
5303 columns,
5304 data,
5305 &state_value.expanding,
5306 &mut visible,
5307 );
5308 }
5309
5310 let total_rows = visible.len();
5311 let bounds = pagination_bounds(total_rows, state_value.pagination);
5312
5313 cache.output = TableViewOutput {
5314 filtered_row_count: total_rows,
5315 pagination: bounds,
5316 };
5317
5318 let page_rows_center: Vec<DisplayRow> = if bounds.page_count == 0 {
5319 Vec::new()
5320 } else {
5321 visible
5322 .get(bounds.page_start..bounds.page_end)
5323 .unwrap_or_default()
5324 .to_vec()
5325 };
5326
5327 let page_rows = apply_grouped_row_pinning_policy(
5328 &visible,
5329 &page_rows_center,
5330 &deps.row_pinning,
5331 deps.grouped_row_pinning_policy,
5332 );
5333
5334 cache.base_deps = Some(deps.base.clone());
5335 cache.grouped = grouped;
5336 cache.row_index_by_key = row_index_by_key;
5337 cache.group_labels = group_labels;
5338 cache.group_aggs_u64 = group_aggs_u64;
5339 cache.group_aggs_any = group_aggs_any;
5340 cache.group_aggs_text = group_aggs_text;
5341 cache.deps = Some(deps.clone());
5342 cache.page_rows = page_rows.clone();
5343 (
5344 page_rows,
5345 cache.output.clone(),
5346 (bounds.page_index != deps.page_index).then_some(bounds.page_index),
5347 )
5348 });
5349
5350 if let Some(page_index) = clamp_to_page {
5351 let _ = cx.app.models_mut().update(&state, |st| {
5352 st.pagination.page_index = page_index;
5353 });
5354 }
5355 if let Some(out) = output {
5356 let _ = cx.app.models_mut().update(&out, |v| {
5357 if *v != view_output {
5358 *v = view_output;
5359 }
5360 });
5361 }
5362
5363 page_rows
5364 };
5365
5366 let set_size = page_display_rows.len();
5367
5368 let mut list_options = fret_ui::element::VirtualListOptions::new(row_h, props.overscan);
5369 list_options.items_revision = items_revision;
5370 list_options.keep_alive = props
5371 .keep_alive
5372 .unwrap_or_else(|| props.overscan.saturating_mul(2));
5373 match props.row_measure_mode {
5374 TableRowMeasureMode::Fixed => {
5375 list_options.measure_mode = fret_ui::element::VirtualListMeasureMode::Fixed;
5376 list_options.key_cache = fret_ui::element::VirtualListKeyCacheMode::VisibleOnly;
5377 }
5378 TableRowMeasureMode::Measured => {
5379 list_options.measure_mode = fret_ui::element::VirtualListMeasureMode::Measured;
5380 list_options.key_cache = fret_ui::element::VirtualListKeyCacheMode::AllKeys;
5381 }
5382 }
5383
5384 let rendered_rows = Cell::new(0usize);
5385
5386 let (
5387 active_index,
5388 anchor_index,
5389 row_meta,
5390 active_element,
5391 active_command,
5392 typeahead,
5393 typeahead_timer,
5394 ) = cx.slot_state(TableKeyboardNavState::default, |st| {
5395 (
5396 st.active_index.clone(),
5397 st.anchor_index.clone(),
5398 st.row_meta.clone(),
5399 st.active_element.clone(),
5400 st.active_command.clone(),
5401 st.typeahead.clone(),
5402 st.typeahead_timer.clone(),
5403 )
5404 });
5405
5406 {
5407 let typeahead_label_at = typeahead_label_at.clone();
5408 let typeahead_facet_str_fn = visible_columns.iter().find_map(|c| c.facet_str_fn.clone());
5409 let next_meta: Arc<[TableNavRowMeta]> = page_display_rows
5410 .iter()
5411 .map(|row| match row {
5412 DisplayRow::Leaf {
5413 data_index,
5414 row_key,
5415 ..
5416 } => {
5417 let label = typeahead_label_at
5418 .as_ref()
5419 .map(|f| f(&data[*data_index], *data_index))
5420 .or_else(|| {
5421 typeahead_facet_str_fn
5422 .as_ref()
5423 .map(|f| Arc::<str>::from(f(&data[*data_index]).to_string()))
5424 })
5425 .unwrap_or_else(|| Arc::from(""));
5426 TableNavRowMeta {
5427 row_key: *row_key,
5428 kind: TableNavRowKind::Leaf,
5429 data_index: Some(*data_index),
5430 label,
5431 }
5432 }
5433 DisplayRow::Group { row_key, label, .. } => TableNavRowMeta {
5434 row_key: *row_key,
5435 kind: TableNavRowKind::Group,
5436 data_index: None,
5437 label: label.clone(),
5438 },
5439 })
5440 .collect::<Vec<_>>()
5441 .into();
5442 *row_meta.borrow_mut() = next_meta;
5443 }
5444
5445 if set_size == 0 {
5446 if active_index.get().is_some() {
5447 active_index.set(None);
5448 }
5449 } else {
5450 let next = Some(
5451 active_index
5452 .get()
5453 .unwrap_or(0)
5454 .min(set_size.saturating_sub(1)),
5455 );
5456 if active_index.get() != next {
5457 active_index.set(next);
5458 }
5459 }
5460
5461 let key_handler: fret_ui::action::OnKeyDown = {
5462 let active_index = active_index.clone();
5463 let anchor_index = anchor_index.clone();
5464 let row_meta = row_meta.clone();
5465 let active_command = active_command.clone();
5466 let vertical_scroll = vertical_scroll.clone();
5467 let state = state.clone();
5468 let enable_row_selection = props.enable_row_selection;
5469 let single_row_selection = props.single_row_selection;
5470 let typeahead = typeahead.clone();
5471 let typeahead_timer = typeahead_timer.clone();
5472
5473 Arc::new(move |host, action_cx, down| {
5474 let meta = row_meta.borrow().clone();
5475 let len = meta.len();
5476 if len == 0 {
5477 if active_index.get().is_some() {
5478 active_index.set(None);
5479 host.request_redraw(action_cx.window);
5480 }
5481 return false;
5482 }
5483
5484 let current = active_index.get().unwrap_or(0).min(len.saturating_sub(1));
5485 let has_disallowed_mods = down.modifiers.alt || down.modifiers.alt_gr;
5486 if has_disallowed_mods {
5487 return false;
5488 }
5489 let primary = (down.modifiers.ctrl || down.modifiers.meta) && !down.modifiers.alt_gr;
5490
5491 let cancel_typeahead_timer = |host: &mut dyn fret_ui::action::UiFocusActionHost| {
5492 if let Some(token) = typeahead_timer.get() {
5493 host.push_effect(Effect::CancelTimer { token });
5494 typeahead_timer.set(None);
5495 }
5496 };
5497 let clear_typeahead = |host: &mut dyn fret_ui::action::UiFocusActionHost| {
5498 typeahead.borrow_mut().clear();
5499 cancel_typeahead_timer(host);
5500 };
5501
5502 match down.key {
5503 KeyCode::KeyA if primary && enable_row_selection && !single_row_selection => {
5504 let leaf_keys = table_collect_leaf_keys(&meta);
5505 if leaf_keys.is_empty() {
5506 return false;
5507 }
5508
5509 let all_selected = host
5510 .models_mut()
5511 .read(&state, |st| {
5512 leaf_keys.iter().all(|k| st.row_selection.contains(k))
5513 })
5514 .ok()
5515 .unwrap_or(false);
5516
5517 let _ = host.models_mut().update(&state, move |st| {
5518 if down.modifiers.shift {
5519 st.row_selection.clear();
5520 return;
5521 }
5522
5523 if all_selected {
5524 for k in &leaf_keys {
5525 st.row_selection.remove(k);
5526 }
5527 } else {
5528 for k in &leaf_keys {
5529 st.row_selection.insert(*k);
5530 }
5531 }
5532 });
5533 clear_typeahead(host);
5534 host.request_redraw(action_cx.window);
5535 true
5536 }
5537 KeyCode::ArrowDown
5538 | KeyCode::ArrowUp
5539 | KeyCode::Home
5540 | KeyCode::End
5541 | KeyCode::PageDown
5542 | KeyCode::PageUp => {
5543 if primary {
5544 return false;
5545 }
5546
5547 let page = 10usize;
5548 let next = match down.key {
5549 KeyCode::ArrowDown => (current + 1).min(len.saturating_sub(1)),
5550 KeyCode::ArrowUp => current.saturating_sub(1),
5551 KeyCode::Home => 0,
5552 KeyCode::End => len.saturating_sub(1),
5553 KeyCode::PageDown => (current + page).min(len.saturating_sub(1)),
5554 KeyCode::PageUp => current.saturating_sub(page),
5555 _ => current,
5556 };
5557
5558 if next != current {
5559 active_index.set(Some(next));
5560 *active_command.borrow_mut() = None;
5561 vertical_scroll.scroll_to_item(next, ScrollStrategy::Nearest);
5562
5563 if down.modifiers.shift {
5564 if enable_row_selection
5565 && let Some(m) = meta.get(next)
5566 && m.kind == TableNavRowKind::Leaf
5567 {
5568 let anchor_key =
5569 anchor_index.get().unwrap_or(meta[current].row_key);
5570 if anchor_index.get().is_none() {
5571 anchor_index.set(Some(anchor_key));
5572 }
5573 let anchor = meta
5574 .iter()
5575 .position(|m| m.row_key == anchor_key)
5576 .unwrap_or(current);
5577 let (a, b) = if anchor <= next {
5578 (anchor, next)
5579 } else {
5580 (next, anchor)
5581 };
5582
5583 let leaf_range: Vec<RowKey> = if single_row_selection {
5584 vec![m.row_key]
5585 } else {
5586 table_collect_leaf_keys_in_range(&meta, a, b)
5587 };
5588
5589 if !leaf_range.is_empty() {
5590 let _ = host.models_mut().update(&state, move |st| {
5591 st.row_selection.clear();
5592 st.row_selection.extend(leaf_range.iter().copied());
5593 });
5594 }
5595 }
5596 } else {
5597 anchor_index.set(Some(meta[next].row_key));
5598 }
5599
5600 clear_typeahead(host);
5601 host.request_redraw(action_cx.window);
5602 }
5603 true
5604 }
5605 KeyCode::Enter | KeyCode::NumpadEnter | KeyCode::Space => {
5606 if primary {
5607 return false;
5608 }
5609
5610 let Some(meta) = meta.get(current).cloned() else {
5611 return false;
5612 };
5613
5614 if let Some(cmd) = active_command.borrow().clone() {
5615 host.dispatch_command(Some(action_cx.window), cmd);
5616 }
5617
5618 match meta.kind {
5619 TableNavRowKind::Group => {
5620 let row_key = meta.row_key;
5621 let _ = host.models_mut().update(&state, move |st| {
5622 match &mut st.expanding {
5623 ExpandingState::All => {
5624 st.expanding = ExpandingState::default();
5625 }
5626 ExpandingState::Keys(keys) => {
5627 if keys.contains(&row_key) {
5628 keys.remove(&row_key);
5629 } else {
5630 keys.insert(row_key);
5631 }
5632 }
5633 }
5634 });
5635 clear_typeahead(host);
5636 host.request_redraw(action_cx.window);
5637 true
5638 }
5639 TableNavRowKind::Leaf => {
5640 if !enable_row_selection {
5641 return false;
5642 }
5643
5644 let row_key = meta.row_key;
5645 let _ = host.models_mut().update(&state, move |st| {
5646 let selected = st.row_selection.contains(&row_key);
5647 if single_row_selection {
5648 st.row_selection.clear();
5649 }
5650 if selected {
5651 st.row_selection.remove(&row_key);
5652 } else {
5653 st.row_selection.insert(row_key);
5654 }
5655 });
5656 anchor_index.set(Some(row_key));
5657 clear_typeahead(host);
5658 host.request_redraw(action_cx.window);
5659 true
5660 }
5661 }
5662 }
5663 _ => {
5664 if primary {
5665 return false;
5666 }
5667 if down.repeat {
5668 return false;
5669 }
5670 let Some(input) = fret_core::keycode_to_ascii_lowercase(down.key) else {
5671 return false;
5672 };
5673
5674 typeahead.borrow_mut().push_char(input, 0);
5675 let typeahead_buf = typeahead.borrow();
5676 let Some(query) = typeahead_buf.query(0) else {
5677 return false;
5678 };
5679
5680 let labels: Vec<Arc<str>> = meta.iter().map(|m| m.label.clone()).collect();
5681 let disabled: Vec<bool> =
5682 meta.iter().map(|m| m.label.trim().is_empty()).collect();
5683
5684 let next = match_prefix_arc_str(&labels, &disabled, query, Some(current), true);
5685 if let Some(next) = next
5686 && next != current
5687 {
5688 active_index.set(Some(next));
5689 anchor_index.set(Some(meta[next].row_key));
5690 *active_command.borrow_mut() = None;
5691 vertical_scroll.scroll_to_item(next, ScrollStrategy::Nearest);
5692 }
5693
5694 cancel_typeahead_timer(host);
5695 let token = host.next_timer_token();
5696 typeahead_timer.set(Some(token));
5697 host.push_effect(Effect::SetTimer {
5698 window: Some(action_cx.window),
5699 token,
5700 after: TABLE_TYPEAHEAD_TIMEOUT,
5701 repeat: None,
5702 });
5703
5704 host.request_redraw(action_cx.window);
5705 true
5706 }
5707 }
5708 })
5709 };
5710
5711 let list = cx.semantics_with_id(
5712 SemanticsProps {
5713 role: SemanticsRole::List,
5714 focusable: true,
5715 ..Default::default()
5716 },
5717 |cx, list_id| {
5718 cx.key_on_key_down_for(list_id, key_handler.clone());
5719 if let Some(copy_text_at) = copy_text_at.clone() {
5720 let copy_text_for_command = copy_text_at.clone();
5721 let state_for_command = state.clone();
5722 let row_meta_for_command = row_meta.clone();
5723 cx.command_on_command_for(
5724 list_id,
5725 Arc::new(move |host, acx, command| {
5726 if command.as_str() != "edit.copy" {
5727 return false;
5728 }
5729 let meta = row_meta_for_command.borrow().clone();
5730 let selected_keys = host
5731 .models_mut()
5732 .read(&state_for_command, |st| st.row_selection.clone())
5733 .ok()
5734 .unwrap_or_default();
5735 let models = host.models_mut();
5736 let mut lines = Vec::new();
5737 if !selected_keys.is_empty() {
5738 for m in meta.iter() {
5739 if m.kind != TableNavRowKind::Leaf {
5740 continue;
5741 }
5742 if !selected_keys.contains(&m.row_key) {
5743 continue;
5744 }
5745 if let Some(data_index) = m.data_index
5746 && let Some(text) = (copy_text_for_command)(&*models, data_index)
5747 {
5748 lines.push(text);
5749 }
5750 }
5751 }
5752
5753 if lines.is_empty() {
5754 return true;
5755 }
5756 let token = host.next_clipboard_token();
5757 host.push_effect(Effect::ClipboardWriteText {
5758 window: acx.window,
5759 token,
5760 text: lines.join("\n"),
5761 });
5762 true
5763 }),
5764 );
5765
5766 let state_for_availability = state.clone();
5767 let row_meta_for_availability = row_meta.clone();
5768 cx.command_on_command_availability_for(
5769 list_id,
5770 Arc::new(move |host, acx, command| {
5771 if command.as_str() != "edit.copy" {
5772 return fret_ui::CommandAvailability::NotHandled;
5773 }
5774 if !acx.focus_in_subtree {
5775 return fret_ui::CommandAvailability::NotHandled;
5776 }
5777 if !acx.input_ctx.caps.clipboard.text.write {
5778 return fret_ui::CommandAvailability::Blocked;
5779 }
5780
5781 let meta = row_meta_for_availability.borrow().clone();
5782 let selected_keys = host
5783 .models_mut()
5784 .read(&state_for_availability, |st| st.row_selection.clone())
5785 .ok()
5786 .unwrap_or_default();
5787 if !selected_keys.is_empty() {
5788 for m in meta.iter() {
5789 if m.kind == TableNavRowKind::Leaf && selected_keys.contains(&m.row_key) {
5790 return fret_ui::CommandAvailability::Available;
5791 }
5792 }
5793 return fret_ui::CommandAvailability::Blocked;
5794 }
5795
5796 fret_ui::CommandAvailability::Blocked
5797 }),
5798 );
5799 }
5800 {
5801 let typeahead = typeahead.clone();
5802 let typeahead_timer = typeahead_timer.clone();
5803 cx.timer_on_timer_for(
5804 list_id,
5805 Arc::new(move |host, action_cx, token| {
5806 if typeahead_timer.get() != Some(token) {
5807 return false;
5808 }
5809 typeahead_timer.set(None);
5810 typeahead.borrow_mut().clear();
5811 host.request_redraw(action_cx.window);
5812 true
5813 }),
5814 );
5815 }
5816 let focus_ring = RingStyle {
5817 placement: RingPlacement::Inset,
5818 width: Px(2.0),
5819 offset: Px(0.0),
5820 color: ring,
5821 offset_color: None,
5822 corner_radii: Corners::all(radius),
5823 };
5824 vec![cx.container(
5825 ContainerProps {
5826 layout: table_clip_fill_layout(),
5827 background: Some(table_bg),
5828 border: if props.draw_frame {
5829 Edges::all(Px(1.0))
5830 } else {
5831 Edges::all(Px(0.0))
5832 },
5833 border_color: if props.draw_frame { Some(border) } else { None },
5834 focus_border_color: if props.draw_frame { Some(ring) } else { None },
5835 focus_ring: Some(focus_ring),
5836 focus_within: true,
5837 corner_radii: if props.draw_frame {
5838 Corners::all(radius)
5839 } else {
5840 Corners::all(Px(0.0))
5841 },
5842 ..Default::default()
5843 },
5844 |cx| {
5845 vec![ui::v_flex(|cx| {
5846 let debug_header_row_test_id = debug_header_row_test_id.clone();
5847 let debug_header_cell_test_id_prefix =
5848 debug_header_cell_test_id_prefix.clone();
5849 let header = cx.container(
5850 ContainerProps {
5851 background: Some(header_bg),
5852 border: Edges {
5853 bottom: Px(1.0),
5854 ..Default::default()
5855 },
5856 border_color: Some(border),
5857 layout: LayoutStyle {
5858 size: fret_ui::element::SizeStyle {
5859 width: Length::Fill,
5860 height: Length::Px(header_h),
5861 min_height: Some(Length::Px(header_h)),
5862 max_height: Some(Length::Px(header_h)),
5863 ..Default::default()
5864 },
5865 flex: fret_ui::element::FlexItemStyle {
5866 shrink: 0.0,
5867 basis: Length::Px(header_h),
5868 ..Default::default()
5869 },
5870 ..Default::default()
5871 },
5872 ..Default::default()
5873 },
5874 |cx| {
5875 let mut render_header_group =
5876 |cx: &mut ElementContext<'_, H>,
5877 cols: &[&ColumnDef<TData>],
5878 scroll_x: Option<ScrollHandle>| {
5879 let column_width_by_id =
5880 column_width_by_id.clone();
5881 let debug_header_cell_test_id_prefix =
5882 debug_header_cell_test_id_prefix.clone();
5883 let row = ui::h_row(|cx| {
5884 let out: Vec<AnyElement> = cols.iter()
5885 .map(|col| {
5886 let sort_state = sort_for_column(
5887 &state_value.sorting,
5888 &col.id,
5889 );
5890
5891 let col_w = column_width_by_id
5892 .get(&col.id)
5893 .copied()
5894 .unwrap_or(Px(col.size));
5895
5896 let cell_props = ContainerProps {
5897 padding: Edges::all(Px(0.0)).into(),
5898 border: if props.optimize_grid_lines {
5899 Edges::default()
5900 } else {
5901 Edges {
5902 right: if props
5903 .enable_column_resizing
5904 && col.enable_resizing
5905 {
5906 Px(0.0)
5907 } else {
5908 Px(1.0)
5909 },
5910 ..Default::default()
5911 }
5912 },
5913 border_color: if props.optimize_grid_lines {
5914 None
5915 } else {
5916 Some(border)
5917 },
5918 layout: LayoutStyle {
5919 size: fret_ui::element::SizeStyle {
5920 width: Length::Px(col_w),
5921 min_width: Some(Length::Px(col_w)),
5922 max_width: Some(Length::Px(col_w)),
5923 ..Default::default()
5924 },
5925 flex: fret_ui::element::FlexItemStyle {
5926 shrink: 0.0,
5927 ..Default::default()
5928 },
5929 ..Default::default()
5930 },
5931 ..Default::default()
5932 };
5933
5934 let hoisted_test_id =
5935 Rc::new(RefCell::new(None));
5936 let hoisted_test_id_for_cell =
5937 hoisted_test_id.clone();
5938 let explicit_test_id =
5939 debug_header_cell_test_id_prefix
5940 .as_ref()
5941 .map(|prefix| {
5942 Arc::<str>::from(format!(
5943 "{prefix}{id}",
5944 id = col.id.as_ref()
5945 ))
5946 });
5947 let header_cell =
5948 cx.container(cell_props, |cx| {
5949 let mut out = Vec::new();
5950
5951 out.push(ui::h_row(|cx| {
5952 let mut pieces = Vec::new();
5953
5954 let enabled = props.enable_sorting
5955 && col.enable_sorting
5956 && (col.sort_cmp.is_some()
5957 || col.sort_value.is_some());
5958 let col_id = col.id.clone();
5959 let state_model =
5960 state.clone();
5961 let sort_options =
5962 TableOptions {
5963 enable_sorting: props
5964 .enable_sorting,
5965 ..TableOptions::default()
5966 };
5967 let sort_toggle_column =
5968 SortToggleColumn {
5969 id: col_id.clone(),
5970 enable_sorting: col
5971 .enable_sorting,
5972 enable_multi_sort: col
5973 .enable_multi_sort,
5974 sort_desc_first: col
5975 .sort_desc_first,
5976 has_sort_value_source: col
5977 .sort_cmp
5978 .is_some()
5979 || col.sort_value.is_some(),
5980 };
5981 let auto_sort_dir_desc = col
5982 .sort_value
5983 .as_ref()
5984 .and_then(|f| {
5985 data.first()
5986 .map(|r| f(r))
5987 })
5988 .map(|v| {
5989 !matches!(
5990 v,
5991 TanStackValue::String(_)
5992 )
5993 })
5994 .unwrap_or(false);
5995
5996 pieces.push(cx.pressable(
5997 PressableProps {
5998 layout: {
5999 let mut layout = LayoutStyle::default();
6000 layout.size.width = Length::Fill;
6001 layout.size.height = Length::Fill;
6002 layout.flex.grow = 1.0;
6003 layout.flex.shrink = 1.0;
6004 layout.flex.basis = Length::Px(Px(0.0));
6005 layout
6006 },
6007 enabled,
6008 a11y: PressableA11y {
6009 role: Some(
6010 SemanticsRole::Button,
6011 ),
6012 ..Default::default()
6013 },
6014 ..Default::default()
6015 },
6016 |cx, _| {
6017 if enabled {
6018 let state_model_for_pointer =
6019 state_model.clone();
6020 let sort_toggle_column_for_pointer =
6021 sort_toggle_column.clone();
6022 let sort_options_for_pointer =
6023 sort_options;
6024 cx.pressable_on_pointer_up(Arc::new(
6025 move |host, _acx, up| {
6026 if !up.is_click
6027 || up.button
6028 != fret_core::MouseButton::Left
6029 {
6030 return PressablePointerUpResult::Continue;
6031 }
6032
6033 let multi =
6034 up.modifiers.shift;
6035 let _ = host
6036 .update_model(
6037 &state_model_for_pointer,
6038 |st| {
6039 toggle_sorting_state_handler_tanstack(
6040 &mut st.sorting,
6041 &sort_toggle_column_for_pointer,
6042 sort_options_for_pointer,
6043 multi,
6044 auto_sort_dir_desc,
6045 );
6046 st.pagination.page_index = 0;
6047 },
6048 );
6049 host.notify(_acx);
6050 PressablePointerUpResult::SkipActivate
6051 },
6052 ));
6053
6054 cx.pressable_update_model(
6055 &state_model,
6056 move |st| {
6057 toggle_sorting_state_handler_tanstack(
6058 &mut st.sorting,
6059 &sort_toggle_column,
6060 sort_options,
6061 false,
6062 auto_sort_dir_desc,
6063 );
6064 st.pagination.page_index = 0;
6065 },
6066 );
6067 }
6068
6069 vec![cx.container(
6070 ContainerProps {
6071 padding: Edges::symmetric(
6072 cell_px, cell_py,
6073 )
6074 .into(),
6075 layout: {
6076 let mut layout =
6077 LayoutStyle::default();
6078 layout.size.width =
6079 Length::Fill;
6080 layout.size.height =
6081 Length::Fill;
6082 layout
6083 },
6084 ..Default::default()
6085 },
6086 |cx| {
6087 let items =
6088 render_header_cell(
6089 cx,
6090 col,
6091 sort_state,
6092 );
6093 let mut children =
6094 collect_children(
6095 cx, items,
6096 );
6097 *hoisted_test_id_for_cell
6098 .borrow_mut() =
6099 table_wrapper_test_id(
6100 &mut children,
6101 explicit_test_id.clone(),
6102 );
6103 children
6104 },
6105 )]
6106 },
6107 ));
6108
6109 if props.enable_column_resizing
6110 && col.enable_resizing
6111 {
6112 let col_id = col.id.clone();
6113 let state_model = state.clone();
6114 let default_w = Px(col.size);
6115 let min_w = col.min_size.max(props.min_column_width.0).max(0.0);
6116 let max_w = col.max_size.max(min_w);
6117 let resize_mode = props.column_resize_mode;
6118 let resize_direction = props.column_resize_direction;
6119 let grip_color = resize_grip;
6120
6121 if props.enable_column_resizing
6122 && props.column_resize_mode
6123 == ColumnResizeMode::OnEnd
6124 && state_value
6125 .column_sizing_info
6126 .is_resizing_column
6127 .as_ref()
6128 .is_some_and(|active| {
6129 active.as_ref()
6130 == col_id.as_ref()
6131 })
6132 {
6133 let delta = state_value
6134 .column_sizing_info
6135 .delta_offset
6136 .unwrap_or(0.0);
6137 pieces.push(cx.container(
6138 ContainerProps {
6139 background: Some(
6140 resize_preview,
6141 ),
6142 layout: LayoutStyle {
6143 size:
6144 fret_ui::element::SizeStyle {
6145 width: Length::Px(Px(2.0)),
6146 height: Length::Fill,
6147 ..Default::default()
6148 },
6149 position: fret_ui::element::PositionStyle::Absolute,
6150 inset: fret_ui::element::InsetStyle {
6151 top: Some(Px(0.0)).into(),
6152 right: Some(Px(-delta - 1.0)).into(),
6153 bottom: Some(Px(0.0)).into(),
6154 left: None.into(),
6155 },
6156 ..Default::default()
6157 },
6158 ..Default::default()
6159 },
6160 |_| Vec::new(),
6161 ));
6162 }
6163
6164 pieces.push(cx.pointer_region(
6165 PointerRegionProps {
6166 layout: LayoutStyle {
6167 size: fret_ui::element::SizeStyle {
6168 width: Length::Px(Px(12.0)),
6169 height: Length::Fill,
6170 ..Default::default()
6171 },
6172 position:
6173 fret_ui::element::PositionStyle::Absolute,
6174 inset: fret_ui::element::InsetStyle {
6175 top: Some(Px(0.0)).into(),
6176 right: Some(Px(0.0)).into(),
6177 bottom: Some(Px(0.0)).into(),
6178 left: None.into(),
6179 },
6180 ..Default::default()
6181 },
6182 enabled: true,
6183 capture_phase_pointer_moves: false,
6184 },
6185 |cx| {
6186 let state_model_down = state_model.clone();
6187 let state_model_move = state_model.clone();
6188 let state_model_up = state_model.clone();
6189 let col_id_down = col_id.clone();
6190 let col_id_move = col_id.clone();
6191 let col_id_up = col_id.clone();
6192
6193 cx.pointer_region_on_pointer_down(
6194 std::sync::Arc::new(move |host, _acx, down| {
6195 if down.button != fret_core::MouseButton::Left {
6196 return false;
6197 }
6198 host.capture_pointer();
6199 host.set_cursor_icon(CursorIcon::ColResize);
6200 let _ = host.models_mut().update(&state_model_down, |st| {
6201 let start = st
6202 .column_sizing
6203 .get(&col_id_down)
6204 .copied()
6205 .unwrap_or(default_w.0)
6206 .clamp(min_w, max_w);
6207 st.column_sizing.insert(col_id_down.clone(), start);
6208 begin_column_resize(
6209 &mut st.column_sizing_info,
6210 col_id_down.clone(),
6211 down.position.x.0,
6212 start,
6213 vec![(col_id_down.clone(), start)],
6214 );
6215 });
6216 true
6217 }),
6218 );
6219 cx.pointer_region_on_pointer_move(
6220 std::sync::Arc::new(move |host, _acx, mv| {
6221 host.set_cursor_icon(CursorIcon::ColResize);
6222 if !mv.buttons.left {
6223 return false;
6224 }
6225 let _ = host.models_mut().update(&state_model_move, |st| {
6226 let Some(active) = &st.column_sizing_info.is_resizing_column else { return; };
6227 if active.as_ref() != col_id_move.as_ref() { return; }
6228 drag_column_resize(
6229 resize_mode,
6230 resize_direction,
6231 &mut st.column_sizing,
6232 &mut st.column_sizing_info,
6233 mv.position.x.0,
6234 );
6235 if let Some(next) = st.column_sizing.get(&col_id_move).copied() {
6236 st.column_sizing.insert(col_id_move.clone(), next.clamp(min_w, max_w));
6237 }
6238 });
6239 true
6240 }),
6241 );
6242 cx.pointer_region_on_pointer_up(
6243 std::sync::Arc::new(move |host, _acx, up| {
6244 if up.button != fret_core::MouseButton::Left {
6245 return false;
6246 }
6247 host.release_pointer_capture();
6248 let _ = host.models_mut().update(&state_model_up, |st| {
6249 if st
6250 .column_sizing_info
6251 .is_resizing_column
6252 .as_deref()
6253 != Some(col_id_up.as_ref())
6254 {
6255 return;
6256 }
6257 end_column_resize(
6258 resize_mode,
6259 resize_direction,
6260 &mut st.column_sizing,
6261 &mut st.column_sizing_info,
6262 Some(up.position.x.0),
6263 );
6264 if let Some(next) = st.column_sizing.get(&col_id_up).copied() {
6265 st.column_sizing.insert(col_id_up.clone(), next.clamp(min_w, max_w));
6266 }
6267 });
6268 true
6269 }),
6270 );
6271 vec![ui::h_row(|cx| {
6272 [cx.container(
6273 ContainerProps {
6274 background: Some(grip_color),
6275 layout: LayoutStyle {
6276 size: fret_ui::element::SizeStyle {
6277 width: Length::Px(Px(1.0)),
6278 height: Length::Fill,
6279 ..Default::default()
6280 },
6281 flex: fret_ui::element::FlexItemStyle {
6282 shrink: 0.0,
6283 ..Default::default()
6284 },
6285 ..Default::default()
6286 },
6287 ..Default::default()
6288 },
6289 |_| Vec::new(),
6290 )]
6291 })
6292 .gap(Space::N0)
6293 .justify_end()
6294 .items_stretch()
6295 .into_element(cx)]
6296 },
6297 ));
6298 }
6299
6300 pieces
6301 })
6302 .layout(
6303 LayoutRefinement::default()
6304 .size_full()
6305 .relative(),
6306 )
6307 .gap(Space::N0)
6308 .justify_start()
6309 .items_center()
6310 .into_element(cx));
6311
6312 out
6313 });
6314
6315 if let Some(test_id) =
6316 hoisted_test_id.borrow_mut().take()
6317 {
6318 header_cell.test_id(test_id)
6319 } else {
6320 header_cell
6321 }
6322 })
6323 .collect();
6324
6325 out
6326 })
6327 .gap(Space::N0)
6328 .justify_start()
6329 .items_center()
6330 .into_element(cx);
6331
6332 if let Some(scroll_x) = scroll_x {
6333 cx.scroll(
6334 ScrollProps {
6335 axis: ScrollAxis::X,
6336 scroll_handle: Some(scroll_x),
6337 layout: LayoutStyle {
6338 size: fret_ui::element::SizeStyle {
6339 width: Length::Fill,
6340 height: Length::Fill,
6341 ..Default::default()
6342 },
6343 flex: fret_ui::element::FlexItemStyle {
6344 grow: 1.0,
6345 shrink: 1.0,
6346 basis: Length::Px(Px(0.0)),
6347 ..Default::default()
6348 },
6349 ..Default::default()
6350 },
6351 ..Default::default()
6352 },
6353 |_| vec![row],
6354 )
6355 } else {
6356 row
6357 }
6358 };
6359
6360 vec![ui::h_row(|cx| {
6361 let has_left = !left_cols.is_empty();
6362 let has_center = !center_cols.is_empty();
6363 let has_right = !right_cols.is_empty();
6364
6365 let divider_after_left = props.optimize_grid_lines
6366 && has_left
6367 && (has_center || has_right);
6368 let divider_after_center =
6369 props.optimize_grid_lines && has_center && has_right;
6370
6371 let left = render_header_group(cx, &left_cols, None);
6372 let left = if divider_after_left {
6373 cx.container(
6374 ContainerProps {
6375 border: Edges {
6376 right: Px(1.0),
6377 ..Default::default()
6378 },
6379 border_color: Some(border),
6380 layout: LayoutStyle {
6381 size: fret_ui::element::SizeStyle {
6382 height: Length::Fill,
6383 ..Default::default()
6384 },
6385 ..Default::default()
6386 },
6387 ..Default::default()
6388 },
6389 move |_| vec![left],
6390 )
6391 } else {
6392 left
6393 };
6394
6395 let center = render_header_group(
6396 cx,
6397 ¢er_cols,
6398 Some(scroll_x.clone()),
6399 );
6400 let center = if divider_after_center {
6401 cx.container(
6402 ContainerProps {
6403 border: Edges {
6404 right: Px(1.0),
6405 ..Default::default()
6406 },
6407 border_color: Some(border),
6408 layout: LayoutStyle {
6409 size: fret_ui::element::SizeStyle {
6410 height: Length::Fill,
6411 ..Default::default()
6412 },
6413 ..Default::default()
6414 },
6415 ..Default::default()
6416 },
6417 move |_| vec![center],
6418 )
6419 } else {
6420 center
6421 };
6422
6423 let right = render_header_group(cx, &right_cols, None);
6424 [left, center, right]
6425 })
6426 .gap(Space::N0)
6427 .justify_start()
6428 .items_stretch()
6429 .into_element(cx)]
6430 },
6431 );
6432 let header = if let Some(test_id) = debug_header_row_test_id {
6433 header.test_id(test_id)
6434 } else {
6435 header
6436 };
6437
6438 let debug_row_test_id_prefix = debug_row_test_id_prefix.clone();
6439 let body = cx.virtual_list_keyed_with_layout(
6440 {
6441 let mut layout = LayoutStyle::default();
6442 layout.size.width = Length::Fill;
6443 layout.flex.grow = 1.0;
6444 layout.flex.shrink = 1.0;
6445 layout.flex.basis = Length::Px(Px(0.0));
6446 layout
6447 },
6448 set_size,
6449 list_options,
6450 vertical_scroll,
6451 |i| page_display_rows[i].row_key().0,
6452 |cx, i| {
6453 rendered_rows.set(rendered_rows.get().saturating_add(1));
6454 let (data_index, row_key, depth) =
6455 match &page_display_rows[i] {
6456 DisplayRow::Leaf {
6457 data_index,
6458 row_key,
6459 depth,
6460 } => (
6461 *data_index,
6462 *row_key,
6463 u16::try_from(*depth).unwrap_or(u16::MAX),
6464 ),
6465 DisplayRow::Group {
6466 grouping_column,
6467 row_key,
6468 depth,
6469 label,
6470 expanded,
6471 aggregations,
6472 } => {
6473 let row_key = *row_key;
6474 let depth = *depth;
6475 let expanded = *expanded;
6476 let label = label.clone();
6477 let grouping_column = grouping_column.clone();
6478 let aggregations = aggregations.clone();
6479
6480 let label_target: ColumnId = if left_cols
6481 .iter()
6482 .chain(center_cols.iter())
6483 .chain(right_cols.iter())
6484 .any(|c| c.id.as_ref() == grouping_column.as_ref())
6485 {
6486 grouping_column.clone()
6487 } else {
6488 left_cols
6489 .first()
6490 .or_else(|| center_cols.first())
6491 .or_else(|| right_cols.first())
6492 .map(|c| c.id.clone())
6493 .unwrap_or_else(|| grouping_column.clone())
6494 };
6495
6496 let enabled = true;
6497 let active_index = active_index.clone();
6498 let anchor_index = anchor_index.clone();
6499 let active_element = active_element.clone();
6500 let active_command = active_command.clone();
6501 let typeahead = typeahead.clone();
6502 let typeahead_timer = typeahead_timer.clone();
6503 let focus_target = list_id;
6504 let row_test_id = debug_row_test_id_prefix
6505 .as_ref()
6506 .map(|prefix| {
6507 Arc::<str>::from(format!(
6508 "{prefix}{id}",
6509 id = row_key.0
6510 ))
6511 });
6512
6513 return cx.pressable_with_id(
6514 PressableProps {
6515 enabled,
6516 focusable: false,
6517 a11y: PressableA11y {
6518 role: Some(
6519 SemanticsRole::ListItem,
6520 ),
6521 expanded: Some(expanded),
6522 test_id: row_test_id,
6523 ..Default::default()
6524 }
6525 .with_collection_position(i, set_size),
6526 ..Default::default()
6527 },
6528 |cx, st, id| {
6529 let active_index_for_pointer =
6530 active_index.clone();
6531 let anchor_index_for_pointer =
6532 anchor_index.clone();
6533 let active_command_for_pointer =
6534 active_command.clone();
6535 let typeahead_for_pointer =
6536 typeahead.clone();
6537 let typeahead_timer_for_pointer =
6538 typeahead_timer.clone();
6539 cx.pressable_on_pointer_down(Arc::new(
6540 move |host, action_cx, down| {
6541 active_index_for_pointer.set(Some(i));
6542 let next_anchor = if down.modifiers.shift {
6543 anchor_index_for_pointer
6544 .get()
6545 .or(Some(row_key))
6546 } else {
6547 Some(row_key)
6548 };
6549 anchor_index_for_pointer
6550 .set(next_anchor);
6551 typeahead_for_pointer
6552 .borrow_mut()
6553 .clear();
6554 if let Some(token) =
6555 typeahead_timer_for_pointer.get()
6556 {
6557 host.push_effect(Effect::CancelTimer { token });
6558 typeahead_timer_for_pointer.set(None);
6559 }
6560 *active_command_for_pointer.borrow_mut() = None;
6561 host.request_redraw(action_cx.window);
6562 PressablePointerDownResult::Continue
6563 },
6564 ));
6565 cx.pressable_add_on_pointer_up(Arc::new(
6566 move |host, action_cx, up| {
6567 if up.button
6568 != fret_core::MouseButton::Left
6569 || !up.is_click
6570 {
6571 return PressablePointerUpResult::Continue;
6572 }
6573 host.request_focus(focus_target);
6574 host.request_redraw(action_cx.window);
6575 PressablePointerUpResult::Continue
6576 },
6577 ));
6578
6579 if active_index.get() == Some(i) {
6580 active_element.set(Some(id));
6581 *active_command.borrow_mut() = None;
6582 }
6583 let state_model = state.clone();
6584 cx.pressable_update_model(
6585 &state_model,
6586 move |st| match &mut st.expanding
6587 {
6588 ExpandingState::All => {
6589 st.expanding =
6590 ExpandingState::default();
6591 }
6592 ExpandingState::Keys(keys) => {
6593 if keys.contains(&row_key)
6594 {
6595 keys.remove(&row_key);
6596 } else {
6597 keys.insert(row_key);
6598 }
6599 }
6600 },
6601 );
6602
6603 let is_active = active_index.get() == Some(i);
6604 let bg = if st.pressed {
6605 Some(row_active_bg)
6606 } else if st.hovered {
6607 Some(row_hover_bg)
6608 } else {
6609 None
6610 };
6611
6612 let indent_step = 12.0_f32;
6613 let indent_px =
6614 Px((depth as f32) * indent_step);
6615 let glyph: Arc<str> = if expanded {
6616 Arc::from("v")
6617 } else {
6618 Arc::from(">")
6619 };
6620 let text: Arc<str> = Arc::from(
6621 format!("{glyph} {label}"),
6622 );
6623
6624 vec![cx.container(
6625 ContainerProps {
6626 background: bg,
6627 layout: LayoutStyle {
6628 size: fret_ui::element::SizeStyle {
6629 width: Length::Fill,
6630 height: body_row_height,
6631 ..Default::default()
6632 },
6633 position:
6634 fret_ui::element::PositionStyle::Relative,
6635 ..Default::default()
6636 },
6637 ..Default::default()
6638 },
6639 |cx| {
6640 let mut out = Vec::new();
6641 if is_active {
6642 out.push(cx.container(
6643 ContainerProps {
6644 background: Some(ring),
6645 layout: LayoutStyle {
6646 size: fret_ui::element::SizeStyle {
6647 width: Length::Px(Px(2.0)),
6648 height: Length::Fill,
6649 ..Default::default()
6650 },
6651 position:
6652 fret_ui::element::PositionStyle::Absolute,
6653 inset: fret_ui::element::InsetStyle {
6654 top: Some(Px(0.0)).into(),
6655 right: None.into(),
6656 bottom: Some(Px(0.0)).into(),
6657 left: Some(Px(0.0)).into(),
6658 },
6659 ..Default::default()
6660 },
6661 ..Default::default()
6662 },
6663 |_| Vec::new(),
6664 ));
6665 }
6666 let render_group =
6667 |cx: &mut ElementContext<
6668 '_,
6669 H,
6670 >,
6671 cols: &[&ColumnDef<
6672 TData,
6673 >],
6674 scroll_x: Option<
6675 ScrollHandle,
6676 >| {
6677 let column_width_by_id =
6678 column_width_by_id.clone();
6679 let row = if props
6680 .optimize_paint_order
6681 {
6682 cx.container(
6683 ContainerProps {
6684 layout: LayoutStyle {
6685 size:
6686 fret_ui::element::SizeStyle {
6687 height: Length::Fill,
6688 ..Default::default()
6689 },
6690 ..Default::default()
6691 },
6692 ..Default::default()
6693 },
6694 |cx| {
6695 let col_widths: Vec<Px> = cols
6696 .iter()
6697 .map(|col| {
6698 column_width_by_id
6699 .get(&col.id)
6700 .copied()
6701 .unwrap_or(Px(col.size))
6702 })
6703 .collect();
6704 let background_row = ui::h_row(|cx| {
6705 cols.iter()
6706 .zip(col_widths.iter().copied())
6707 .map(|(_col, col_w)| {
6708 cx.container(
6709 ContainerProps {
6710 border: if props.optimize_grid_lines {
6711 Edges::default()
6712 } else {
6713 Edges {
6714 right: Px(1.0),
6715 ..Default::default()
6716 }
6717 },
6718 border_color: if props.optimize_grid_lines {
6719 None
6720 } else {
6721 Some(border)
6722 },
6723 layout: LayoutStyle {
6724 size: fret_ui::element::SizeStyle {
6725 width: Length::Px(col_w),
6726 min_width: Some(Length::Px(col_w)),
6727 max_width: Some(Length::Px(col_w)),
6728 height: Length::Fill,
6729 ..Default::default()
6730 },
6731 flex: fret_ui::element::FlexItemStyle {
6732 shrink: 0.0,
6733 ..Default::default()
6734 },
6735 ..Default::default()
6736 },
6737 ..Default::default()
6738 },
6739 |_| Vec::new(),
6740 )
6741 })
6742 .collect::<Vec<_>>()
6743 })
6744 .gap(Space::N0)
6745 .justify_start()
6746 .items_stretch()
6747 .into_element(cx);
6748
6749 let content_overlay = cx.container(
6750 ContainerProps {
6751 layout: LayoutStyle {
6752 size: fret_ui::element::SizeStyle {
6753 width: Length::Fill,
6754 height: Length::Fill,
6755 ..Default::default()
6756 },
6757 position:
6758 fret_ui::element::PositionStyle::Absolute,
6759 inset: fret_ui::element::InsetStyle {
6760 top: Some(Px(0.0)).into(),
6761 right: Some(Px(0.0)).into(),
6762 bottom: Some(Px(0.0)).into(),
6763 left: Some(Px(0.0)).into(),
6764 },
6765 ..Default::default()
6766 },
6767 ..Default::default()
6768 },
6769 |cx| {
6770 vec![ui::h_row(|cx| {
6771 cols.iter()
6772 .zip(col_widths.iter().copied())
6773 .map(|(col, col_w)| {
6774
6775 let is_label_target = col
6776 .id
6777 .as_ref()
6778 == label_target.as_ref();
6779 let is_placeholder =
6780 !is_label_target
6781 && grouping.iter().any(|id| {
6782 id.as_ref() == col.id.as_ref()
6783 });
6784
6785 let padding = if is_label_target {
6786 Edges {
6787 left: Px(
6788 cell_px.0
6789 + indent_px.0,
6790 ),
6791 right: cell_px,
6792 top: cell_py,
6793 bottom: cell_py,
6794 }
6795 } else {
6796 Edges::symmetric(cell_px, cell_py)
6797 };
6798
6799 cx.container(
6800 ContainerProps {
6801 padding: padding.into(),
6802 layout: LayoutStyle {
6803 size: fret_ui::element::SizeStyle {
6804 width: Length::Px(col_w),
6805 min_width: Some(Length::Px(col_w)),
6806 max_width: Some(Length::Px(col_w)),
6807 height: Length::Fill,
6808 ..Default::default()
6809 },
6810 flex: fret_ui::element::FlexItemStyle {
6811 shrink: 0.0,
6812 ..Default::default()
6813 },
6814 ..Default::default()
6815 },
6816 ..Default::default()
6817 },
6818 |cx| {
6819 if is_placeholder {
6820 return Vec::new();
6821 }
6822 if is_label_target {
6823 vec![cx.text(text.clone())]
6824 } else {
6825 let v = aggregations
6826 .iter()
6827 .find(|entry| {
6828 entry.0.as_ref()
6829 == col
6830 .id
6831 .as_ref()
6832 })
6833 .map(|entry| entry.1.clone());
6834 v.map(|v| vec![cx.text(v)])
6835 .unwrap_or_default()
6836 }
6837 },
6838 )
6839 })
6840 .collect::<Vec<_>>()
6841 })
6842 .gap(Space::N0)
6843 .justify_start()
6844 .items_center()
6845 .into_element(cx)]
6846 },
6847 );
6848
6849 vec![background_row, content_overlay]
6850 },
6851 )
6852 } else {
6853 ui::h_row(|cx| {
6854 cols.iter()
6855 .map(|col| {
6856 let col_w = column_width_by_id
6857 .get(&col.id)
6858 .copied()
6859 .unwrap_or(Px(col.size));
6860
6861 let is_label_target =
6862 col.id.as_ref()
6863 == label_target.as_ref();
6864 let is_placeholder = !is_label_target
6865 && grouping.iter().any(|id| {
6866 id.as_ref() == col.id.as_ref()
6867 });
6868
6869 let padding = if is_label_target {
6870 Edges {
6871 left: Px(
6872 cell_px.0
6873 + indent_px.0,
6874 ),
6875 right: cell_px,
6876 top: cell_py,
6877 bottom: cell_py,
6878 }
6879 } else {
6880 Edges::symmetric(
6881 cell_px,
6882 cell_py,
6883 )
6884 };
6885
6886 cx.container(
6887 ContainerProps {
6888 padding: padding.into(),
6889 border: if props.optimize_grid_lines {
6890 Edges::default()
6891 } else {
6892 Edges {
6893 right: Px(1.0),
6894 ..Default::default()
6895 }
6896 },
6897 border_color: if props.optimize_grid_lines {
6898 None
6899 } else {
6900 Some(border)
6901 },
6902 layout: LayoutStyle {
6903 size: fret_ui::element::SizeStyle {
6904 width: Length::Px(col_w),
6905 min_width: Some(Length::Px(col_w)),
6906 max_width: Some(Length::Px(col_w)),
6907 ..Default::default()
6908 },
6909 flex: fret_ui::element::FlexItemStyle {
6910 shrink: 0.0,
6911 ..Default::default()
6912 },
6913 ..Default::default()
6914 },
6915 ..Default::default()
6916 },
6917 |cx| {
6918 if is_placeholder {
6919 return Vec::new();
6920 }
6921 if is_label_target {
6922 vec![cx.text(text.clone())]
6923 } else {
6924 let v = aggregations
6925 .iter()
6926 .find(|entry| {
6927 entry.0.as_ref()
6928 == col
6929 .id
6930 .as_ref()
6931 })
6932 .map(|entry| entry.1.clone());
6933 v.map(|v| vec![cx.text(v)])
6934 .unwrap_or_default()
6935 }
6936 },
6937 )
6938 })
6939 .collect::<Vec<_>>()
6940 })
6941 .gap(Space::N0)
6942 .justify_start()
6943 .items_center()
6944 .into_element(cx)
6945 };
6946
6947 if let Some(scroll_x) =
6948 scroll_x
6949 {
6950 cx.scroll(
6951 ScrollProps {
6952 axis: ScrollAxis::X,
6953 scroll_handle: Some(scroll_x),
6954 layout: LayoutStyle {
6955 size: fret_ui::element::SizeStyle {
6956 width: Length::Fill,
6957 height: Length::Fill,
6958 ..Default::default()
6959 },
6960 flex: fret_ui::element::FlexItemStyle {
6961 grow: 1.0,
6962 shrink: 1.0,
6963 basis: Length::Px(Px(0.0)),
6964 ..Default::default()
6965 },
6966 ..Default::default()
6967 },
6968 ..Default::default()
6969 },
6970 |_| vec![row],
6971 )
6972 } else {
6973 row
6974 }
6975 };
6976
6977 out.push(ui::h_row(|cx| {
6978 let has_left =
6979 !left_cols.is_empty();
6980 let has_center =
6981 !center_cols.is_empty();
6982 let has_right =
6983 !right_cols.is_empty();
6984
6985 let divider_after_left = props
6986 .optimize_grid_lines
6987 && has_left
6988 && (has_center || has_right);
6989 let divider_after_center = props
6990 .optimize_grid_lines
6991 && has_center
6992 && has_right;
6993
6994 let left = render_group(
6995 cx,
6996 &left_cols,
6997 None,
6998 );
6999 let left = if divider_after_left {
7000 cx.container(
7001 ContainerProps {
7002 border: Edges {
7003 right: Px(1.0),
7004 ..Default::default()
7005 },
7006 border_color: Some(border),
7007 layout: LayoutStyle {
7008 size: fret_ui::element::SizeStyle {
7009 height: Length::Fill,
7010 ..Default::default()
7011 },
7012 ..Default::default()
7013 },
7014 ..Default::default()
7015 },
7016 move |_| vec![left],
7017 )
7018 } else {
7019 left
7020 };
7021
7022 let center = render_group(
7023 cx,
7024 ¢er_cols,
7025 Some(scroll_x.clone()),
7026 );
7027 let center = if divider_after_center {
7028 cx.container(
7029 ContainerProps {
7030 border: Edges {
7031 right: Px(1.0),
7032 ..Default::default()
7033 },
7034 border_color: Some(border),
7035 layout: LayoutStyle {
7036 size: fret_ui::element::SizeStyle {
7037 height: Length::Fill,
7038 ..Default::default()
7039 },
7040 ..Default::default()
7041 },
7042 ..Default::default()
7043 },
7044 move |_| vec![center],
7045 )
7046 } else {
7047 center
7048 };
7049
7050 let right = render_group(
7051 cx,
7052 &right_cols,
7053 None,
7054 );
7055
7056 vec![left, center, right]
7057 })
7058 .gap(Space::N0)
7059 .justify_start()
7060 .items_stretch()
7061 .into_element(cx));
7062 out
7063 },
7064 )]
7065 },
7066 );
7067 }
7068 };
7069
7070 let data_row = Row {
7071 id: RowId::new(row_key.0.to_string()),
7072 key: row_key,
7073 original: &data[data_index],
7074 index: data_index,
7075 depth,
7076 parent: None,
7077 parent_key: None,
7078 sub_rows: Vec::new(),
7079 };
7080
7081 let cmd = on_row_activate(&data_row);
7082 let enabled = cmd.is_some() || props.enable_row_selection;
7083 let is_selected =
7084 is_row_selected(data_row.key, &state_value.row_selection);
7085 let row_test_id = debug_row_test_id_prefix
7086 .as_ref()
7087 .map(|prefix| {
7088 Arc::<str>::from(format!(
7089 "{prefix}{id}",
7090 id = data_row.key.0
7091 ))
7092 });
7093
7094 let active_index = active_index.clone();
7095 let anchor_index = anchor_index.clone();
7096 let active_element = active_element.clone();
7097 let active_command = active_command.clone();
7098 let typeahead = typeahead.clone();
7099 let typeahead_timer = typeahead_timer.clone();
7100 let focus_target = list_id;
7101
7102 cx.pressable_with_id(
7103 PressableProps {
7104 enabled,
7105 focusable: false,
7106 a11y: PressableA11y {
7107 role: Some(SemanticsRole::ListItem),
7108 selected: is_selected,
7109 test_id: row_test_id,
7110 ..Default::default()
7111 }
7112 .with_collection_position(i, set_size),
7113 ..Default::default()
7114 },
7115 |cx, st, id| {
7116 let active_index_for_pointer =
7117 active_index.clone();
7118 let anchor_index_for_pointer_down =
7119 anchor_index.clone();
7120 let anchor_index_for_pointer_up =
7121 anchor_index.clone();
7122 let row_meta_for_pointer = row_meta.clone();
7123 let typeahead_for_pointer = typeahead.clone();
7124 let typeahead_timer_for_pointer =
7125 typeahead_timer.clone();
7126 let state_model_for_pointer = state.clone();
7127 let row_key_for_pointer = data_row.key;
7128
7129 cx.pressable_on_pointer_down(Arc::new(
7130 move |host, action_cx, down| {
7131 active_index_for_pointer.set(Some(i));
7132 let next_anchor = if down.modifiers.shift {
7133 anchor_index_for_pointer_down
7134 .get()
7135 .or(Some(row_key_for_pointer))
7136 } else {
7137 Some(row_key_for_pointer)
7138 };
7139 anchor_index_for_pointer_down
7140 .set(next_anchor);
7141 typeahead_for_pointer
7142 .borrow_mut()
7143 .clear();
7144 if let Some(token) =
7145 typeahead_timer_for_pointer.get()
7146 {
7147 host.push_effect(
7148 Effect::CancelTimer { token },
7149 );
7150 typeahead_timer_for_pointer
7151 .set(None);
7152 }
7153 host.request_redraw(action_cx.window);
7154 PressablePointerDownResult::Continue
7155 },
7156 ));
7157
7158 cx.pressable_add_on_pointer_up(Arc::new(
7159 move |host, action_cx, up| {
7160 if up.button
7161 != fret_core::MouseButton::Left
7162 || !up.is_click
7163 {
7164 return PressablePointerUpResult::Continue;
7165 }
7166 host.request_focus(focus_target);
7167 let pointer_row_selection_enabled = props.enable_row_selection
7168 && props.pointer_row_selection;
7169 if pointer_row_selection_enabled {
7170 let policy =
7171 props.pointer_row_selection_policy;
7172 let modifiers = up.modifiers;
7173 let row_key = row_key_for_pointer;
7174 let single = props.single_row_selection;
7175 let meta = row_meta_for_pointer
7176 .borrow()
7177 .clone();
7178 let range_keys = if policy
7179 == PointerRowSelectionPolicy::ListLike
7180 && !single
7181 && modifiers.shift
7182 {
7183 let anchor_key = anchor_index_for_pointer_up
7184 .get()
7185 .unwrap_or(row_key);
7186 let anchor = meta
7187 .iter()
7188 .position(|m| m.row_key == anchor_key)
7189 .unwrap_or(i);
7190 let (a, b) = if anchor <= i {
7191 (anchor, i)
7192 } else {
7193 (i, anchor)
7194 };
7195 let keys = if single {
7196 vec![row_key]
7197 } else {
7198 table_collect_leaf_keys_in_range(
7199 &meta, a, b,
7200 )
7201 };
7202 (!keys.is_empty()).then_some(keys)
7203 } else {
7204 None
7205 };
7206
7207 let _ = host.models_mut().update(
7208 &state_model_for_pointer,
7209 move |st| match policy {
7210 PointerRowSelectionPolicy::Toggle => {
7211 let selected =
7212 st.row_selection
7213 .contains(&row_key);
7214 if single {
7215 st.row_selection.clear();
7216 }
7217 if selected {
7218 st.row_selection
7219 .remove(&row_key);
7220 } else {
7221 st.row_selection
7222 .insert(row_key);
7223 }
7224 }
7225 PointerRowSelectionPolicy::ListLike => {
7226 if let Some(range_keys) =
7227 range_keys.as_ref()
7228 {
7229 if modifiers.ctrl
7230 || modifiers.meta
7231 {
7232 st.row_selection.extend(
7233 range_keys
7234 .iter()
7235 .copied(),
7236 );
7237 } else {
7238 st.row_selection.clear();
7239 st.row_selection.extend(
7240 range_keys
7241 .iter()
7242 .copied(),
7243 );
7244 }
7245 } else if !single
7246 && (modifiers.ctrl
7247 || modifiers.meta)
7248 {
7249 if st.row_selection
7250 .contains(&row_key)
7251 {
7252 st.row_selection
7253 .remove(&row_key);
7254 } else {
7255 st.row_selection
7256 .insert(row_key);
7257 }
7258 } else {
7259 st.row_selection.clear();
7260 st.row_selection
7261 .insert(row_key);
7262 }
7263 }
7264 },
7265 );
7266
7267 let next_anchor = if policy
7268 == PointerRowSelectionPolicy::ListLike
7269 && modifiers.shift
7270 {
7271 anchor_index_for_pointer_up
7272 .get()
7273 .or(Some(row_key))
7274 } else {
7275 Some(row_key)
7276 };
7277 anchor_index_for_pointer_up
7278 .set(next_anchor);
7279 }
7280 host.request_redraw(action_cx.window);
7281 // When pointer-driven row selection is enabled, a click should not
7282 // also activate the row command (avoid "selection + activate" conflicts).
7283 PressablePointerUpResult::SkipActivate
7284 },
7285 ));
7286
7287 if active_index.get() == Some(i) {
7288 active_element.set(Some(id));
7289 *active_command.borrow_mut() = cmd.clone();
7290 }
7291 cx.pressable_dispatch_command_if_enabled_opt(cmd.clone());
7292
7293 let is_active = active_index.get() == Some(i);
7294 let bg = if is_selected || (enabled && st.pressed) {
7295 Some(row_active_bg)
7296 } else if enabled && st.hovered {
7297 Some(row_hover_bg)
7298 } else {
7299 None
7300 };
7301
7302 vec![cx.container(
7303 ContainerProps {
7304 background: bg,
7305 layout: LayoutStyle {
7306 size: fret_ui::element::SizeStyle {
7307 width: Length::Fill,
7308 height: body_row_height,
7309 ..Default::default()
7310 },
7311 position:
7312 fret_ui::element::PositionStyle::Relative,
7313 ..Default::default()
7314 },
7315 ..Default::default()
7316 },
7317 |cx| {
7318 let mut out = Vec::new();
7319 if is_active {
7320 out.push(cx.container(
7321 ContainerProps {
7322 background: Some(ring),
7323 layout: LayoutStyle {
7324 size:
7325 fret_ui::element::SizeStyle {
7326 width: Length::Px(Px(2.0)),
7327 height: Length::Fill,
7328 ..Default::default()
7329 },
7330 position:
7331 fret_ui::element::PositionStyle::Absolute,
7332 inset: fret_ui::element::InsetStyle {
7333 top: Some(Px(0.0)).into(),
7334 right: None.into(),
7335 bottom: Some(Px(0.0)).into(),
7336 left: Some(Px(0.0)).into(),
7337 },
7338 ..Default::default()
7339 },
7340 ..Default::default()
7341 },
7342 |_| Vec::new(),
7343 ));
7344 }
7345 let mut render_row_group =
7346 |cx: &mut ElementContext<'_, H>,
7347 cols: &[&ColumnDef<TData>],
7348 scroll_x: Option<ScrollHandle>| {
7349 let column_width_by_id =
7350 column_width_by_id.clone();
7351 let row = if props.optimize_paint_order {
7352 cx.container(
7353 ContainerProps {
7354 layout: LayoutStyle {
7355 size: fret_ui::element::SizeStyle {
7356 height: Length::Fill,
7357 ..Default::default()
7358 },
7359 ..Default::default()
7360 },
7361 ..Default::default()
7362 },
7363 |cx| {
7364 let col_widths: Vec<Px> = cols
7365 .iter()
7366 .map(|col| {
7367 column_width_by_id
7368 .get(&col.id)
7369 .copied()
7370 .unwrap_or(Px(col.size))
7371 })
7372 .collect();
7373 let background_row = ui::h_row(|cx| {
7374 cols.iter()
7375 .zip(col_widths.iter().copied())
7376 .map(|(_col, col_w)| {
7377 cx.container(
7378 ContainerProps {
7379 border: if props.optimize_grid_lines {
7380 Edges::default()
7381 } else {
7382 Edges {
7383 right: Px(1.0),
7384 ..Default::default()
7385 }
7386 },
7387 border_color: if props.optimize_grid_lines {
7388 None
7389 } else {
7390 Some(border)
7391 },
7392 layout: table_fixed_column_fill_layout(col_w),
7393 ..Default::default()
7394 },
7395 |_| Vec::new(),
7396 )
7397 })
7398 .collect::<Vec<_>>()
7399 })
7400 .gap(Space::N0)
7401 .justify_start()
7402 .items_stretch()
7403 .into_element(cx);
7404
7405 let content_overlay = cx.container(
7406 ContainerProps {
7407 layout: LayoutStyle {
7408 size: fret_ui::element::SizeStyle {
7409 width: Length::Fill,
7410 height: Length::Fill,
7411 ..Default::default()
7412 },
7413 position:
7414 fret_ui::element::PositionStyle::Absolute,
7415 inset: fret_ui::element::InsetStyle {
7416 top: Some(Px(0.0)).into(),
7417 right: Some(Px(0.0)).into(),
7418 bottom: Some(Px(0.0)).into(),
7419 left: Some(Px(0.0)).into(),
7420 },
7421 ..Default::default()
7422 },
7423 ..Default::default()
7424 },
7425 |cx| {
7426 vec![ui::h_row(|cx| {
7427 cols.iter()
7428 .zip(col_widths.iter().copied())
7429 .map(|(col, col_w)| {
7430 let hoisted_test_id =
7431 Rc::new(RefCell::new(None));
7432 let hoisted_test_id_for_cell =
7433 hoisted_test_id.clone();
7434 let explicit_test_id =
7435 debug_row_test_id_prefix
7436 .as_ref()
7437 .map(|prefix| {
7438 Arc::<str>::from(format!(
7439 "{prefix}{row}-cell-{col}",
7440 row = data_row.key.0,
7441 col = col.id.as_ref()
7442 ))
7443 });
7444 let cell =
7445 cx.container(
7446 ContainerProps {
7447 padding: Edges::symmetric(
7448 cell_px,
7449 cell_py,
7450 )
7451 .into(),
7452 layout:
7453 table_fixed_column_clip_fill_layout(
7454 col_w,
7455 ),
7456 ..Default::default()
7457 },
7458 |cx| {
7459 let items =
7460 render_cell(
7461 cx,
7462 &data_row,
7463 col,
7464 );
7465 let mut children =
7466 collect_children(
7467 cx, items,
7468 );
7469 *hoisted_test_id_for_cell
7470 .borrow_mut() =
7471 table_wrapper_test_id(
7472 &mut children,
7473 explicit_test_id
7474 .clone(),
7475 );
7476 let content = ui::h_row(
7477 move |_cx| {
7478 children
7479 },
7480 )
7481 .layout(
7482 LayoutRefinement::default()
7483 .w_full()
7484 .h_full(),
7485 )
7486 .gap(Space::N0)
7487 .justify_start()
7488 .items_center()
7489 .into_element(cx);
7490 vec![content]
7491 },
7492 );
7493 if let Some(test_id) =
7494 hoisted_test_id.borrow_mut().take()
7495 {
7496 cell.test_id(test_id)
7497 } else {
7498 cell
7499 }
7500 })
7501 .collect::<Vec<_>>()
7502 })
7503 .gap(Space::N0)
7504 .justify_start()
7505 .items_center()
7506 .into_element(cx)]
7507 },
7508 );
7509
7510 vec![background_row, content_overlay]
7511 },
7512 )
7513 } else {
7514 ui::h_row(|cx| {
7515 cols.iter()
7516 .map(|col| {
7517 let col_w = column_width_by_id
7518 .get(&col.id)
7519 .copied()
7520 .unwrap_or(Px(col.size));
7521 let hoisted_test_id =
7522 Rc::new(RefCell::new(None));
7523 let hoisted_test_id_for_cell =
7524 hoisted_test_id.clone();
7525 let explicit_test_id =
7526 debug_row_test_id_prefix
7527 .as_ref()
7528 .map(|prefix| {
7529 Arc::<str>::from(format!(
7530 "{prefix}{row}-cell-{col}",
7531 row = data_row.key.0,
7532 col = col.id.as_ref()
7533 ))
7534 });
7535 let cell = cx.container(
7536 ContainerProps {
7537 padding: Edges::symmetric(
7538 cell_px, cell_py,
7539 )
7540 .into(),
7541 border: if props.optimize_grid_lines {
7542 Edges::default()
7543 } else {
7544 Edges {
7545 right: Px(1.0),
7546 ..Default::default()
7547 }
7548 },
7549 border_color: if props.optimize_grid_lines {
7550 None
7551 } else {
7552 Some(border)
7553 },
7554 layout:
7555 table_fixed_column_clip_fill_layout(
7556 col_w,
7557 ),
7558 ..Default::default()
7559 },
7560 |cx| {
7561 let items =
7562 render_cell(
7563 cx,
7564 &data_row,
7565 col,
7566 );
7567 let mut children =
7568 collect_children(
7569 cx, items,
7570 );
7571 *hoisted_test_id_for_cell
7572 .borrow_mut() =
7573 table_wrapper_test_id(
7574 &mut children,
7575 explicit_test_id
7576 .clone(),
7577 );
7578 let content = ui::h_row(
7579 move |_cx| {
7580 children
7581 },
7582 )
7583 .layout(
7584 LayoutRefinement::default()
7585 .w_full()
7586 .h_full(),
7587 )
7588 .gap(Space::N0)
7589 .justify_start()
7590 .items_center()
7591 .into_element(cx);
7592 vec![content]
7593 },
7594 );
7595 if let Some(test_id) =
7596 hoisted_test_id.borrow_mut().take()
7597 {
7598 cell.test_id(test_id)
7599 } else {
7600 cell
7601 }
7602 })
7603 .collect::<Vec<_>>()
7604 })
7605 .gap(Space::N0)
7606 .justify_start()
7607 .items_center()
7608 .into_element(cx)
7609 };
7610
7611 table_wrap_horizontal_scroll(
7612 cx, scroll_x, row,
7613 )
7614 };
7615
7616 out.push(ui::h_row(|cx| {
7617 let has_left = !left_cols.is_empty();
7618 let has_center = !center_cols.is_empty();
7619 let has_right = !right_cols.is_empty();
7620
7621 let divider_after_left = props.optimize_grid_lines
7622 && has_left
7623 && (has_center || has_right);
7624 let divider_after_center =
7625 props.optimize_grid_lines && has_center && has_right;
7626
7627 let left =
7628 render_row_group(cx, &left_cols, None);
7629 let left = if divider_after_left {
7630 cx.container(
7631 ContainerProps {
7632 border: Edges {
7633 right: Px(1.0),
7634 ..Default::default()
7635 },
7636 border_color: Some(border),
7637 layout: LayoutStyle {
7638 size: fret_ui::element::SizeStyle {
7639 height: Length::Fill,
7640 ..Default::default()
7641 },
7642 ..Default::default()
7643 },
7644 ..Default::default()
7645 },
7646 move |_| vec![left],
7647 )
7648 } else {
7649 left
7650 };
7651
7652 let center = render_row_group(
7653 cx,
7654 ¢er_cols,
7655 Some(scroll_x.clone()),
7656 );
7657 let center = if divider_after_center {
7658 cx.container(
7659 ContainerProps {
7660 border: Edges {
7661 right: Px(1.0),
7662 ..Default::default()
7663 },
7664 border_color: Some(border),
7665 layout: LayoutStyle {
7666 size: fret_ui::element::SizeStyle {
7667 height: Length::Fill,
7668 ..Default::default()
7669 },
7670 ..Default::default()
7671 },
7672 ..Default::default()
7673 },
7674 move |_| vec![center],
7675 )
7676 } else {
7677 center
7678 };
7679
7680 let right =
7681 render_row_group(cx, &right_cols, None);
7682 vec![left, center, right]
7683 })
7684 .gap(Space::N0)
7685 .justify_start()
7686 .items_stretch()
7687 .into_element(cx));
7688 out
7689 },
7690 )]
7691 },
7692 )
7693 },
7694 );
7695
7696 if profile {
7697 tracing::info!(
7698 "table_virtualized: list len={} page_rows={} rendered_rows={} row_h={:.1}px",
7699 data.len(),
7700 set_size,
7701 rendered_rows.get(),
7702 row_h.0
7703 );
7704 }
7705
7706 vec![header, body]
7707 })
7708 .h_full()
7709 .into_element(cx)]
7710 },
7711 )]
7712 },
7713 );
7714
7715 if let Some(active_element) = active_element.get() {
7716 // The active row element is discovered while the table body mounts. Keep the relationship
7717 // declarative here and let the semantics pass resolve it against the final mounted node
7718 // once the current frame commits.
7719 list.attach_semantics(
7720 SemanticsDecoration::default().active_descendant_element(active_element.0),
7721 )
7722 } else {
7723 list
7724 }
7725}