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 `HeaderRow` so the two
56/// never disagree on when to switch shapes.
57fn 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 is invisible to AccessKit — used by
88    /// TreeTableView, which wraps BodyRow in `TreeRowA11y` and wants the
89    /// outer wrapper to carry `Role::Row` instead.
90    announce_a11y: bool,
91
92    // Build state — populated by `build()`.
93    bands: Option<[Option<WidgetId>; 3]>,
94}
95
96impl BodyRow {
97    pub(crate) fn new(
98        cells: Vec<WidgetId>,
99        row_index_1based: usize,
100        selected: bool,
101        row_height: Option<f32>,
102        widths: SharedColumnWidths,
103        pane_boundaries: PaneBoundaries,
104        scroll_x: Signal<f32>,
105    ) -> Self {
106        Self {
107            cells,
108            row_index_1based,
109            selected,
110            row_height,
111            widths,
112            pane_boundaries,
113            scroll_x,
114            announce_a11y: true,
115            bands: None,
116        }
117    }
118
119    pub(crate) fn a11y_hidden(mut self) -> Self {
120        self.announce_a11y = false;
121        self
122    }
123
124    fn has_pinning(&self) -> bool {
125        has_pinning(self.pane_boundaries, self.cells.len())
126    }
127}
128
129impl Widget for BodyRow {
130    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
131        if !self.has_pinning() {
132            // Flat shape — cells are direct children (see module docs).
133            return Vec::new();
134        }
135        let b = self.pane_boundaries;
136        let leading_end = b.leading_count.min(self.cells.len());
137        let middle_end = b.middle_end.min(self.cells.len()).max(leading_end);
138        let leading: Vec<WidgetId> = self.cells[..leading_end].to_vec();
139        let middle: Vec<WidgetId> = self.cells[leading_end..middle_end].to_vec();
140        let trailing: Vec<WidgetId> = self.cells[middle_end..].to_vec();
141
142        let mut bands: [Option<WidgetId>; 3] = [None, None, None];
143        if !leading.is_empty() {
144            bands[0] = Some(ctx.add(RowBand::new(leading, self.widths.clone(), 0)));
145        }
146        if !middle.is_empty() {
147            bands[1] = Some(
148                ctx.add(
149                    RowBand::new(middle, self.widths.clone(), leading_end)
150                        .scrollable(self.scroll_x.clone()),
151                ),
152            );
153        }
154        if !trailing.is_empty() {
155            bands[2] = Some(ctx.add(RowBand::new(trailing, self.widths.clone(), middle_end)));
156        }
157        let out: Vec<WidgetId> = bands.iter().copied().flatten().collect();
158        self.bands = Some(bands);
159        out
160    }
161
162    fn layout_response(
163        &self,
164        proposal: SizeProposal,
165        ctx: &LayoutContext,
166    ) -> teksilo_core::widget::LayoutResponse {
167        // Width: caller's proposal (the row fills its parent's bounds).
168        let width = proposal
169            .width
170            .unwrap_or_else(|| self.widths.borrow().iter().sum());
171        // Height: the configured row height, or — in auto-measure mode —
172        // the tallest cell measured at its column width. The widths were
173        // resolved by the table root's `place_children` earlier in this
174        // same pass; the borrow is dropped before measuring. Measures
175        // `self.cells` directly (not the bands) — cells are known
176        // `WidgetId`s regardless of how `build()` grouped them, and
177        // `ctx.child_size` works on any arena id.
178        let height = match self.row_height {
179            Some(h) => h,
180            None => {
181                let widths: Vec<f32> = self.widths.borrow().clone();
182                let mut max_h = 0.0_f32;
183                for (i, cell) in self.cells.iter().enumerate() {
184                    let w = widths.get(i).copied().unwrap_or(width);
185                    if let Some(size) = ctx.child_size(*cell, SizeProposal::with_width(w)) {
186                        max_h = max_h.max(size.height);
187                    }
188                }
189                max_h
190            }
191        };
192        Size::new(width, height).into()
193    }
194
195    fn place_children(
196        &self,
197        bounds: Rect,
198        _proposal: SizeProposal,
199        children: &mut [WidgetPlacement],
200        ctx: &LayoutContext,
201    ) {
202        if let Some(bands) = self.bands {
203            let widths = self.widths.borrow();
204            let rtl = ctx.is_rtl();
205            let (leading_rect, middle_rect, trailing_rect) =
206                band_rects(bounds, &widths, self.pane_boundaries, rtl);
207            let rects = [leading_rect, middle_rect, trailing_rect];
208            let mut next = 0;
209            for (band, rect) in bands.iter().zip(rects.iter()) {
210                if band.is_some() {
211                    if let Some(child) = children.get_mut(next) {
212                        child.origin = rect.origin();
213                        child.size = rect.size();
214                    }
215                    next += 1;
216                }
217            }
218            return;
219        }
220
221        let widths = self.widths.borrow();
222        let total_children = children.len();
223        // Defensive fallback: if the widths vector is shorter than the cell
224        // count (shouldn't happen — TableView writes them in lock-step),
225        // distribute evenly. Captured before `iter_mut` to avoid an
226        // aliasing borrow.
227        let fallback_w = if total_children == 0 {
228            0.0
229        } else {
230            bounds.width / total_children as f32
231        };
232        let scroll = self.scroll_x.get();
233        // Display order is preserved; only the physical x reverses under
234        // RTL (the HStack model). Cell `i` is column display-index `i` in
235        // both directions — the AT/selection/width contract is unchanged.
236        if ctx.is_rtl() {
237            let mut x = bounds.right() + scroll;
238            for (i, child) in children.iter_mut().enumerate() {
239                let w = widths.get(i).copied().unwrap_or(fallback_w);
240                x -= w;
241                child.origin = Point::new(x, bounds.y);
242                child.size = Size::new(w, bounds.height);
243            }
244        } else {
245            let mut x = bounds.x - scroll;
246            for (i, child) in children.iter_mut().enumerate() {
247                let w = widths.get(i).copied().unwrap_or(fallback_w);
248                child.origin = Point::new(x, bounds.y);
249                child.size = Size::new(w, bounds.height);
250                x += w;
251            }
252        }
253    }
254
255    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
256        if !self.announce_a11y {
257            builder.set_hidden();
258            return;
259        }
260        builder.set_role(teksilo_core::accesskit::Role::Row);
261        builder.set_selected(self.selected);
262        builder.set_row_index(self.row_index_1based);
263    }
264
265    fn children(&self) -> Vec<WidgetId> {
266        match self.bands {
267            Some(bands) => bands.iter().copied().flatten().collect(),
268            None => self.cells.clone(),
269        }
270    }
271}
272
273/// One pane band (Leading / Middle / Trailing) of cells within a header or
274/// body row, used only when column pinning splits the row (see the module
275/// docs on [`BodyRow`]). Groups a contiguous slice of already-built cell
276/// widgets sharing the row-wide `SharedColumnWidths` handle, positions them
277/// at `widths[widths_start..]` relative to its own bounds, and — only for
278/// the Middle band, via [`scrollable`](Self::scrollable) — shifts that walk
279/// by `-scroll_x` and clips its children so a partially-scrolled cell at
280/// either edge is cropped to the band instead of bleeding into a pinned
281/// neighbour.
282#[derive(Debug)]
283pub(crate) struct RowBand {
284    cells: Vec<WidgetId>,
285    widths: SharedColumnWidths,
286    widths_start: usize,
287    scroll_x: Option<Signal<f32>>,
288}
289
290impl RowBand {
291    pub(crate) fn new(
292        cells: Vec<WidgetId>,
293        widths: SharedColumnWidths,
294        widths_start: usize,
295    ) -> Self {
296        Self {
297            cells,
298            widths,
299            widths_start,
300            scroll_x: None,
301        }
302    }
303
304    /// Mark this band as the scrollable Middle pane: it shifts its cells by
305    /// `-scroll_x.get()` and clips them to its own bounds.
306    pub(crate) fn scrollable(mut self, scroll_x: Signal<f32>) -> Self {
307        self.scroll_x = Some(scroll_x);
308        self
309    }
310}
311
312impl Widget for RowBand {
313    fn layout_response(
314        &self,
315        proposal: SizeProposal,
316        _ctx: &LayoutContext,
317    ) -> teksilo_core::widget::LayoutResponse {
318        // Never queried for sizing purposes — `BodyRow`/`HeaderRow` compute
319        // each band's rect themselves (`layout::band_rects`) and assign it
320        // directly in their own `place_children`, the same way `RowBand`
321        // assigns its own children's rects below. Row height in
322        // auto-measure mode is measured off the raw cell ids directly
323        // (`BodyRow::layout_response`), bypassing this widget entirely.
324        proposal.resolve(0.0, 0.0).into()
325    }
326
327    fn place_children(
328        &self,
329        bounds: Rect,
330        _proposal: SizeProposal,
331        children: &mut [WidgetPlacement],
332        ctx: &LayoutContext,
333    ) {
334        let widths = self.widths.borrow();
335        let scroll = self.scroll_x.as_ref().map(|s| s.get()).unwrap_or(0.0);
336        if ctx.is_rtl() {
337            let mut x = bounds.right() + scroll;
338            for (i, child) in children.iter_mut().enumerate() {
339                let w = widths.get(self.widths_start + i).copied().unwrap_or(0.0);
340                x -= w;
341                child.origin = Point::new(x, bounds.y);
342                child.size = Size::new(w, bounds.height);
343            }
344        } else {
345            let mut x = bounds.x - scroll;
346            for (i, child) in children.iter_mut().enumerate() {
347                let w = widths.get(self.widths_start + i).copied().unwrap_or(0.0);
348                child.origin = Point::new(x, bounds.y);
349                child.size = Size::new(w, bounds.height);
350                x += w;
351            }
352        }
353    }
354
355    fn children(&self) -> Vec<WidgetId> {
356        self.cells.clone()
357    }
358
359    fn clips_children(&self) -> bool {
360        self.scroll_x.is_some()
361    }
362}