Skip to main content

teksilo_widgets/table_view/
body.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Per-row container widget — `Role::Row`, lays its cells horizontally
5//! using a shared column-width handle owned by the parent table.
6//!
7//! The body widget itself is the `TableView` root; this file just holds
8//! the small `BodyRow` container that one level above the leaf cell
9//! delegates so the AccessKit tree exposes the canonical
10//! `Table > Row > Cell` hierarchy.
11//!
12//! ## Pane bands and horizontal scroll
13//!
14//! When no column is pinned (`PaneBoundaries::leading_count == 0` and
15//! `middle_end == cells.len()` — the overwhelmingly common case), `BodyRow`
16//! keeps its original flat shape: cells are direct children, positioned by a
17//! single cumulative walk offset by `-scroll_x`. Content scrolled out of
18//! view is caught by the existing ancestor clips (`BodyPane` / `TableView`
19//! both `clips_children()`), exactly as static column overflow always has
20//! been — no new node, no behavior change for the default case.
21//!
22//! When pinning IS active, a scrolled-out-of-place Middle-pane cell could
23//! otherwise paint over a co-resident Leading/Trailing-pane cell within the
24//! same row bounds (the outer ancestor clip only bounds the row's own outer
25//! edges, not the seam between panes). `build()` then groups the cells into
26//! up to three `RowBand` children — Leading / Middle / Trailing — and only
27//! the Middle band clips (`RowBand::clips_children`); Leading/Trailing never
28//! need it since their own width IS the sum of their own columns. This is
29//! the same "wrap in a `clips_children` container" idiom `ScrollArea` /
30//! `BodyPane` / `TableView` already use, applied per-pane instead of
31//! per-widget.
32
33use std::cell::RefCell;
34use std::rc::Rc;
35
36use teksilo_canvas::{Point, Rect, Size, SizeProposal};
37use teksilo_core::accessibility::AccessNodeBuilder;
38use teksilo_core::build_context::BuildContext;
39use teksilo_core::signal::Signal;
40use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
41use teksilo_core::widget_id::WidgetId;
42
43use super::PaneBoundaries;
44use super::layout::band_rects;
45
46/// Shared handle holding the resolved column widths in display order.
47/// `TableView` writes in `place_children` (after running the column
48/// solver against the available width); each `BodyRow` reads in its own
49/// `place_children` to position its cells. Sharing through `Rc<RefCell>`
50/// keeps row layout consistent with the table's effective widths without
51/// re-cloning the vector per row.
52pub(crate) type SharedColumnWidths = Rc<RefCell<Vec<f32>>>;
53
54/// Whether `PaneBoundaries` actually pins anything — the trigger for the
55/// per-row band split. Shared between `BodyRow` and `TableHeader` so the two
56/// never disagree on when to switch shapes.
57pub(super) fn has_pinning(boundaries: PaneBoundaries, cell_count: usize) -> bool {
58    boundaries.leading_count > 0 || boundaries.middle_end < cell_count
59}
60
61/// One row's worth of cells. Pre-built children are passed in by index;
62/// `place_children` reads the shared widths and positions each cell at
63/// `(sum(widths[0..i]), 0)` with `(widths[i], row_height)` — or, when
64/// pinning splits the row into bands (see module docs), delegates to those
65/// bands' own `place_children`.
66#[derive(Debug)]
67pub(crate) struct BodyRow {
68    cells: Vec<WidgetId>,
69    /// 1-based row index for AccessKit. Header is 1; first body row is 2.
70    row_index_1based: usize,
71    selected: bool,
72    /// `Some(h)` — fixed height (uniform / exact modes). `None` —
73    /// auto-measure: `layout_response` measures each cell at its column
74    /// width and reports the tallest (height-for-width).
75    row_height: Option<f32>,
76    widths: SharedColumnWidths,
77    /// Pane partition — see module docs. Snapshotted at construction: a
78    /// pinning/order change bumps the owning table's rebuild version, so a
79    /// fresh `BodyRow` (and fresh boundaries) is constructed on every change
80    /// anyway.
81    pane_boundaries: PaneBoundaries,
82    /// The owning table's Middle-pane horizontal scroll offset. Forwarded
83    /// to the Middle `RowBand` (via `RowBand::scrollable`) when pinning
84    /// splits the row into bands; read directly in the flat, no-pinning
85    /// path's own cumulative walk.
86    scroll_x: Signal<f32>,
87    /// When `false`, the row publishes no node of its own, its cells
88    /// promoted to its parent. Used by TreeTableView, which wraps BodyRow
89    /// in `TreeRowA11y` and wants the outer wrapper to carry `Role::Row`
90    /// instead.
91    announce_a11y: bool,
92
93    // Build state — populated by `build()`.
94    bands: Option<[Option<WidgetId>; 3]>,
95}
96
97impl BodyRow {
98    pub(crate) fn new(
99        cells: Vec<WidgetId>,
100        row_index_1based: usize,
101        selected: bool,
102        row_height: Option<f32>,
103        widths: SharedColumnWidths,
104        pane_boundaries: PaneBoundaries,
105        scroll_x: Signal<f32>,
106    ) -> Self {
107        Self {
108            cells,
109            row_index_1based,
110            selected,
111            row_height,
112            widths,
113            pane_boundaries,
114            scroll_x,
115            announce_a11y: true,
116            bands: None,
117        }
118    }
119
120    pub(crate) fn a11y_hidden(mut self) -> Self {
121        self.announce_a11y = false;
122        self
123    }
124
125    fn has_pinning(&self) -> bool {
126        has_pinning(self.pane_boundaries, self.cells.len())
127    }
128}
129
130impl Widget for BodyRow {
131    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
132        if !self.has_pinning() {
133            // Flat shape — cells are direct children (see module docs).
134            return Vec::new();
135        }
136        let b = self.pane_boundaries;
137        let leading_end = b.leading_count.min(self.cells.len());
138        let middle_end = b.middle_end.min(self.cells.len()).max(leading_end);
139        let leading: Vec<WidgetId> = self.cells[..leading_end].to_vec();
140        let middle: Vec<WidgetId> = self.cells[leading_end..middle_end].to_vec();
141        let trailing: Vec<WidgetId> = self.cells[middle_end..].to_vec();
142
143        let mut bands: [Option<WidgetId>; 3] = [None, None, None];
144        if !leading.is_empty() {
145            bands[0] = Some(ctx.add(RowBand::new(leading, self.widths.clone(), 0)));
146        }
147        if !middle.is_empty() {
148            bands[1] = Some(
149                ctx.add(
150                    RowBand::new(middle, self.widths.clone(), leading_end)
151                        .scrollable(self.scroll_x.clone()),
152                ),
153            );
154        }
155        if !trailing.is_empty() {
156            bands[2] = Some(ctx.add(RowBand::new(trailing, self.widths.clone(), middle_end)));
157        }
158        let out: Vec<WidgetId> = bands.iter().copied().flatten().collect();
159        self.bands = Some(bands);
160        out
161    }
162
163    fn layout_response(
164        &self,
165        proposal: SizeProposal,
166        ctx: &LayoutContext,
167    ) -> teksilo_core::widget::LayoutResponse {
168        // Width: caller's proposal (the row fills its parent's bounds).
169        let width = proposal
170            .width
171            .unwrap_or_else(|| self.widths.borrow().iter().sum());
172        // Height: the configured row height, or — in auto-measure mode —
173        // the tallest cell measured at its column width. The widths were
174        // resolved by the table root's `place_children` earlier in this
175        // same pass; the borrow is dropped before measuring. Measures
176        // `self.cells` directly (not the bands) — cells are known
177        // `WidgetId`s regardless of how `build()` grouped them, and
178        // `ctx.child_size` works on any arena id.
179        let height = match self.row_height {
180            Some(h) => h,
181            None => {
182                let widths: Vec<f32> = self.widths.borrow().clone();
183                let mut max_h = 0.0_f32;
184                for (i, cell) in self.cells.iter().enumerate() {
185                    let w = widths.get(i).copied().unwrap_or(width);
186                    if let Some(size) = ctx.child_size(*cell, SizeProposal::with_width(w)) {
187                        max_h = max_h.max(size.height);
188                    }
189                }
190                max_h
191            }
192        };
193        Size::new(width, height).into()
194    }
195
196    fn place_children(
197        &self,
198        bounds: Rect,
199        _proposal: SizeProposal,
200        children: &mut [WidgetPlacement],
201        ctx: &LayoutContext,
202    ) {
203        if let Some(bands) = self.bands {
204            let widths = self.widths.borrow();
205            let rtl = ctx.is_rtl();
206            let (leading_rect, middle_rect, trailing_rect) =
207                band_rects(bounds, &widths, self.pane_boundaries, rtl);
208            let rects = [leading_rect, middle_rect, trailing_rect];
209            let mut next = 0;
210            for (band, rect) in bands.iter().zip(rects.iter()) {
211                if band.is_some() {
212                    if let Some(child) = children.get_mut(next) {
213                        child.origin = rect.origin();
214                        child.size = rect.size();
215                    }
216                    next += 1;
217                }
218            }
219            return;
220        }
221
222        let widths = self.widths.borrow();
223        let total_children = children.len();
224        // Defensive fallback: if the widths vector is shorter than the cell
225        // count (shouldn't happen — TableView writes them in lock-step),
226        // distribute evenly. Captured before `iter_mut` to avoid an
227        // aliasing borrow.
228        let fallback_w = if total_children == 0 {
229            0.0
230        } else {
231            bounds.width / total_children as f32
232        };
233        let scroll = self.scroll_x.get();
234        // Display order is preserved; only the physical x reverses under
235        // RTL (the HStack model). Cell `i` is column display-index `i` in
236        // both directions — the AT/selection/width contract is unchanged.
237        if ctx.is_rtl() {
238            let mut x = bounds.right() + scroll;
239            for (i, child) in children.iter_mut().enumerate() {
240                let w = widths.get(i).copied().unwrap_or(fallback_w);
241                x -= w;
242                child.origin = Point::new(x, bounds.y);
243                child.size = Size::new(w, bounds.height);
244            }
245        } else {
246            let mut x = bounds.x - scroll;
247            for (i, child) in children.iter_mut().enumerate() {
248                let w = widths.get(i).copied().unwrap_or(fallback_w);
249                child.origin = Point::new(x, bounds.y);
250                child.size = Size::new(w, bounds.height);
251                x += w;
252            }
253        }
254    }
255
256    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
257        if !self.announce_a11y {
258            // The wrapper above carries the row. A bare `GenericContainer`
259            // is dropped by every adapter with the cells promoted to it;
260            // `set_hidden()` took every cell of a `TreeTableView` with it.
261            builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
262            return;
263        }
264        builder.set_role(teksilo_core::accesskit::Role::Row);
265        builder.set_selected(self.selected);
266        builder.set_row_index(self.row_index_1based);
267        // Advertised, not just handled: every adapter gates its scroll
268        // pattern on the node *supporting* the action (UIA's
269        // `IScrollItemProvider`, AppKit's `accessibilityScrollToVisible`,
270        // AT-SPI's `ScrollTo`), so a row whose pane installs the handler
271        // through `on_access_action` alone is still unreachable to a real
272        // screen reader. The handler itself lives on the body pane.
273        builder.add_action(teksilo_core::accesskit::Action::ScrollIntoView);
274    }
275
276    fn children(&self) -> Vec<WidgetId> {
277        match self.bands {
278            Some(bands) => bands.iter().copied().flatten().collect(),
279            None => self.cells.clone(),
280        }
281    }
282}
283
284/// One pane band (Leading / Middle / Trailing) of cells within a header or
285/// body row, used only when column pinning splits the row (see the module
286/// docs on [`BodyRow`]). Groups a contiguous slice of already-built cell
287/// widgets sharing the row-wide `SharedColumnWidths` handle, positions them
288/// at `widths[widths_start..]` relative to its own bounds, and — only for
289/// the Middle band, via [`scrollable`](Self::scrollable) — shifts that walk
290/// by `-scroll_x` and clips its children so a partially-scrolled cell at
291/// either edge is cropped to the band instead of bleeding into a pinned
292/// neighbour.
293#[derive(Debug)]
294pub(crate) struct RowBand {
295    cells: Vec<WidgetId>,
296    widths: SharedColumnWidths,
297    widths_start: usize,
298    scroll_x: Option<Signal<f32>>,
299}
300
301impl RowBand {
302    pub(crate) fn new(
303        cells: Vec<WidgetId>,
304        widths: SharedColumnWidths,
305        widths_start: usize,
306    ) -> Self {
307        Self {
308            cells,
309            widths,
310            widths_start,
311            scroll_x: None,
312        }
313    }
314
315    /// Mark this band as the scrollable Middle pane: it shifts its cells by
316    /// `-scroll_x.get()` and clips them to its own bounds.
317    pub(crate) fn scrollable(mut self, scroll_x: Signal<f32>) -> Self {
318        self.scroll_x = Some(scroll_x);
319        self
320    }
321}
322
323impl Widget for RowBand {
324    fn layout_response(
325        &self,
326        proposal: SizeProposal,
327        _ctx: &LayoutContext,
328    ) -> teksilo_core::widget::LayoutResponse {
329        // Never queried for sizing purposes — `BodyRow`/`TableHeader` compute
330        // each band's rect themselves (`layout::band_rects`) and assign it
331        // directly in their own `place_children`, the same way `RowBand`
332        // assigns its own children's rects below. Row height in
333        // auto-measure mode is measured off the raw cell ids directly
334        // (`BodyRow::layout_response`), bypassing this widget entirely.
335        proposal.resolve(0.0, 0.0).into()
336    }
337
338    fn place_children(
339        &self,
340        bounds: Rect,
341        _proposal: SizeProposal,
342        children: &mut [WidgetPlacement],
343        ctx: &LayoutContext,
344    ) {
345        let widths = self.widths.borrow();
346        let scroll = self.scroll_x.as_ref().map(|s| s.get()).unwrap_or(0.0);
347        if ctx.is_rtl() {
348            let mut x = bounds.right() + scroll;
349            for (i, child) in children.iter_mut().enumerate() {
350                let w = widths.get(self.widths_start + i).copied().unwrap_or(0.0);
351                x -= w;
352                child.origin = Point::new(x, bounds.y);
353                child.size = Size::new(w, bounds.height);
354            }
355        } else {
356            let mut x = bounds.x - scroll;
357            for (i, child) in children.iter_mut().enumerate() {
358                let w = widths.get(self.widths_start + i).copied().unwrap_or(0.0);
359                child.origin = Point::new(x, bounds.y);
360                child.size = Size::new(w, bounds.height);
361                x += w;
362            }
363        }
364    }
365
366    fn children(&self) -> Vec<WidgetId> {
367        self.cells.clone()
368    }
369
370    fn clips_children(&self) -> bool {
371        self.scroll_x.is_some()
372    }
373}