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}