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}