Skip to main content

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}