teksilo_widgets/table_view/table_header.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`TableHeader`] — the column header strip of [`TableView`](crate::TableView)
5//! and [`TreeTableView`](crate::TreeTableView), and a widget of its own for an
6//! application that lays out its own rows under columns: a section list with
7//! full-width group rows, an album card beside its tracks.
8//!
9//! Both views compose this widget, so there is one header implementation: the
10//! `HeaderCell`s (label, sort indicator, filter popover, the resize grip on
11//! each divider, the column-reorder drag, `Role::ColumnHeader` with its sort
12//! direction and AccessKit `Increment` / `Decrement`), the strip that lays them
13//! out in pinned and scrolling bands and paints their separators, and the drop
14//! target that turns a reorder drag into a new column order.
15//!
16//! ## State
17//!
18//! The header's state is six signals — the width overrides, the sort, the
19//! column order, the pinning overrides, the filters and the horizontal scroll
20//! offset. Each builder ([`widths`](TableHeader::widths),
21//! [`sort`](TableHeader::sort), [`order`](TableHeader::order),
22//! [`pinning`](TableHeader::pinning), [`filters`](TableHeader::filters),
23//! [`scroll_x`](TableHeader::scroll_x)) adopts an application's signal; one
24//! that is not given is the header's own, read back through its getter. The
25//! rules are the views' ("Column state an application owns" in the
26//! [`table_view`](crate::table_view) module docs): the header writes nothing
27//! into these signals until the user acts on it, ignores ids it does not
28//! declare, and keeps them when it writes. Hand the same signals to a
29//! `TableView` and the two stay in step.
30//!
31//! The width map holds **overrides**, not the layout: a column with no entry
32//! takes its declared [`ColumnWidth`]. What the header actually laid each
33//! column out at is [`resolved_widths_signal`](TableHeader::resolved_widths_signal),
34//! which is what rows of an application's own read to line their cells up.
35//!
36//! ## Hosted
37//!
38//! Inside a view the header is *hosted* (`TableHeader::hosted`): the view
39//! resolves the widths and the display order (its body needs both before the
40//! header is placed), paints the `OnRelease` resize guide across the whole
41//! table, rebuilds the header whenever its state changes, and holds the
42//! resize-drag state, which a relayout keeps. A rebuild abandons an
43//! in-flight resize, hosted or not: it destroys the cells and the pointer
44//! capture with them (see `TableHeader::build`). A standalone header does
45//! all of that itself, within its own bounds, which it clips to.
46
47use std::cell::{Cell, RefCell};
48use std::collections::HashMap;
49use std::rc::Rc;
50
51use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
52use teksilo_core::accessibility::AccessNodeBuilder;
53use teksilo_core::binding::BindingLevel;
54use teksilo_core::build_context::BuildContext;
55use teksilo_core::signal::Signal;
56use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
57use teksilo_core::widget_builder::HandlerSet;
58use teksilo_core::widget_id::WidgetId;
59use teksilo_data::SortDirection;
60use teksilo_tokens::{BorderRole, SurfaceRole};
61
62use super::body::{RowBand, SharedColumnWidths, has_pinning};
63use super::column::{ColumnResizePolicy, ColumnSpec, ColumnWidth, PinnedSide};
64use super::header::{
65 ColumnResizeInfo, ColumnResizeTable, HeaderCell, HeaderCellSpec, ResizeStateHandle,
66 SharedFrozenWidths, width_overrides,
67};
68use super::imperative;
69use super::layout::{self, ColumnSolver, band_rects, insertion_slot_at_x};
70use super::{ColumnReorderDragData, PaneBoundaries};
71use crate::styles::recipe_table_style as cp;
72
73/// A process-unique id for one header's column-reorder drags. Every header
74/// draws from this one counter — a `TableView`'s, a `TreeTableView`'s and a
75/// standalone one — so a drop never takes another header's drag for one of
76/// its own.
77pub(crate) fn next_header_id() -> usize {
78 use std::sync::atomic::{AtomicUsize, Ordering};
79 static NEXT: AtomicUsize = AtomicUsize::new(1);
80 NEXT.fetch_add(1, Ordering::Relaxed)
81}
82
83/// The handles a header shares with whatever lays it out — all of them outlive
84/// one build of the header.
85///
86/// A standalone header owns a set ([`HeaderLink::new`]) and fills it itself; a
87/// view composing the header hands over its own (see [`TableHeader::hosted`]).
88#[derive(Clone)]
89pub(crate) struct HeaderLink {
90 /// Disambiguates this header's reorder drags; see [`next_header_id`].
91 pub table_id: usize,
92 /// Resolved widths in display order — shared with a view's body rows.
93 pub widths: SharedColumnWidths,
94 /// Display order, as indices into the columns.
95 pub display_indices: Rc<RefCell<Vec<usize>>>,
96 pub pane_boundaries: Rc<RefCell<PaneBoundaries>>,
97 /// The strip's width: the reorder drop mirrors its x against it under RTL,
98 /// where the columns are anchored to the strip's right edge.
99 pub strip_width: Rc<Cell<f32>>,
100 pub resize_state: ResizeStateHandle,
101 pub resize_target: Signal<Option<usize>>,
102 pub resize_preview_x: Signal<Option<f32>>,
103 /// `Some` when the width map is adopted: the widths a resize freezes stay
104 /// here rather than in the map, which other views may share. See
105 /// `commit_resize` in the header cell module.
106 pub frozen_widths: Option<SharedFrozenWidths>,
107}
108
109impl HeaderLink {
110 pub(crate) fn new() -> Self {
111 Self {
112 table_id: next_header_id(),
113 widths: Rc::new(RefCell::new(Vec::new())),
114 display_indices: Rc::new(RefCell::new(Vec::new())),
115 pane_boundaries: Rc::new(RefCell::new(PaneBoundaries::default())),
116 strip_width: Rc::new(Cell::new(0.0)),
117 resize_state: Rc::new(RefCell::new(None)),
118 resize_target: Signal::new(None),
119 resize_preview_x: Signal::new(None),
120 frozen_widths: None,
121 }
122 }
123}
124
125/// A table's column header strip: one `Role::ColumnHeader` cell per column,
126/// with click-to-sort, drag-to-resize on every divider, drag-to-reorder,
127/// pinned columns, the filter popover and a horizontal scroll offset to follow
128/// a scrolled body.
129///
130/// ```ignore
131/// let widths = Signal::new(HashMap::new());
132/// let sort = Signal::new(None);
133/// let header = TableHeader::new(vec![
134/// ColumnSpec::new("title", lit!("Title")).sortable(true),
135/// ColumnSpec::new("length", lit!("Length"))
136/// .width(ColumnWidth::Fixed(80.0))
137/// .sortable(true),
138/// ])
139/// .widths(widths.clone())
140/// .sort(sort.clone());
141/// let layout = header.resolved_widths_signal().clone(); // line rows up with this
142/// ```
143///
144/// A header announces itself as the `Role::Row` with row index 1, its cells as
145/// `Role::ColumnHeader`s, so place it inside the container that announces the
146/// rows under it as a table or a grid.
147///
148/// See the [module docs](crate::table_view::table_header) for how its state is
149/// shared and for how a view hosts it.
150pub struct TableHeader {
151 columns: Vec<ColumnSpec>,
152 widths: Signal<HashMap<String, f32>>,
153 sort: Signal<Option<(String, SortDirection)>>,
154 order: Signal<Vec<String>>,
155 pinning: Signal<HashMap<String, PinnedSide>>,
156 filters: Signal<HashMap<String, String>>,
157 scroll_x: Signal<f32>,
158 resize_policy: ColumnResizePolicy,
159 stretch_last_column: bool,
160 /// `(id, width)` per displayed column, published by a standalone header
161 /// after each layout.
162 resolved: Signal<Vec<(String, f32)>>,
163 link: HeaderLink,
164 /// `true` when a view composes this header; see the module docs.
165 hosted: bool,
166
167 // Build state.
168 cells: Vec<WidgetId>,
169 /// Pane bands, built only while a column is pinned (see `BodyRow`'s
170 /// module docs for why the split exists).
171 bands: Option<[Option<WidgetId>; 3]>,
172 boundaries: PaneBoundaries,
173}
174
175impl TableHeader {
176 /// A header over `columns`, in declaration order. Every piece of state it
177 /// is not handed is its own.
178 pub fn new(columns: Vec<ColumnSpec>) -> Self {
179 Self {
180 columns,
181 widths: Signal::new(HashMap::new()),
182 sort: Signal::new(None),
183 order: Signal::new(Vec::new()),
184 pinning: Signal::new(HashMap::new()),
185 filters: Signal::new(HashMap::new()),
186 scroll_x: Signal::new(0.0),
187 resize_policy: ColumnResizePolicy::default(),
188 stretch_last_column: false,
189 resolved: Signal::new(Vec::new()),
190 link: HeaderLink::new(),
191 hosted: false,
192 cells: Vec::new(),
193 bands: None,
194 boundaries: PaneBoundaries::default(),
195 }
196 }
197
198 /// Adopt `widths` as the map of column id → width override; a column
199 /// with no entry takes its declared [`ColumnWidth`]. A resize drag writes
200 /// the resized column only. Each `Flex` column before it keeps its
201 /// current width in this header, but not in the map, which other views
202 /// may share at other widths.
203 pub fn widths(mut self, widths: Signal<HashMap<String, f32>>) -> Self {
204 self.widths = widths;
205 // A hosting view hands over its own, adopted or not.
206 if !self.hosted {
207 self.link.frozen_widths = Some(Rc::default());
208 }
209 self
210 }
211
212 /// Adopt `sort` as the active sort. A click on a sortable column cycles it
213 /// None → ascending → descending → None.
214 pub fn sort(mut self, sort: Signal<Option<(String, SortDirection)>>) -> Self {
215 self.sort = sort;
216 self
217 }
218
219 /// Adopt `order` as the column order, a list of column ids; columns it
220 /// does not name follow in declaration order. A reorder drop writes it.
221 pub fn order(mut self, order: Signal<Vec<String>>) -> Self {
222 self.order = order;
223 self
224 }
225
226 /// Adopt `pinning` as the per-column pinning overrides, which win over
227 /// each [`ColumnSpec::pinned`]; [`PinnedSide::None`] unpins a column
228 /// declared pinned. A reorder drop writes it: an entry when the drop puts
229 /// the column in a pane other than the one it declares, none when it
230 /// puts it back.
231 pub fn pinning(mut self, pinning: Signal<HashMap<String, PinnedSide>>) -> Self {
232 self.pinning = pinning;
233 self
234 }
235
236 /// Adopt `filters` as the per-column filter text the filter popover of a
237 /// [`filterable`](ColumnSpec::filterable) column edits.
238 pub fn filters(mut self, filters: Signal<HashMap<String, String>>) -> Self {
239 self.filters = filters;
240 self
241 }
242
243 /// Follow `scroll_x`, the horizontal offset of the body under the header:
244 /// the unpinned columns shift left by it, the pinned ones stay put.
245 pub fn scroll_x(mut self, scroll_x: Signal<f32>) -> Self {
246 self.scroll_x = scroll_x;
247 self
248 }
249
250 /// Whether a resize writes its width on every pointer move (`Live`, the
251 /// default) or on release (`OnRelease`, with a guide line meanwhile).
252 pub fn resize_policy(mut self, policy: ColumnResizePolicy) -> Self {
253 self.resize_policy = policy;
254 self
255 }
256
257 /// Let the last column in display order take the width the others leave
258 /// — see [`TableView::stretch_last_column`](crate::TableView::stretch_last_column).
259 /// Default off.
260 pub fn stretch_last_column(mut self, on: bool) -> Self {
261 self.stretch_last_column = on;
262 self
263 }
264
265 /// Compose this header inside a view, which owns the layout and the drag
266 /// state `link` carries; see the module docs.
267 pub(crate) fn hosted(mut self, link: HeaderLink) -> Self {
268 self.link = link;
269 self.hosted = true;
270 self
271 }
272
273 /// The width overrides — the signal [`widths`](Self::widths) adopted, or
274 /// the header's own.
275 pub fn widths_signal(&self) -> &Signal<HashMap<String, f32>> {
276 &self.widths
277 }
278
279 /// The active sort — the signal [`sort`](Self::sort) adopted, or the
280 /// header's own.
281 pub fn sort_signal(&self) -> &Signal<Option<(String, SortDirection)>> {
282 &self.sort
283 }
284
285 /// The column order — the signal [`order`](Self::order) adopted, or the
286 /// header's own.
287 pub fn order_signal(&self) -> &Signal<Vec<String>> {
288 &self.order
289 }
290
291 /// The pinning overrides — the signal [`pinning`](Self::pinning)
292 /// adopted, or the header's own.
293 pub fn pinning_signal(&self) -> &Signal<HashMap<String, PinnedSide>> {
294 &self.pinning
295 }
296
297 /// The filters — the signal [`filters`](Self::filters) adopted, or the
298 /// header's own.
299 pub fn filters_signal(&self) -> &Signal<HashMap<String, String>> {
300 &self.filters
301 }
302
303 /// The horizontal offset the header follows — the signal
304 /// [`scroll_x`](Self::scroll_x) adopted, or the header's own (which
305 /// stays at 0).
306 pub fn scroll_x_signal(&self) -> &Signal<f32> {
307 &self.scroll_x
308 }
309
310 /// `(column id, width)` for each displayed column, in display order: the
311 /// widths the header laid its cells out at, after the overrides, the
312 /// declared widths, the `Flex` share and the min / max clamps. Written
313 /// when the header is placed in a layout pass, and only when it changed;
314 /// bind a row's layout to it to line the row's cells up with the columns.
315 /// Leading-pinned columns come first and do not move with `scroll_x`, nor
316 /// do the trailing-pinned ones at the end.
317 ///
318 /// **Rows follow it one layout pass late**, unless they read it in their
319 /// own `place_children` and are laid out after the header (below it in
320 /// the same stack): only the header's bounds settle the widths, and a row
321 /// that sizes itself from them in `layout_response` (a `FixedSize` bound
322 /// to a width derived from this, say) has been measured by then, so it is
323 /// relaid out on the next pass. During a live resize drag such a row
324 /// trails the header by a frame, and a headless test needs a second
325 /// `layout()` before it lines up.
326 ///
327 /// Published by a standalone header only; a view hosting the header lays
328 /// its rows out from the same widths directly.
329 pub fn resolved_widths_signal(&self) -> &Signal<Vec<(String, f32)>> {
330 &self.resolved
331 }
332
333 fn has_pinning(&self) -> bool {
334 has_pinning(self.boundaries, self.cells.len())
335 }
336
337 /// The display order, from the order and pinning signals — a standalone
338 /// header's own; a hosted one reads the view's.
339 fn compute_display_order(&self) {
340 let (display, boundaries) =
341 layout::display_order(&self.columns, &self.order.get(), &self.pinning.get());
342 *self.link.display_indices.borrow_mut() = display;
343 *self.link.pane_boundaries.borrow_mut() = boundaries;
344 }
345
346 fn resolve_widths(&self, available: f32) -> Vec<f32> {
347 let overrides = width_overrides(
348 self.widths.get(),
349 self.link.frozen_widths.as_ref(),
350 self.columns.iter().map(|c| c.id.as_str()),
351 );
352 ColumnSolver::resolve_in_order(
353 &self.columns,
354 &self.link.display_indices.borrow(),
355 available,
356 cp::MIN_COLUMN_WIDTH_DEFAULT,
357 &overrides,
358 self.stretch_last_column,
359 )
360 }
361
362 /// Wire the strip as the drop target of its own cells' reorder drags:
363 /// classify the drop into a pane and an insertion slot, and write the
364 /// pinning and the order.
365 fn reorder_drop_handlers(&self) -> HandlerSet {
366 let link = self.link.clone();
367 let order = self.order.clone();
368 let pinning = self.pinning.clone();
369 let scroll_x = self.scroll_x.clone();
370 let ids: Vec<String> = self.columns.iter().map(|c| c.id.clone()).collect();
371 let declared_pinning: Vec<PinnedSide> = self.columns.iter().map(|c| c.pinned).collect();
372 let table_id = self.link.table_id;
373
374 HandlerSet::new()
375 .on_drag_hover(|payload, _position, _ctx| {
376 if payload.has_typed::<ColumnReorderDragData>() {
377 teksilo_core::DropFeedback::HighlightRect {
378 rect: Rect::ZERO,
379 color: teksilo_tokens::Color::TRANSPARENT,
380 }
381 } else {
382 teksilo_core::DropFeedback::NoFeedback
383 }
384 })
385 .on_drop(move |mut payload, position, ctx| {
386 let drag = match payload.take_typed::<ColumnReorderDragData>() {
387 Some(d) => d,
388 None => return false,
389 };
390 if drag.source_table_id != table_id {
391 return false;
392 }
393 let widths = link.widths.borrow().clone();
394 let display = link.display_indices.borrow().clone();
395 let panes = *link.pane_boundaries.borrow();
396 let total = display.len();
397 if total == 0 {
398 return false;
399 }
400
401 // `position` is local to the strip (origin at its physical-left
402 // edge). Under RTL the columns are placed in display order from
403 // the strip's right edge leftward, so mirror the drop x against
404 // the strip width before running the left-to-right scan. (A drop
405 // in any non-content dead space then maps past the last column →
406 // append, matching LTR's trailing-end behaviour.)
407 let drop_x = if ctx.is_rtl() {
408 link.strip_width.get() - position.x
409 } else {
410 position.x
411 };
412
413 // Insertion index in display order: the first column whose
414 // midpoint exceeds the (mirrored) x — pane- and scroll-aware, so
415 // a drop under a nonzero `scroll_x` resolves against the columns
416 // actually under the pointer, not their unscrolled positions.
417 let insertion_display_idx = insertion_slot_at_x(
418 &widths,
419 panes,
420 scroll_x.get(),
421 link.strip_width.get(),
422 drop_x,
423 );
424
425 // Classify the drop position into a pane. A pane only exists
426 // while a column is pinned to it: with nothing pinned the leading
427 // pane is empty and the middle pane ends at the strip's end, so a
428 // drop at either end of the strip is a plain move to the first /
429 // last slot, not a pin — without the guards it would pin the
430 // column to a pane the user never saw.
431 let new_pinning = if panes.leading_count > 0
432 && insertion_display_idx <= panes.leading_count
433 {
434 PinnedSide::Leading
435 } else if panes.middle_end < total && insertion_display_idx >= panes.middle_end {
436 PinnedSide::Trailing
437 } else {
438 PinnedSide::None
439 };
440
441 // The pinning override: an entry when the drop puts the column
442 // on a side other than the one it declares — `None` included,
443 // or a column declared pinned could never be dragged out of its
444 // pane — and none when it puts it back on its own.
445 let Some(col_idx) = ids.iter().position(|id| id == &drag.col_id) else {
446 return false;
447 };
448 let mut pin_map = pinning.get();
449 if new_pinning == declared_pinning[col_idx] {
450 pin_map.remove(&drag.col_id);
451 } else {
452 pin_map.insert(drag.col_id.clone(), new_pinning);
453 }
454 pinning.set(pin_map);
455
456 // This header's columns in their new display order, each with
457 // the pane it now sits in.
458 let pane_at = |slot: usize| {
459 if slot < panes.leading_count {
460 PinnedSide::Leading
461 } else if slot < panes.middle_end {
462 PinnedSide::None
463 } else {
464 PinnedSide::Trailing
465 }
466 };
467 let mut new_display: Vec<(String, PinnedSide)> = display
468 .iter()
469 .enumerate()
470 .map(|(slot, &i)| (ids[i].clone(), pane_at(slot)))
471 .collect();
472 let from_pos = new_display.iter().position(|(id, _)| id == &drag.col_id);
473 if let Some(from) = from_pos {
474 let (item, _) = new_display.remove(from);
475 let to = if from < insertion_display_idx {
476 insertion_display_idx.saturating_sub(1)
477 } else {
478 insertion_display_idx
479 };
480 let to = to.min(new_display.len());
481 new_display.insert(to, (item, new_pinning));
482 order.set(move_dropped_column(
483 &order.get(),
484 &new_display,
485 &drag.col_id,
486 ));
487 }
488 true
489 })
490 }
491}
492
493impl std::fmt::Debug for TableHeader {
494 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
495 f.debug_struct("TableHeader")
496 .field("columns", &self.columns.len())
497 .field("hosted", &self.hosted)
498 .field("sort", &self.sort.get())
499 .finish()
500 }
501}
502
503impl Widget for TableHeader {
504 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
505 let self_id = ctx.self_id();
506 // `paint` draws the column separators from the widths and `scroll_x`,
507 // but neither a column resize nor a horizontal scroll changes the
508 // strip's own bounds — and the render walker replays a clean node's
509 // cached paint. The cells move with their new bounds; only the
510 // separators would stay behind. Repaint-only: geometry is a relayout's
511 // business, below for a standalone header and the view's for a hosted
512 // one.
513 self.widths
514 .bind_to(self_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
515 self.scroll_x
516 .bind_to(self_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
517 if !self.hosted {
518 // A view relays out and rebuilds a hosted header on all of these
519 // itself, and owns the display order it reads.
520 self.widths
521 .bind_to(self_id, ctx.binding_registry(), BindingLevel::Relayout);
522 self.scroll_x
523 .bind_to(self_id, ctx.binding_registry(), BindingLevel::Relayout);
524 // Each cell captures its sort direction, its filter state and its
525 // display position at build, and the bands follow the pinning.
526 self.sort
527 .bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
528 self.order
529 .bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
530 self.pinning
531 .bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
532 self.filters
533 .bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
534 self.link.resize_preview_x.bind_to(
535 self_id,
536 ctx.binding_registry(),
537 BindingLevel::RepaintOnly,
538 );
539 self.compute_display_order();
540 }
541
542 // A resize drag that loses the window never gets its PointerUp: the
543 // user Alt-Tabs (or a native dialog steals focus) with the button
544 // down, releases it over another window, and the OS delivers the Up
545 // nowhere. Abandon the gesture on deactivation, or the state outlives
546 // it and the next bare PointerMove drags the column with no button
547 // held. Nothing is committed — an interrupted drag leaves the column
548 // wherever the last delivered move put it, which is what the user last
549 // saw.
550 {
551 let resize_state = self.link.resize_state.clone();
552 let resize_target = self.link.resize_target.clone();
553 let resize_preview_x = self.link.resize_preview_x.clone();
554 ctx.effect(&ctx.window_active_signal(), move |active| {
555 if !*active && resize_state.borrow().is_some() {
556 *resize_state.borrow_mut() = None;
557 resize_target.set(None);
558 resize_preview_x.set(None);
559 }
560 });
561 }
562
563 // A build destroys (and re-creates) every header cell, which drops the
564 // pointer capture an in-flight resize depends on. Clear the shared drag
565 // state with it: a `ResizeState` that outlived its anchor would
566 // otherwise let the next bare PointerMove over the same column resize
567 // it with no button held.
568 *self.link.resize_state.borrow_mut() = None;
569 self.link.resize_target.set(None);
570 self.link.resize_preview_x.set(None);
571
572 let display = self.link.display_indices.borrow().clone();
573 self.boundaries = *self.link.pane_boundaries.borrow();
574
575 // A stretched last column has no size of its own to drag: its trailing
576 // grip (and the AT step actions behind the same flag) is off. The grip
577 // on its *leading* edge still resizes its predecessor.
578 let stretched_slot = self
579 .stretch_last_column
580 .then(|| display.len().saturating_sub(1));
581 let resize_columns: ColumnResizeTable = Rc::new(
582 display
583 .iter()
584 .enumerate()
585 .map(|(slot, &i)| {
586 let c = &self.columns[i];
587 ColumnResizeInfo {
588 id: c.id.clone(),
589 min_width: c.min_width.unwrap_or(cp::MIN_COLUMN_WIDTH_DEFAULT),
590 max_width: c.max_width,
591 resizable: c.resizable && stretched_slot != Some(slot),
592 flex: matches!(c.width, ColumnWidth::Flex(_)),
593 }
594 })
595 .collect(),
596 );
597
598 let active_sort = self.sort.get();
599 let cell_padding_horizontal = crate::styles::recipe_table_style::resolve_table_style(ctx)
600 .cell_padding_horizontal(&ctx.theme().input);
601 // Filter zone width: indicator glyph + a small horizontal padding for
602 // tap tolerance. Mirrors the layout of the HStack inside
603 // `HeaderCell::build` — including its gutter, which comes from the
604 // active `TableStyle` rather than from `cp::CELL_PADDING_HORIZONTAL`, so
605 // the padding the cell applies and the zone the press handler measures
606 // cannot name different cells.
607 //
608 // At every shipped gutter this is currently inert:
609 // `header_cell_zones` hands the figure to `partition_targets` with a
610 // floor of `target_size`, and 12 + 8 (IntUI) and 12 + 12 (Fluent) are
611 // both under the 24 dp Compact floor, so the solved zone is 24 either
612 // way. It stops being inert the moment a preset's gutter takes the sum
613 // past the floor, which is exactly when the two numbers disagreeing
614 // would show.
615 let filter_zone_width = cp::FILTER_INDICATOR_SIZE + cell_padding_horizontal;
616 self.cells = display
617 .iter()
618 .enumerate()
619 .map(|(display_pos, &col_idx)| {
620 let col = &self.columns[col_idx];
621 let current_sort = active_sort
622 .as_ref()
623 .and_then(|(id, dir)| (id == &col.id).then_some(*dir));
624 ctx.add(HeaderCell::new(HeaderCellSpec {
625 col_id: col.id.clone(),
626 label: col.header_label.resolve_now(),
627 col_index_1based: display_pos + 1,
628 sortable: col.sortable,
629 reorderable: col.reorderable,
630 filterable: col.filterable,
631 resize_grip: cp::RESIZE_HANDLE_WIDTH,
632 filter_zone_width,
633 current_sort,
634 width_index: display_pos,
635 pane_boundaries: self.boundaries,
636 resize_columns: resize_columns.clone(),
637 resize_policy: self.resize_policy,
638 resize_state: self.link.resize_state.clone(),
639 resize_target: self.link.resize_target.clone(),
640 resize_preview_x: self.link.resize_preview_x.clone(),
641 table_id: self.link.table_id,
642 sort_signal: self.sort.clone(),
643 column_widths_signal: self.widths.clone(),
644 frozen_widths: self.link.frozen_widths.clone(),
645 column_widths: self.link.widths.clone(),
646 filters_signal: self.filters.clone(),
647 }))
648 })
649 .collect();
650
651 ctx.apply_self_handlers(self.reorder_drop_handlers());
652
653 if !self.has_pinning() {
654 self.bands = None;
655 return self.cells.clone();
656 }
657 let b = self.boundaries;
658 let leading_end = b.leading_count.min(self.cells.len());
659 let middle_end = b.middle_end.min(self.cells.len()).max(leading_end);
660 let leading: Vec<WidgetId> = self.cells[..leading_end].to_vec();
661 let middle: Vec<WidgetId> = self.cells[leading_end..middle_end].to_vec();
662 let trailing: Vec<WidgetId> = self.cells[middle_end..].to_vec();
663
664 let mut bands: [Option<WidgetId>; 3] = [None, None, None];
665 if !leading.is_empty() {
666 bands[0] = Some(ctx.add(RowBand::new(leading, self.link.widths.clone(), 0)));
667 }
668 if !middle.is_empty() {
669 bands[1] = Some(
670 ctx.add(
671 RowBand::new(middle, self.link.widths.clone(), leading_end)
672 .scrollable(self.scroll_x.clone()),
673 ),
674 );
675 }
676 if !trailing.is_empty() {
677 bands[2] = Some(ctx.add(RowBand::new(trailing, self.link.widths.clone(), middle_end)));
678 }
679 self.bands = Some(bands);
680 bands.iter().copied().flatten().collect()
681 }
682
683 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
684 let width = proposal.width.unwrap_or_else(|| {
685 if self.hosted {
686 self.link.widths.borrow().iter().sum()
687 } else {
688 // With no width to fill, every `Flex` column sits at its floor.
689 self.resolve_widths(0.0).iter().sum()
690 }
691 });
692 let height = proposal.height.unwrap_or(cp::HEADER_HEIGHT);
693 Size::new(width, height).into()
694 }
695
696 fn place_children(
697 &self,
698 bounds: Rect,
699 _proposal: SizeProposal,
700 children: &mut [WidgetPlacement],
701 ctx: &LayoutContext,
702 ) {
703 if !self.hosted {
704 let widths = self.resolve_widths(bounds.width);
705 let resolved: Vec<(String, f32)> = self
706 .link
707 .display_indices
708 .borrow()
709 .iter()
710 .zip(&widths)
711 .map(|(&i, &w)| (self.columns[i].id.clone(), w))
712 .collect();
713 *self.link.widths.borrow_mut() = widths;
714 self.link.strip_width.set(bounds.width);
715 // Equality-guarded: a layout that changed nothing must not wake a
716 // row bound to this.
717 imperative::set_if_changed(&self.resolved, resolved);
718 }
719
720 if let Some(bands) = self.bands {
721 let widths = self.link.widths.borrow();
722 let rtl = ctx.is_rtl();
723 let (leading_rect, middle_rect, trailing_rect) =
724 band_rects(bounds, &widths, self.boundaries, rtl);
725 let rects = [leading_rect, middle_rect, trailing_rect];
726 let mut next = 0;
727 for (band, rect) in bands.iter().zip(rects.iter()) {
728 if band.is_some() {
729 if let Some(child) = children.get_mut(next) {
730 child.origin = rect.origin();
731 child.size = rect.size();
732 }
733 next += 1;
734 }
735 }
736 return;
737 }
738
739 let widths = self.link.widths.borrow();
740 let total_children = children.len();
741 let fallback_w = if total_children == 0 {
742 0.0
743 } else {
744 bounds.width / total_children as f32
745 };
746 let scroll = self.scroll_x.get();
747 // Mirror the body: preserve display order, reverse physical x in RTL.
748 if ctx.is_rtl() {
749 let mut x = bounds.right() + scroll;
750 for (i, child) in children.iter_mut().enumerate() {
751 let w = widths.get(i).copied().unwrap_or(fallback_w);
752 x -= w;
753 child.origin = Point::new(x, bounds.y);
754 child.size = Size::new(w, bounds.height);
755 }
756 } else {
757 let mut x = bounds.x - scroll;
758 for (i, child) in children.iter_mut().enumerate() {
759 let w = widths.get(i).copied().unwrap_or(fallback_w);
760 child.origin = Point::new(x, bounds.y);
761 child.size = Size::new(w, bounds.height);
762 x += w;
763 }
764 }
765 }
766
767 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
768 let bg = SurfaceRole::Raised.resolve(&ctx.theme.colors);
769 canvas.fill_rect(bounds, bg);
770
771 let line = BorderRole::DividerStrong.resolve(&ctx.theme.colors);
772 let dw = cp::GRID_LINE_THICKNESS.max(1.0);
773 canvas.fill_rect(
774 Rect::new(bounds.x, bounds.y + bounds.height - dw, bounds.width, dw),
775 line,
776 );
777
778 // Column separators. Unlike the body's vertical grid lines these are
779 // NOT gated on `GridLines` — in the header the separator *is* the
780 // resize affordance (it is the only thing showing where the grip is),
781 // so a table with `GridLines::None`/`Horizontal` — the default, and
782 // what both shipped demos use — would otherwise ask the user to grab
783 // an invisible divider. Every desktop table (QHeaderView, GtkTreeView,
784 // NSTableHeaderView) draws them unconditionally for the same reason.
785 let widths = self.link.widths.borrow();
786 if widths.len() > 1 {
787 let rtl =
788 ctx.layout_direction == teksilo_core::environment::LayoutDirection::RightToLeft;
789 let sep = BorderRole::Divider.resolve(&ctx.theme.colors);
790 let (leading_rect, middle_rect, trailing_rect) =
791 band_rects(bounds, &widths, self.boundaries, rtl);
792 let b = self.boundaries;
793 let leading_end = b.leading_count.min(widths.len());
794 let middle_end = b.middle_end.min(widths.len()).max(leading_end);
795 // Within-pane dividers (the Middle pane's are scroll-shifted and
796 // bounded to its viewport, exactly like the body's).
797 draw_band_separators(
798 canvas,
799 leading_rect,
800 &widths[..leading_end],
801 0.0,
802 rtl,
803 sep,
804 dw,
805 );
806 draw_band_separators(
807 canvas,
808 middle_rect,
809 &widths[leading_end..middle_end],
810 self.scroll_x.get(),
811 rtl,
812 sep,
813 dw,
814 );
815 draw_band_separators(
816 canvas,
817 trailing_rect,
818 &widths[middle_end..],
819 0.0,
820 rtl,
821 sep,
822 dw,
823 );
824 // Pane seams — the boundary between the last pinned column and
825 // the scrolling region. `draw_band_separators` only draws a band's
826 // *internal* boundaries, so these two would otherwise be the only
827 // column edges in the strip with no line.
828 let mut seam = |x: f32| {
829 canvas.fill_rect(Rect::new(x, bounds.y, dw, bounds.height), sep);
830 };
831 if leading_end > 0 {
832 seam(if rtl {
833 leading_rect.x
834 } else {
835 leading_rect.right() - dw
836 });
837 }
838 if middle_end < widths.len() {
839 seam(if rtl {
840 trailing_rect.right() - dw
841 } else {
842 trailing_rect.x
843 });
844 }
845 }
846 }
847
848 fn wants_post_paint(&self) -> bool {
849 // A hosting view paints the guide itself, across its rows too.
850 !self.hosted
851 }
852
853 /// The `OnRelease` resize guide. Under that policy no column moves until
854 /// the button comes up, so this line is the *only* feedback the gesture
855 /// has. Drawn over the cells, within the strip.
856 fn post_paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
857 if let Some(x) = self.link.resize_preview_x.get() {
858 let thickness = cp::GRID_LINE_THICKNESS.max(1.5);
859 canvas.fill_rect(
860 Rect::new(x - thickness * 0.5, bounds.y, thickness, bounds.height),
861 BorderRole::Focused.resolve(&ctx.theme.colors),
862 );
863 }
864 }
865
866 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
867 builder.set_role(teksilo_core::accesskit::Role::Row);
868 builder.set_row_index(1);
869 }
870
871 fn children(&self) -> Vec<WidgetId> {
872 match self.bands {
873 Some(bands) => bands.iter().copied().flatten().collect(),
874 None => self.cells.clone(),
875 }
876 }
877
878 fn clips_children(&self) -> bool {
879 // A hosting view clips the strip with the rest of the table.
880 !self.hosted
881 }
882
883 fn as_any(&self) -> Option<&dyn std::any::Any> {
884 Some(self)
885 }
886}
887
888/// Draw one pane band's internal column separators inside the header strip.
889///
890/// Deliberately *not* [`super::draw_pane_dividers`], which scissors each band:
891/// the strip paints inside its view's `clips_children` scope, and
892/// `Canvas::clear_clip` resets the scissor outright rather than popping a
893/// stack — so borrowing that helper here would drop the table's clip for every
894/// header cell painted afterwards (the walker emits the enclosing `SetClip`
895/// before the children, not around each one). Separators are `line_w` wide, so
896/// range-testing each against the band is equivalent to scissoring it, and
897/// leaves the clip state untouched.
898fn draw_band_separators(
899 canvas: &mut Canvas,
900 rect: Rect,
901 slice: &[f32],
902 scroll: f32,
903 rtl: bool,
904 color: teksilo_tokens::Color,
905 line_w: f32,
906) {
907 if slice.len() < 2 || rect.width <= 0.0 {
908 return;
909 }
910 let mut emit = |x: f32| {
911 if x >= rect.x && x + line_w <= rect.right() {
912 canvas.fill_rect(Rect::new(x, rect.y, line_w, rect.height), color);
913 }
914 };
915 // Same walk (and same which-side-of-the-boundary convention) as the body's
916 // vertical grid lines, so header and body seams land on the same x.
917 if rtl {
918 let mut x = rect.right() + scroll;
919 for &w in &slice[..slice.len() - 1] {
920 x -= w;
921 emit(x);
922 }
923 } else {
924 let mut x = rect.x - scroll;
925 for &w in &slice[..slice.len() - 1] {
926 x += w;
927 emit(x - line_w);
928 }
929 }
930}
931
932/// Write the drop of `dragged` into `existing`, the order signal's current
933/// list, which may also name columns this header does not have.
934///
935/// `display` is this header's columns in their display order after the drop,
936/// each with the pane it sits in then. `display_order` sorts each pane by the
937/// columns' positions in the list (the ones the list does not name follow, in
938/// declaration order), so the list only has to put `dragged` right before the
939/// next column of its pane, or right after the previous one.
940///
941/// Only `dragged` moves. An order several views share (`bind_column_order`)
942/// carries every view's columns; re-slotting this header's whole set instead
943/// would move the columns another view shares past ids that view lacks, and
944/// reorder it though nothing it shows was dragged. Own columns the list does
945/// not name yet are appended first, in their current order, so the list
946/// reads as this header's whole order afterwards.
947pub(crate) fn move_dropped_column(
948 existing: &[String],
949 display: &[(String, PinnedSide)],
950 dragged: &str,
951) -> Vec<String> {
952 let mut out = existing.to_vec();
953 for (id, _) in display {
954 if !out.contains(id) {
955 out.push(id.clone());
956 }
957 }
958 let Some(was) = out.iter().position(|id| id == dragged) else {
959 return out;
960 };
961 out.remove(was);
962 let Some(at) = display.iter().position(|(id, _)| id == dragged) else {
963 out.insert(was, dragged.to_string());
964 return out;
965 };
966 let pane = display[at].1;
967 let next = display[at + 1..].iter().find(|(_, side)| *side == pane);
968 let prev = display[..at].iter().rev().find(|(_, side)| *side == pane);
969 let slot = |id: &String| out.iter().position(|o| o == id);
970 let to = match (next, prev) {
971 (Some((next, _)), _) => slot(next),
972 (None, Some((prev, _))) => slot(prev).map(|i| i + 1),
973 // Alone in its pane: where it sits among other panes' columns
974 // changes nothing.
975 (None, None) => None,
976 }
977 .unwrap_or(was);
978 out.insert(to, dragged.to_string());
979 out
980}