Skip to main content

teksilo_widgets/table_view/
header.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Per-column header cells.
5//!
6//! `HeaderCell` lays out as
7//! `Padding → HStack { TextWidget(label), Spacer, SortIndicator? }` and
8//! handles click-to-sort plus drag-to-resize on the grip that straddles
9//! *either* of the two column dividers it touches (see the type's docs).
10//! [`TableHeader`](super::table_header::TableHeader) lays the cells out
11//! horizontally using the same shared `column_widths` handle that body rows
12//! consume — so a resize commits in one place and reflows everywhere — and
13//! paints the column separators that make those grips findable.
14//!
15//! Supports sort, resize, reuse across pinned panes, per-column filter
16//! popovers, and the start of a column-reorder drag (the strip is its drop
17//! target).
18//!
19//! ## Touch and pen
20//!
21//! A header cell paints three things inside one node and tells them apart by
22//! coordinate: the label (whose press cycles the sort or starts the column
23//! reorder), the filter-popover trigger, and the resize band on its trailing edge.
24//!
25//! The label and filter zones come from
26//! [`partition_targets`](teksilo_core::partition::partition_targets), which gives
27//! the filter zone the density's target floor by clamp-and-redistribute — the glyph
28//! and its padding are below the conformance floor at every density — answers in
29//! reading order so nothing mirrors for RTL, and splits evenly when the two floors
30//! cannot both be met. The same function answers
31//! [`teksilo_core::widget::Widget::target_regions`], so the
32//! geometry an audit measures is the geometry the press test uses.
33//!
34//! The plain press stays `Ignored` so a finger can pan the table from its header
35//! strip, and a coarse pointer's column reorder waits for a hold.
36
37use std::cell::{Cell, RefCell};
38use std::collections::HashMap;
39use std::rc::Rc;
40use teksilo_i18n::lit;
41
42use teksilo_canvas::{Canvas, Path, Point, Rect, Size, SizeProposal};
43use teksilo_core::accessibility::AccessNodeBuilder;
44use teksilo_core::build_context::BuildContext;
45use teksilo_core::color_prop::ColorProp;
46use teksilo_core::drag_payload::DragPayload;
47use teksilo_core::event::{EventResponse, PointerButton, WidgetEvent};
48use teksilo_core::signal::Signal;
49use teksilo_core::styles::{
50    SharedTableStyle, SortDirection as StyleSortDirection, TableHeaderCellConfig,
51};
52use teksilo_core::widget::{
53    CursorIcon, EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement,
54};
55use teksilo_core::widget_builder::HandlerSet;
56use teksilo_core::widget_id::WidgetId;
57use teksilo_data::SortDirection;
58use teksilo_tokens::{TextRole, TextStyleRole};
59
60/// Convert the data-layer `SortDirection` (teksilo-data) to the
61/// styles-layer one (teksilo-core::styles). They share the same shape
62/// but are distinct types because teksilo-core cannot depend on
63/// teksilo-data.
64fn style_sort(d: SortDirection) -> StyleSortDirection {
65    match d {
66        SortDirection::Ascending => StyleSortDirection::Ascending,
67        SortDirection::Descending => StyleSortDirection::Descending,
68    }
69}
70
71use crate::primitives::{HStack, Padding, Spacer, TextWidget};
72
73use super::ColumnReorderDragData;
74use super::PaneBoundaries;
75use super::body::SharedColumnWidths;
76use super::column::ColumnResizePolicy;
77use super::filter::FilterIndicator;
78use super::filter::FilterPopoverContent;
79use crate::overlay_trigger::OverlayTrigger;
80use crate::popover_widget::PopoverWidget;
81use teksilo_core::overlay::OverlayPlacement;
82
83const DRAG_REORDER_THRESHOLD: f32 = 5.0;
84
85/// Per-column resize metadata, in **display order** — the table a
86/// [`HeaderCell`] consults to resolve a grabbed divider into the column it
87/// actually resizes, and to clamp the committed width to exactly the bounds
88/// [`ColumnSolver::resolve_in_order`](super::layout::ColumnSolver::resolve_in_order)
89/// will re-apply to the override.
90///
91/// A cell owns *two* dividers (the one at its trailing edge and the one at
92/// its leading edge, shared with its predecessor), so it cannot resize from
93/// its own column's declaration alone — hence a shared table rather than
94/// per-cell scalars.
95#[derive(Debug, Clone)]
96pub(crate) struct ColumnResizeInfo {
97    /// Stable column id — the key `column_widths_signal` is written under.
98    pub id: String,
99    /// Floor a resize can't push this column below — the column's own
100    /// `min_width` if declared, else the table's `min_column_width_default`.
101    pub min_width: f32,
102    /// Ceiling, if the column declared one.
103    pub max_width: Option<f32>,
104    /// Whether the column opted into drag-resize at all.
105    pub resizable: bool,
106    /// Whether the column's declared width is `Flex` — it shares the pane's
107    /// leftover, so it would absorb part of any resize made *after* it in
108    /// display order unless [`commit_resize`] freezes it first.
109    pub flex: bool,
110}
111
112/// Shared, display-ordered [`ColumnResizeInfo`] table. Rebuilt (and re-shared
113/// into every `HeaderCell`) on each view rebuild, which is also when the
114/// display order can change — so an index into it is always current.
115pub(crate) type ColumnResizeTable = Rc<Vec<ColumnResizeInfo>>;
116
117/// Which pane a display slot belongs to (0 = Leading-pinned, 1 = Middle /
118/// scrollable, 2 = Trailing-pinned). Only used to decide whether two adjacent
119/// slots share a *column* divider or a *pane seam* — a seam is not a resize
120/// boundary, because under a nonzero `scroll_x` the column on its far side is
121/// not the one visually adjacent to it.
122fn pane_of(slot: usize, b: PaneBoundaries) -> u8 {
123    if slot < b.leading_count {
124        0
125    } else if slot < b.middle_end {
126        1
127    } else {
128        2
129    }
130}
131
132/// Clamp a dragged width to the same `[min, max]` window the column solver
133/// re-applies to the stored override. Keeping the two in lock-step is what
134/// stops `column_widths_signal` — a *public* handle apps read back and
135/// persist — from holding a width the table never renders.
136fn clamp_width(w: f32, min: f32, max: Option<f32>) -> f32 {
137    w.max(min).min(max.unwrap_or(f32::INFINITY))
138}
139
140/// Active resize state for one column. Anchored at PointerDown,
141/// advanced on PointerMove, committed (and cleared) on PointerUp.
142#[derive(Debug, Clone)]
143pub(crate) struct ResizeState {
144    /// Id of the column being resized — not necessarily the cell that owns
145    /// the gesture, since grabbing a cell's *leading* edge resizes its
146    /// predecessor (see [`HeaderCell`]'s grip docs).
147    pub col_id: String,
148    /// Display slot of the column being resized — the slot `col_id` occupies
149    /// in the shared [`ColumnResizeTable`] / widths vector, so the commit can
150    /// tell which columns precede it.
151    pub target_index: usize,
152    /// Display slot of the cell that took the pointer capture. Only that
153    /// cell may advance or commit the drag; a mismatch means the event
154    /// reached the wrong cell (capture lost) and must not move a column.
155    pub anchor_index: usize,
156    /// Pointer x at PointerDown, in **window** coordinates. Window-space
157    /// (not cell-local) so the delta stays stable across the relayouts a
158    /// Live-policy resize triggers: under RTL a widening column's
159    /// physical-left edge — and thus the cell's local origin — moves
160    /// mid-drag, so a cell-local anchor would drift. Window x doesn't.
161    pub start_pointer_x: f32,
162    /// Target column's width at PointerDown (in pixels).
163    pub start_width: f32,
164    /// Window x of the grabbed divider at PointerDown — the anchor the
165    /// `OnRelease` preview line is offset from.
166    pub start_divider_x: f32,
167    /// Resolved floor / ceiling of the target column, snapshotted so the
168    /// commit path needs no second lookup.
169    pub min_width: f32,
170    pub max_width: Option<f32>,
171}
172
173pub(crate) type ResizeStateHandle = Rc<RefCell<Option<ResizeState>>>;
174
175/// Press state recorded on PointerDown in the label region of a
176/// HeaderCell. If the pointer moves past `DRAG_REORDER_THRESHOLD`
177/// before PointerUp, the cell starts a reorder drag; otherwise the
178/// cell cycles its sort on PointerUp.
179#[derive(Debug, Clone, Copy)]
180struct PressState {
181    pointer_x: f32,
182    pointer_y: f32,
183}
184
185/// Everything one [`HeaderCell`] needs, gathered into a struct because the
186/// positional constructor had outgrown readability (and clippy's
187/// `too_many_arguments`) long before the resize grip needed three more
188/// fields. Both `TableView` and `TreeTableView` fill this in the same shape.
189pub(crate) struct HeaderCellSpec {
190    pub col_id: String,
191    pub label: String,
192    pub col_index_1based: usize,
193    pub sortable: bool,
194    pub reorderable: bool,
195    pub filterable: bool,
196    /// Half-width of the resize grip: the grabbable band extends this far on
197    /// **each** side of a column divider (the Qt `PM_HeaderGripMargin`
198    /// convention), not just inside the cell that owns the divider.
199    pub resize_grip: f32,
200    /// Width of the trailing region reserved for the filter popover trigger
201    /// (glyph + padding). Ignored when `filterable` is false.
202    pub filter_zone_width: f32,
203    pub current_sort: Option<SortDirection>,
204    pub width_index: usize,
205    pub pane_boundaries: PaneBoundaries,
206    pub resize_columns: ColumnResizeTable,
207    pub resize_policy: ColumnResizePolicy,
208    pub resize_state: ResizeStateHandle,
209    /// Display slot of the column under an active resize, or `None`. Shared
210    /// view-wide: the *target* cell derives its `is_resizing` chrome from it,
211    /// which is not always the cell holding the capture.
212    pub resize_target: Signal<Option<usize>>,
213    /// Window x of the prospective divider while an `OnRelease` drag is in
214    /// flight, or `None`. The view paints it as a guide line — without it
215    /// `OnRelease` gives the user no feedback at all until the button comes up.
216    pub resize_preview_x: Signal<Option<f32>>,
217    pub table_id: usize,
218    pub sort_signal: Signal<Option<(String, SortDirection)>>,
219    pub column_widths_signal: Signal<HashMap<String, f32>>,
220    /// `Some` when `column_widths_signal` is adopted: where a resize keeps
221    /// the widths it freezes instead (see [`commit_resize`]).
222    pub frozen_widths: Option<SharedFrozenWidths>,
223    pub column_widths: SharedColumnWidths,
224    pub filters_signal: Signal<HashMap<String, String>>,
225}
226
227/// One header cell — label, optional sort indicator, click-to-sort,
228/// drag-to-resize on either of the two dividers it touches.
229///
230/// ## The resize grip
231///
232/// A column divider is a boundary *between* two cells, so the grabbable band
233/// straddles it: `resize_grip` px inside the cell on each side. Consequently
234/// a cell claims two zones — its reading-order **trailing** edge (which
235/// resizes *this* column) and its **leading** edge (which resizes its
236/// **predecessor**, whose trailing edge that same divider is). Claiming only
237/// the former, as this widget originally did, left the outer half of every
238/// divider owned by the next cell's label region: a grab that missed by one
239/// pixel cycled the sort or started a column-reorder drag instead of
240/// resizing.
241///
242/// Under RTL the display order runs right-to-left, so "reading-order
243/// trailing" is the cell's physical-**left** edge and the two zones swap
244/// sides; the drag sign inverts with them (see `on_pointer_event`).
245///
246/// The leading zone is suppressed when the predecessor sits in a different
247/// pane: that boundary is a pane *seam*, and under a nonzero `scroll_x` the
248/// column on its far side is not the one visually adjacent to it.
249pub(crate) struct HeaderCell {
250    col_id: String,
251    label: String,
252    col_index_1based: usize,
253    sortable: bool,
254    reorderable: bool,
255    /// Half-width of the resize grip — see the type docs.
256    resize_grip: f32,
257    /// Sort direction for *this* column, or `None` if it isn't the
258    /// active sort column. Captured at build time and reflected in the
259    /// AccessKit node + the chevron child.
260    current_sort: Option<SortDirection>,
261    sort_signal: Signal<Option<(String, SortDirection)>>,
262    column_widths_signal: Signal<HashMap<String, f32>>,
263    /// See [`HeaderCellSpec::frozen_widths`].
264    frozen_widths: Option<SharedFrozenWidths>,
265    /// Live resolved widths, shared with the row layout. Read at
266    /// PointerDown to record the starting width of whichever column the
267    /// grabbed divider belongs to.
268    column_widths: SharedColumnWidths,
269    /// Index of this column in the resolved-widths vector.
270    width_index: usize,
271    /// Pane partition, so the leading grip can be suppressed across a seam.
272    pane_boundaries: PaneBoundaries,
273    /// Display-ordered resize metadata for **all** columns — this cell needs
274    /// its predecessor's floor/ceiling too.
275    resize_columns: ColumnResizeTable,
276    resize_policy: ColumnResizePolicy,
277    resize_state: ResizeStateHandle,
278    resize_target: Signal<Option<usize>>,
279    resize_preview_x: Signal<Option<f32>>,
280    /// Stable id of the owning TableView, propagated into the reorder
281    /// drag payload so inter-table drops are rejected.
282    table_id: usize,
283    /// This cell's window-space leading edge, written by `place_children`
284    /// and read by `on_pointer_event` to translate the window-coord
285    /// pointer position into cell-local coords. Without this, the
286    /// trailing-edge resize-zone test (`local_x > cell_w - resize_zone`)
287    /// would compare against a window x that's always huge for any
288    /// column past the first one, firing resize from the wrong region.
289    cell_window_x: Rc<Cell<f32>>,
290    /// This cell's placed width, written by `place_children`. The grip test
291    /// runs against the geometry actually on screen rather than against the
292    /// shared widths vector, so the two can never disagree (they do when the
293    /// vector is shorter than the cell list and `TableHeader` falls back to an
294    /// even split).
295    cell_window_w: Rc<Cell<f32>>,
296    /// The partition floor and reading direction, so `target_regions` — which
297    /// is handed a rectangle and nothing else — can carve the same zones the
298    /// press test carves. Written in `build` and `place_children`.
299    zone_floor: Cell<f32>,
300    zone_rtl: Cell<bool>,
301    /// This cell's resolved height, written by `place_children` and read
302    /// by `on_pointer_event` to reject pointer events that bubble up from
303    /// the in-tree filter popover (a descendant overlay anchored below the
304    /// cell). Without it the x-only resize-zone test paints a `ColResize`
305    /// cursor across the whole popover.
306    cell_window_h: Rc<Cell<f32>>,
307    /// `true` when the column's `filterable` flag is set. Drives the
308    /// filter popover affordance and a "leave-this-region-alone" zone
309    /// in the cell's pointer handler so PointerDown over the popover
310    /// trigger reaches the trigger instead of being eaten by sort.
311    filterable: bool,
312    /// Width of the trailing region reserved for the filter popover
313    /// trigger (glyph + padding). Zero when `filterable` is false.
314    filter_zone_width: f32,
315    /// Live per-column filter map. The popover edits this signal in
316    /// place via `set_filter` semantics — empty string removes the
317    /// entry, non-empty string inserts/replaces it.
318    filters_signal: Signal<HashMap<String, String>>,
319    /// `true` while the pointer is inside the cell — drives the
320    /// `Hover` overlay supplied by `TableStyle::make_header_cell`.
321    /// Toggled by the cell's `on_hover` handler.
322    is_hovered: Signal<bool>,
323    /// `true` while **this column** is the one being resized — drives the
324    /// `Pressed` overlay supplied by `TableStyle::make_header_cell`. Derived
325    /// from the shared `resize_target` rather than set locally, so the
326    /// highlight follows the column that moves even when the gesture is
327    /// anchored on its neighbour's leading grip.
328    is_resizing: Signal<bool>,
329
330    // Build state
331    root_child_id: Option<WidgetId>,
332}
333
334impl HeaderCell {
335    pub(crate) fn new(spec: HeaderCellSpec) -> Self {
336        let width_index = spec.width_index;
337        let is_resizing = spec.resize_target.map(move |t| *t == Some(width_index));
338        Self {
339            col_id: spec.col_id,
340            label: spec.label,
341            col_index_1based: spec.col_index_1based,
342            sortable: spec.sortable,
343            reorderable: spec.reorderable,
344            resize_grip: spec.resize_grip,
345            current_sort: spec.current_sort,
346            sort_signal: spec.sort_signal,
347            column_widths_signal: spec.column_widths_signal,
348            frozen_widths: spec.frozen_widths,
349            column_widths: spec.column_widths,
350            width_index,
351            pane_boundaries: spec.pane_boundaries,
352            resize_columns: spec.resize_columns,
353            resize_policy: spec.resize_policy,
354            resize_state: spec.resize_state,
355            resize_target: spec.resize_target,
356            resize_preview_x: spec.resize_preview_x,
357            table_id: spec.table_id,
358            cell_window_x: Rc::new(Cell::new(0.0)),
359            cell_window_w: Rc::new(Cell::new(0.0)),
360            zone_floor: Cell::new(0.0),
361            zone_rtl: Cell::new(false),
362            cell_window_h: Rc::new(Cell::new(0.0)),
363            filterable: spec.filterable,
364            filter_zone_width: if spec.filterable {
365                spec.filter_zone_width
366            } else {
367                0.0
368            },
369            filters_signal: spec.filters_signal,
370            is_hovered: Signal::new(false),
371            is_resizing,
372            root_child_id: None,
373        }
374    }
375}
376
377impl std::fmt::Debug for HeaderCell {
378    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379        f.debug_struct("HeaderCell")
380            .field("col_id", &self.col_id)
381            .field("label", &self.label)
382            .field("sortable", &self.sortable)
383            .field(
384                "resizable",
385                &self
386                    .resize_columns
387                    .get(self.width_index)
388                    .map(|c| c.resizable)
389                    .unwrap_or(false),
390            )
391            .field("current_sort", &self.current_sort)
392            .finish()
393    }
394}
395
396/// `part` discriminator: the sort / reorder zone carrying the column label.
397pub const HEADER_PART_LABEL: u16 = 0;
398/// `part` discriminator: the filter-popover trigger zone.
399pub const HEADER_PART_FILTER: u16 = 1;
400/// `part` discriminator: the column-resize band on the cell's trailing edge.
401pub const HEADER_PART_RESIZE: u16 = 2;
402
403/// The label / filter partition of one header cell, in the frame of `bounds`.
404///
405/// One function for the two questions a split cell has to answer the same way:
406/// where the press test draws the boundary, and what
407/// [`teksilo_core::widget::Widget::target_regions`]
408/// reports. Both used to be open-coded — the press test as a physical-x
409/// comparison with its own RTL branch, the report not at all — and a cell
410/// painting two targets inside one node is exactly what `target_regions`
411/// exists for.
412///
413/// The trailing resize grip is excluded: the resize test above owns that band,
414/// on **both** of the cell's edges. What is left is split by
415/// [`partition_targets`](teksilo_core::partition::partition_targets), which
416/// gives the filter affordance its `floor` by
417/// clamp-and-redistribute, answers in reading order (so nothing here mirrors
418/// for RTL), and — when the two floors cannot both be met — splits evenly
419/// rather than letting either zone vanish.
420///
421/// Returns `None` for a cell with no filter affordance: there is one zone, it
422/// is the cell, and there is nothing to partition.
423fn header_cell_zones(
424    bounds: Rect,
425    filter_zone_w: f32,
426    grip: f32,
427    floor: f32,
428    rtl: bool,
429) -> Option<[Rect; 2]> {
430    if filter_zone_w <= 0.0 || bounds.width <= 0.0 {
431        return None;
432    }
433    let usable_w = (bounds.width - grip).max(0.0);
434    // The grip sits at the reading-order trailing edge, which is the physical
435    // left one under RTL, so the usable band starts past it there.
436    let usable = Rect::new(
437        if rtl { bounds.x + grip } else { bounds.x },
438        bounds.y,
439        usable_w,
440        bounds.height,
441    );
442    let label_w = (usable_w - filter_zone_w).max(0.0);
443    let direction = if rtl {
444        teksilo_core::environment::LayoutDirection::RightToLeft
445    } else {
446        teksilo_core::environment::LayoutDirection::LeftToRight
447    };
448    let zones = teksilo_core::partition::partition_targets(
449        usable,
450        &[label_w, filter_zone_w],
451        floor,
452        direction,
453    );
454    Some([zones[0], zones[1]])
455}
456
457impl Widget for HeaderCell {
458    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
459        use crate::styles::recipe_table_style as cp;
460
461        let label_id = ctx.add(
462            TextWidget::new(lit!(self.label.clone()))
463                .style(TextStyleRole::Body)
464                .color(ColorProp::from(TextRole::Primary))
465                .single_line()
466                .a11y_hidden(),
467        );
468
469        let mut row = HStack::new()
470            .spacing(4.0)
471            .child(label_id)
472            .child(ctx.add(Spacer::new()));
473
474        if self.current_sort.is_some() {
475            let chevron = ctx.add(SortIndicator::new(
476                self.current_sort,
477                cp::SORT_INDICATOR_SIZE,
478            ));
479            row = row.child(chevron);
480        }
481
482        // Filter popover trigger — appears at the trailing end of the
483        // cell after the sort indicator. The Popover content is a
484        // `TextInput` with a trailing clear `IconButton` bound back
485        // into `filters_signal`. Callers can also mutate
486        // `filters_signal` programmatically.
487        if self.filterable {
488            let filters_signal = self.filters_signal.clone();
489            let col_id = self.col_id.clone();
490            let initial = self
491                .filters_signal
492                .get()
493                .get(&self.col_id)
494                .cloned()
495                .unwrap_or_default();
496            let active = !initial.is_empty();
497            let glyph = FilterIndicator::new(cp::FILTER_INDICATOR_SIZE, active);
498            let on_change = {
499                let filters_signal = filters_signal.clone();
500                let col_id = col_id.clone();
501                move |s: &str| {
502                    let mut m = filters_signal.get();
503                    if s.is_empty() {
504                        m.remove(&col_id);
505                    } else {
506                        m.insert(col_id.clone(), s.to_string());
507                    }
508                    filters_signal.set(m);
509                }
510            };
511            // A custom (non-button) trigger, so `PopoverWidget` takes it through
512            // `OverlayTrigger`. No `focus_on_show` slot: the popover asks for
513            // focus by *panel* id and the framework walks to the first focusable
514            // descendant, which is this panel's filter field.
515            let popover = PopoverWidget::new(
516                OverlayTrigger::around(glyph).named(lit!("Filter").resolve_now()),
517            )
518            .content(FilterPopoverContent::new(initial).on_change(on_change))
519            .placement(OverlayPlacement::BelowPreferred)
520            .show_disclosure_caret(false);
521            let popover_id = ctx.add(popover);
522            row = row.child(popover_id);
523        }
524        // Route the header cell's chrome through `TableStyle::make_header_cell`.
525        // The default `RecipeTableStyle` returns a `ZStack` that overlays
526        // a hover/resize background behind the label — apps install a
527        // theme-wide `style_slots.table` or pass their own when wrapping
528        // the table to swap the chrome wholesale.
529        let style: SharedTableStyle = crate::styles::recipe_table_style::resolve_table_style(ctx);
530        let row_id = ctx.add(row);
531        // The gutter comes from the style, not from `cp::CELL_PADDING_*`: a
532        // preset writes its own on the recipe (Fluent's 12 dp `ListViewItem`
533        // gutter) and reading the module constant here rendered 8 dp whatever
534        // the theme had decided. The two views measure their filter zone from
535        // the same accessor, so the padding and the hit zone agree.
536        let tokens = ctx.theme().input;
537        let padded = ctx.add(
538            Padding::symmetric(
539                style.cell_padding_vertical(&tokens),
540                style.cell_padding_horizontal(&tokens),
541            )
542            .child(row_id),
543        );
544        let cell_cfg = TableHeaderCellConfig {
545            label: padded,
546            sort: self.current_sort.map(style_sort),
547            is_hovered: self.is_hovered.clone(),
548            is_resizing: self.is_resizing.clone(),
549        };
550        let cell_root = style.make_header_cell(&cell_cfg, ctx);
551        self.root_child_id = Some(cell_root);
552
553        // Build a single pointer-event handler covering: cursor hint,
554        // resize start (PointerDown in trailing zone), resize advance
555        // (PointerMove with active state), sort cycle (PointerDown
556        // outside the resize zone), and resize commit + capture release
557        // (PointerUp).
558        let sort_signal = self.sort_signal.clone();
559        let widths_signal = self.column_widths_signal.clone();
560        let frozen_widths = self.frozen_widths.clone();
561        let widths_handle = self.column_widths.clone();
562        let resize_state = self.resize_state.clone();
563        let resize_target = self.resize_target.clone();
564        let resize_preview_x = self.resize_preview_x.clone();
565        let resize_columns = self.resize_columns.clone();
566        let boundaries = self.pane_boundaries;
567        let col_id = self.col_id.clone();
568        let sortable = self.sortable;
569        let reorderable = self.reorderable;
570        let grip_base = self.resize_grip;
571        let policy = self.resize_policy;
572        let width_index = self.width_index;
573        let table_id = self.table_id;
574        let press_state: Rc<Cell<Option<PressState>>> = Rc::new(Cell::new(None));
575        let self_id = ctx.self_id();
576        let cell_window_x = self.cell_window_x.clone();
577        let cell_window_w = self.cell_window_w.clone();
578        let cell_window_h = self.cell_window_h.clone();
579        let filter_zone_w = self.filter_zone_width;
580        // The zone floor is a build-time read: a density change marks the tree
581        // at `BindingLevel::Rebuild`, so a dimension baked here cannot go stale.
582        let zone_floor = ctx.theme().input.target_size;
583        self.zone_floor.set(zone_floor);
584        let is_hovered = self.is_hovered.clone();
585        // A coarse pointer's column reorder waits for a hold; see the escalation
586        // arm in `PointerMove` for why the raw path cannot read
587        // `DragActivation` and what this stands in for.
588        let hold_armed: Rc<Cell<bool>> = Rc::new(Cell::new(false));
589        let hold_for_press = hold_armed.clone();
590
591        let handlers = HandlerSet::new()
592            .on_hover({
593                let is_hovered = is_hovered.clone();
594                move |entered, _ctx| {
595                    is_hovered.set(entered);
596                }
597            })
598            // Arms the coarse-pointer reorder. A `long_press` recognizer is the
599            // hold source this cell has: the reorder is a raw `start_drag` out
600            // of `PointerMove` rather than an `on_drag` member of the pointer
601            // sequence, so the tree resolves no `DragActivation` for it and
602            // nothing defers it on the cell's behalf, and nothing on
603            // `EventContext` hands a handler the input clock to time a hold
604            // itself.
605            //
606            // Two consequences, both load-bearing. Installing it gives the cell
607            // a gesture arena and so the implicit press capture, which is why
608            // the escalation arm below must refuse a coarse pointer that has not
609            // held — the cell now receives the moves of a pan that has left the
610            // strip entirely. And it makes the hold this cell's: a header cell
611            // has no context menu today, but anything that wants one has to
612            // share a gesture the column reorder has already claimed.
613            .on_long_press(move |_event, _ctx| {
614                hold_for_press.set(true);
615            })
616            .on_pointer_event(move |event, ctx: &mut EventContext| {
617                // Pointer events now deliver `position` in cell-local
618                // coords (the framework converts once at dispatch), so
619                // `local_x` is `position.x` directly. The resize math,
620                // however, wants a *window-space* x that stays stable
621                // across the relayouts a Live resize triggers (the cell's
622                // own leading edge moves under RTL) — reconstruct it as
623                // `position.x + cell_x0`, where `cell_x0` is the cell's
624                // window-space leading edge written by `place_children`
625                // (== this cell node's bounds origin).
626                let cell_x0 = cell_window_x.get();
627                // This cell's height, used to reject pointer events that
628                // bubble up from the in-tree filter popover (a descendant
629                // overlay anchored *below* the cell). Those arrive with a
630                // local y outside `[0, cell_h]`; the resize/cursor logic is
631                // x-only, so without this gate it paints a `ColResize`
632                // cursor across the entire popover.
633                let cell_h = cell_window_h.get();
634                // Prefer the width `place_children` actually laid this cell
635                // out at; fall back to the shared widths vector only before
636                // the first layout has run.
637                let cell_w = {
638                    let placed = cell_window_w.get();
639                    if placed > 0.0 {
640                        placed
641                    } else {
642                        widths_handle
643                            .borrow()
644                            .get(width_index)
645                            .copied()
646                            .unwrap_or(0.0)
647                    }
648                };
649                // Under RTL columns run right-to-left, so a cell's
650                // reading-order trailing edge is its physical-*left* one and
651                // the drag sign inverts. Read direction live.
652                let rtl = ctx.is_rtl();
653
654                // Resolve a cell-local x into the display slot of the column
655                // whose divider is being grabbed, if any. See the type docs
656                // for why a cell owns the grip on *both* of its edges.
657                let target_at = |local_x: f32| -> Option<usize> {
658                    if cell_w <= 0.0 {
659                        return None;
660                    }
661                    // Never let the two half-grips eat more than half the
662                    // cell: a column dragged down to a tiny `min_width` must
663                    // keep a central band for sort / reorder, or it becomes
664                    // permanently un-sortable and un-draggable.
665                    let grip = grip_base.min(cell_w * 0.25);
666                    if grip <= 0.0 {
667                        return None;
668                    }
669                    let near_physical_leading = local_x <= grip;
670                    let near_physical_trailing = local_x >= cell_w - grip;
671                    let (on_own_edge, on_predecessor_edge) = if rtl {
672                        (near_physical_leading, near_physical_trailing)
673                    } else {
674                        (near_physical_trailing, near_physical_leading)
675                    };
676                    if on_own_edge && resize_columns.get(width_index).is_some_and(|c| c.resizable) {
677                        return Some(width_index);
678                    }
679                    if on_predecessor_edge && width_index > 0 {
680                        let prev = width_index - 1;
681                        // A pane seam is not a column divider: with the
682                        // Middle pane scrolled, the column on its far side
683                        // is not the one visually adjacent to it.
684                        if pane_of(prev, boundaries) == pane_of(width_index, boundaries)
685                            && resize_columns.get(prev).is_some_and(|c| c.resizable)
686                        {
687                            return Some(prev);
688                        }
689                    }
690                    None
691                };
692
693                match event {
694                    WidgetEvent::PointerMove { position, .. } => {
695                        let local_x = position.x;
696                        // 1. Active resize: advance regardless of pointer
697                        //    location (the pointer is captured). Only the
698                        //    cell that anchored the gesture may advance it —
699                        //    an event reaching any other cell means the
700                        //    capture was lost, and moving a column then would
701                        //    look like the table resizing itself with no
702                        //    button held.
703                        // …and only while this cell still *owns* the press:
704                        // capture is an arbitration act, so a grip that lost it
705                        // must stop resizing even though its own state is set.
706                        let active = resize_state.borrow().clone();
707                        if let Some(state) = active
708                            && state.anchor_index == width_index
709                            && ctx.owns_pointer()
710                        {
711                            // Window-space delta — stable across the
712                            // relayouts a Live resize triggers (see
713                            // ResizeState::start_pointer_x). Reconstruct
714                            // window x from the now cell-local position.
715                            let delta = position.x + cell_x0 - state.start_pointer_x;
716                            let signed = if rtl { -delta } else { delta };
717                            let new_w = clamp_width(
718                                state.start_width + signed,
719                                state.min_width,
720                                state.max_width,
721                            );
722                            match policy {
723                                ColumnResizePolicy::Live => {
724                                    commit_resize(
725                                        &widths_signal,
726                                        frozen_widths.as_ref(),
727                                        &widths_handle,
728                                        &resize_columns,
729                                        state.target_index,
730                                        new_w,
731                                    );
732                                }
733                                ColumnResizePolicy::OnRelease => {
734                                    // Nothing moves until release, so show
735                                    // where the divider would land.
736                                    let dir = if rtl { -1.0 } else { 1.0 };
737                                    resize_preview_x.set(Some(
738                                        state.start_divider_x + (new_w - state.start_width) * dir,
739                                    ));
740                                }
741                            }
742                            // Hold the resize shape for the whole drag: the
743                            // pointer is captured, so it can travel far
744                            // outside the grip (and outside the header) and
745                            // must not look like it stopped resizing.
746                            ctx.set_cursor(CursorIcon::ColResize);
747                            return EventResponse::Handled;
748                        }
749                        // 2. Press state set, no resize: if movement
750                        //    crosses the threshold, escalate to a
751                        //    reorder drag.
752                        //    A coarse pointer must have held first: a
753                        //    horizontal swipe along the strip is a horizontal
754                        //    pan, and at 5 dp the reorder used to take it
755                        //    (measured: `max_scroll_x` 512, `scroll_x` 0, and
756                        //    the columns swapped). A held contact is picking the
757                        //    column up, which is the same ruling the row reorder
758                        //    settles with `DragActivation` — reached here by a
759                        //    `long_press` recognizer, because a raw `start_drag`
760                        //    is not a sequence member and has no activation to
761                        //    resolve.
762                        if reorderable
763                            && (hold_armed.get() || !ctx.pointer_kind().is_coarse())
764                            && let Some(p) = press_state.get()
765                        {
766                            let dx = local_x - p.pointer_x;
767                            let dy = position.y - p.pointer_y;
768                            if (dx * dx + dy * dy).sqrt() > DRAG_REORDER_THRESHOLD {
769                                press_state.set(None);
770                                let payload = DragPayload::typed(ColumnReorderDragData {
771                                    col_id: col_id.clone(),
772                                    source_table_id: table_id,
773                                });
774                                ctx.start_drag(self_id, payload);
775                                return EventResponse::Handled;
776                            }
777                        }
778                        // 3. Cursor hint over either grip. Outside them we
779                        //    explicitly reset to Default so the cursor shape
780                        //    doesn't stay stuck on `ColResize` after the
781                        //    pointer moves off the handle (PointerLeave alone
782                        //    can't rescue this — it never fires while the
783                        //    pointer stays inside the same cell, and the
784                        //    node-level `Default` cursor installed below is
785                        //    only restored on leave).
786                        // Only manage the cursor for moves that are
787                        // genuinely over this cell. Moves bubbling up from
788                        // the filter popover (out-of-cell y) must leave the
789                        // cursor alone, or the x-only grip test below paints
790                        // `ColResize` across the popover.
791                        let in_cell_y =
792                            cell_h <= 0.0 || (position.y >= 0.0 && position.y <= cell_h);
793                        if in_cell_y {
794                            if target_at(local_x).is_some() {
795                                ctx.set_cursor(CursorIcon::ColResize);
796                                return EventResponse::Handled;
797                            }
798                            ctx.set_cursor(CursorIcon::Default);
799                        }
800                        EventResponse::Ignored
801                    }
802                    WidgetEvent::PointerDown {
803                        position,
804                        button: PointerButton::Primary,
805                        ..
806                    } => {
807                        // Ignore presses bubbling up from the filter popover
808                        // (out-of-cell y) — they must not record a header
809                        // press / sort cycle.
810                        if cell_h > 0.0 && (position.y < 0.0 || position.y > cell_h) {
811                            return EventResponse::Ignored;
812                        }
813                        let local_x = position.x;
814                        if let Some(target) = target_at(local_x) {
815                            let info = &resize_columns[target];
816                            let start_width =
817                                widths_handle.borrow().get(target).copied().unwrap_or(0.0);
818                            // Window x of the divider under the pointer: this
819                            // cell's own trailing edge when resizing itself,
820                            // its leading edge when resizing the predecessor
821                            // — mirrored under RTL.
822                            let resizing_self = target == width_index;
823                            let own_edge_is_physical_leading = rtl;
824                            let on_physical_leading = if resizing_self {
825                                own_edge_is_physical_leading
826                            } else {
827                                !own_edge_is_physical_leading
828                            };
829                            let start_divider_x = if on_physical_leading {
830                                cell_x0
831                            } else {
832                                cell_x0 + cell_w
833                            };
834                            *resize_state.borrow_mut() = Some(ResizeState {
835                                col_id: info.id.clone(),
836                                target_index: target,
837                                anchor_index: width_index,
838                                start_pointer_x: position.x + cell_x0,
839                                start_width,
840                                start_divider_x,
841                                min_width: info.min_width,
842                                max_width: info.max_width,
843                            });
844                            resize_target.set(Some(target));
845                            ctx.set_cursor(CursorIcon::ColResize);
846                            ctx.capture_pointer();
847                            return EventResponse::Handled;
848                        }
849                        // Filter-popover trigger zone — leave it alone
850                        // so the Popover's gesture-based tap handler
851                        // running in the bubble pass can fire. Without
852                        // this carve-out, the preview-pass handler
853                        // would consume PointerDown and the popover
854                        // would never open. The filter glyph sits at the
855                        // trailing inner edge — physical-right under LTR,
856                        // physical-left under RTL (the header HStack
857                        // reverses), just inside the resize handle.
858                        let in_filter_zone = header_cell_zones(
859                            Rect::new(0.0, 0.0, cell_w, cell_h),
860                            filter_zone_w,
861                            grip_base,
862                            zone_floor,
863                            rtl,
864                        )
865                        .is_some_and(|[_, filter]| {
866                            local_x >= filter.x && local_x <= filter.right()
867                        });
868                        if in_filter_zone {
869                            return EventResponse::Ignored;
870                        }
871                        // Record press: PointerUp without movement →
872                        // sort cycle; PointerMove past threshold →
873                        // reorder drag. A fresh press has not held yet,
874                        // whatever the last one did.
875                        hold_armed.set(false);
876                        press_state.set(Some(PressState {
877                            pointer_x: local_x,
878                            pointer_y: position.y,
879                        }));
880                        // **`Ignored`, deliberately, and it is what lets a
881                        // finger scroll the table from its header strip.**
882                        //
883                        // This handler runs in the root-first preview pass for
884                        // every press that lands on one of the cell's own
885                        // children (its label, its indicators), and the first
886                        // `Handled` there is step 1 of the decision procedure:
887                        // the router reads it as a preview claim and *decides
888                        // the sequence* for this cell. A decided sequence yields
889                        // no candidates, so the table's own `PanClaim` was never
890                        // evaluated and neither axis moved — measured, on the
891                        // same 120 dp pan from the header of a table scrolled to
892                        // 400: offset stayed 400 with `Handled`, reached 557
893                        // with `Ignored`. It was not the reorder drag: the same
894                        // 0 was measured with every column `reorderable(false)`.
895                        //
896                        // Nothing needed the claim. The two things this cell
897                        // commits are the sort (which rides `press_state` and
898                        // the framework's own press record) and the reorder
899                        // (which escalates from `PointerMove`); the resize grip
900                        // above still answers `Handled` and takes an explicit
901                        // capture of its own.
902                        EventResponse::Ignored
903                    }
904                    WidgetEvent::PointerUp { position, .. } => {
905                        // Resize commit / release. Delta is window-space
906                        // (reconstruct window x from cell-local position).
907                        let taken = resize_state.borrow_mut().take();
908                        if let Some(state) = taken {
909                            // Only the anchoring cell commits. A mismatch
910                            // means the capture was lost mid-gesture and this
911                            // Up landed on a bystander by hit-test — clear the
912                            // orphaned state so the next press starts clean,
913                            // but never write a width from the wrong origin.
914                            if state.anchor_index == width_index {
915                                if policy == ColumnResizePolicy::OnRelease {
916                                    let delta = position.x + cell_x0 - state.start_pointer_x;
917                                    let signed = if rtl { -delta } else { delta };
918                                    let new_w = clamp_width(
919                                        state.start_width + signed,
920                                        state.min_width,
921                                        state.max_width,
922                                    );
923                                    commit_resize(
924                                        &widths_signal,
925                                        frozen_widths.as_ref(),
926                                        &widths_handle,
927                                        &resize_columns,
928                                        state.target_index,
929                                        new_w,
930                                    );
931                                }
932                                resize_target.set(None);
933                                resize_preview_x.set(None);
934                                ctx.release_pointer();
935                                return EventResponse::Handled;
936                            }
937                            resize_target.set(None);
938                            resize_preview_x.set(None);
939                        }
940                        // Click without significant movement → sort cycle,
941                        // and the `Down` arm's comment promises exactly that.
942                        //
943                        // Both halves come from
944                        // [`release_completes_the_press`]. A press is closed
945                        // when a peer claim takes it — which is how a header
946                        // press that a scrollable's `PanClaim` won stops sorting
947                        // the table it just scrolled — *and* when the pointer
948                        // leaves the press's `TapBoundary`, which is the
949                        // movement check, sized for the pointer holding it: a
950                        // `tap_slop` radius for a mouse (5 dp — the same figure
951                        // this cell's reorder threshold uses), the pressed
952                        // node's own bounds for a finger. Measuring it here as
953                        // well, against `PressState`, would be a second
954                        // hardcoded radius for the framework's own question, and
955                        // no case in the suite can tell the two apart.
956                        //
957                        // That delegation needs the press to *have* an owner,
958                        // which it has because the `long_press` recognizer above
959                        // gives this cell a gesture arena. Removing that
960                        // recognizer without restoring a local distance check
961                        // would leave the sort firing on a press dragged across
962                        // the window;
963                        // `a_mouse_click_on_a_column_header_sorts_it_and_a_wander_off_does_not`
964                        // is what fails if anyone does.
965                        //
966                        // [`release_completes_the_press`]: crate::data_views::release_completes_the_press
967                        let recorded_press = press_state.replace(None).is_some();
968                        if recorded_press
969                            && sortable
970                            && crate::data_views::release_completes_the_press(ctx)
971                        {
972                            let next = match sort_signal.get() {
973                                None => Some((col_id.clone(), SortDirection::Ascending)),
974                                Some((id, SortDirection::Ascending)) if id == col_id => {
975                                    Some((col_id.clone(), SortDirection::Descending))
976                                }
977                                Some((id, SortDirection::Descending)) if id == col_id => None,
978                                Some(_) => Some((col_id.clone(), SortDirection::Ascending)),
979                            };
980                            sort_signal.set(next);
981                            return EventResponse::Handled;
982                        }
983                        EventResponse::Ignored
984                    }
985                    _ => EventResponse::Ignored,
986                }
987            })
988            // Assistive-technology path to the resize grip. Pointer-only
989            // resize is unreachable by a screen reader, switch access, or the
990            // automation MCP (whose action tools route through AccessKit), so
991            // a resizable column advertises Increment / Decrement and steps
992            // its width by `COLUMN_RESIZE_STEP` — clamped exactly like a drag,
993            // so the two paths cannot disagree about the stored override.
994            .on_access_action({
995                let resize_columns = self.resize_columns.clone();
996                let widths_handle = self.column_widths.clone();
997                let widths_signal = self.column_widths_signal.clone();
998                let frozen_widths = self.frozen_widths.clone();
999                move |action, _ctx| {
1000                    use teksilo_core::accesskit::Action;
1001                    if !matches!(action, Action::Increment | Action::Decrement) {
1002                        return EventResponse::Ignored;
1003                    }
1004                    let Some(info) = resize_columns.get(width_index) else {
1005                        return EventResponse::Ignored;
1006                    };
1007                    if !info.resizable {
1008                        return EventResponse::Ignored;
1009                    }
1010                    let current = widths_handle
1011                        .borrow()
1012                        .get(width_index)
1013                        .copied()
1014                        .unwrap_or(0.0);
1015                    if current <= 0.0 {
1016                        return EventResponse::Ignored;
1017                    }
1018                    let step = if matches!(action, Action::Increment) {
1019                        crate::styles::recipe_table_style::COLUMN_RESIZE_STEP
1020                    } else {
1021                        -crate::styles::recipe_table_style::COLUMN_RESIZE_STEP
1022                    };
1023                    let next = clamp_width(current + step, info.min_width, info.max_width);
1024                    commit_resize(
1025                        &widths_signal,
1026                        frozen_widths.as_ref(),
1027                        &widths_handle,
1028                        &resize_columns,
1029                        width_index,
1030                        next,
1031                    );
1032                    EventResponse::Handled
1033                }
1034            })
1035            // Default node cursor — the framework restores this on
1036            // PointerLeave, which guarantees the resize cursor doesn't
1037            // bleed across cells when the pointer exits HeaderCell from
1038            // inside the resize zone (where on_pointer_event last set
1039            // it to `ColResize`).
1040            .cursor(CursorIcon::Default)
1041            .focusable(false);
1042        ctx.apply_self_handlers(handlers);
1043
1044        vec![cell_root]
1045    }
1046
1047    fn layout_response(
1048        &self,
1049        proposal: SizeProposal,
1050        ctx: &LayoutContext,
1051    ) -> teksilo_core::widget::LayoutResponse {
1052        match self.root_child_id {
1053            Some(id) => ctx
1054                .child_size(id, proposal)
1055                .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
1056            None => proposal.resolve(0.0, 0.0),
1057        }
1058        .into()
1059    }
1060
1061    fn place_children(
1062        &self,
1063        bounds: Rect,
1064        _proposal: SizeProposal,
1065        children: &mut [WidgetPlacement],
1066        ctx: &LayoutContext,
1067    ) {
1068        // Snapshot the cell's window-space physical-left edge so the
1069        // pointer-event handler can convert window x to cell-local x
1070        // (`local_x ∈ [0, cell_w]` in both directions), plus the placed
1071        // size the grip test runs against.
1072        self.cell_window_x.set(bounds.x);
1073        self.cell_window_w.set(bounds.width);
1074        self.cell_window_h.set(bounds.height);
1075        // `target_regions` is handed a rectangle and no context, so the
1076        // direction it must partition in is recorded here.
1077        self.zone_rtl.set(ctx.is_rtl());
1078        for child in children.iter_mut() {
1079            child.origin = bounds.origin();
1080            child.size = bounds.size();
1081        }
1082    }
1083
1084    /// The label zone, the filter zone, and the resize band on the cell's
1085    /// reading-order trailing edge.
1086    ///
1087    /// All three are geometry inside one node — the cell's chrome is one
1088    /// composed subtree and its parts are told apart by coordinate at press
1089    /// time — so without this report the filter affordance and the grip do not
1090    /// exist to anything outside the press handler: not to a conformance audit,
1091    /// and not to a router that would route a coarse press to the nearest
1092    /// target. The rectangles come from [`header_cell_zones`], the same
1093    /// function the press test asks, so the report and the routing cannot
1094    /// drift.
1095    ///
1096    /// A cell with no filter affordance reports nothing: its one target is its
1097    /// own node, which every consumer can already see.
1098    fn target_regions(&self, bounds: Rect) -> Vec<teksilo_core::partition::TargetRegion> {
1099        use teksilo_core::partition::TargetRegion;
1100        let Some([label, filter]) = header_cell_zones(
1101            bounds,
1102            self.filter_zone_width,
1103            self.resize_grip,
1104            self.zone_floor.get(),
1105            self.zone_rtl.get(),
1106        ) else {
1107            return Vec::new();
1108        };
1109        let grip = if self.zone_rtl.get() {
1110            Rect::new(bounds.x, bounds.y, self.resize_grip, bounds.height)
1111        } else {
1112            Rect::new(
1113                bounds.right() - self.resize_grip,
1114                bounds.y,
1115                self.resize_grip,
1116                bounds.height,
1117            )
1118        };
1119        vec![
1120            TargetRegion::target(label, HEADER_PART_LABEL),
1121            TargetRegion::target(filter, HEADER_PART_FILTER),
1122            TargetRegion::grab(grip, HEADER_PART_RESIZE),
1123        ]
1124    }
1125
1126    // No `paint()` — the cell's visual chrome is composed via
1127    // `TableStyle::make_header_cell`, layered behind the label inside
1128    // a `ZStack`. The outer `TableHeader` paints the shared `Raised`
1129    // background for the whole strip, so a transparent cell default
1130    // (`SurfaceRole::Transparent`) lets the row chrome show through
1131    // while hover / resize overlays come from the composed body.
1132
1133    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1134        builder.set_role(teksilo_core::accesskit::Role::ColumnHeader);
1135        builder.set_name(self.label.clone());
1136        // Advertise the resize grip to assistive technology. Deliberately
1137        // actions only, with no `numeric_value` / range: a value on a
1138        // `ColumnHeader` would be read out on every ordinary pass over the
1139        // table, trading a noisier common case for a rarer one. The actions
1140        // are invocable without being announced.
1141        if self
1142            .resize_columns
1143            .get(self.width_index)
1144            .is_some_and(|c| c.resizable)
1145        {
1146            builder.add_action(teksilo_core::accesskit::Action::Increment);
1147            builder.add_action(teksilo_core::accesskit::Action::Decrement);
1148        }
1149        // Through the typed wrapper, which converts the ARIA 1-based index to
1150        // AccessKit's zero-based `column_index`. `inner_mut()` would not.
1151        builder.set_column_index(self.col_index_1based);
1152        let n = builder.inner_mut();
1153        if let Some(dir) = self.current_sort {
1154            let ak_dir = match dir {
1155                SortDirection::Ascending => teksilo_core::accesskit::SortDirection::Ascending,
1156                SortDirection::Descending => teksilo_core::accesskit::SortDirection::Descending,
1157            };
1158            n.set_sort_direction(ak_dir);
1159        }
1160    }
1161
1162    fn children(&self) -> Vec<WidgetId> {
1163        self.root_child_id.into_iter().collect()
1164    }
1165}
1166
1167/// Commit a user resize of the column at display slot `target` to `new_w`.
1168///
1169/// Writes the target's override and, in the **same** update, freezes every
1170/// un-overridden `Flex` column that *precedes* it in display order at its
1171/// current resolved width. Without that, the solver shares the leftover the
1172/// resize consumed (or freed) among every flex column — the ones before the
1173/// target included — so the target's leading edge slides the other way and
1174/// the grabbed divider tracks the pointer at a fraction of its speed
1175/// (`1 − p/m`, for `p` of the `m` remaining flex weights ahead of it), or not
1176/// at all once every remaining flex column is ahead of it. What the user then
1177/// sees is the dividers on the far side of the grip sweeping *against* the
1178/// mouse while the grabbed one barely moves.
1179///
1180/// Every desktop table keeps the columns before a dragged divider where they
1181/// are and reflows only the ones after it (NSTableView's column autoresizing,
1182/// `QHeaderView`'s interactive sections with a stretch-last). Freezing the
1183/// preceding flex columns is what makes that hold here. Columns *after* the
1184/// target are left alone on purpose: they are the ones meant to absorb the
1185/// change, and once none of them can, the pane overflows into horizontal
1186/// scroll rather than moving the divider away from the pointer.
1187///
1188/// Where the freezes go depends on whose map `signal` is:
1189///
1190/// - The view's own (`frozen` is `None`): into the map, with the target. It
1191///   then records exactly what is on screen, so a persisted
1192///   `column_widths_signal` keeps matching the picture.
1193/// - An adopted one (`frozen` is `Some`): into `frozen`, which only this view
1194///   reads, and the map gets the target alone. An adopted map may be shared
1195///   by views of other widths, and a width frozen here is not the width the
1196///   column has there: written into the map, it would stop that view's flex
1197///   column flexing too, and leave it a gap or an overflow nobody asked for.
1198///
1199/// The frozen values come from the widths `place_children` last resolved —
1200/// already clamped to each column's `[min, max]` — so re-resolving them as
1201/// overrides changes nothing visible.
1202fn commit_resize(
1203    signal: &Signal<HashMap<String, f32>>,
1204    frozen: Option<&SharedFrozenWidths>,
1205    resolved: &SharedColumnWidths,
1206    columns: &ColumnResizeTable,
1207    target: usize,
1208    new_w: f32,
1209) {
1210    let mut m = signal.get();
1211    let own = || columns.iter().map(|c| c.id.as_str());
1212    let mut kept = frozen.map(|f| f.borrow_mut());
1213    if let Some(kept) = kept.as_mut()
1214        && !kept.hold(&m, own())
1215    {
1216        // The view has been laid out from the map alone since it changed, so
1217        // what is on screen is what to freeze from.
1218        kept.frozen.clear();
1219    }
1220    {
1221        let widths = resolved.borrow();
1222        for (slot, info) in columns.iter().enumerate().take(target) {
1223            let Some(&w) = widths.get(slot) else {
1224                continue;
1225            };
1226            if !info.flex || m.contains_key(&info.id) {
1227                continue;
1228            }
1229            match kept.as_mut() {
1230                Some(kept) => {
1231                    kept.frozen.entry(info.id.clone()).or_insert(w);
1232                }
1233                None => {
1234                    m.insert(info.id.clone(), w);
1235                }
1236            }
1237        }
1238    }
1239    if let Some(info) = columns.get(target) {
1240        m.insert(info.id.clone(), new_w);
1241    }
1242    if let Some(kept) = kept.as_mut() {
1243        kept.written = own()
1244            .filter_map(|id| m.get(id).map(|&w| (id.to_string(), w)))
1245            .collect();
1246    }
1247    // Released before the write, which notifies observers synchronously.
1248    drop(kept);
1249    signal.set(m);
1250}
1251
1252/// The widths a resize froze in a view whose width map is adopted, kept out
1253/// of that map — see [`commit_resize`].
1254#[derive(Debug, Default)]
1255pub(crate) struct FrozenWidths {
1256    /// Column id → the width it was frozen at.
1257    frozen: HashMap<String, f32>,
1258    /// This view's own entries in the map just after its last resize.
1259    written: HashMap<String, f32>,
1260}
1261
1262/// One view's [`FrozenWidths`], shared by its header cells and its layout.
1263pub(crate) type SharedFrozenWidths = Rc<RefCell<FrozenWidths>>;
1264
1265impl FrozenWidths {
1266    /// Whether the freezes still apply: the map gives the view's columns
1267    /// (`own`) exactly what its last resize left there. Any other write to
1268    /// one of them (a reset, a setter, another view resizing a column the two
1269    /// share) puts the view back on the map alone. A write to a column the
1270    /// view lacks does not.
1271    fn hold<'a>(&self, map: &HashMap<String, f32>, mut own: impl Iterator<Item = &'a str>) -> bool {
1272        own.all(|id| map.get(id) == self.written.get(id))
1273    }
1274}
1275
1276/// The width overrides a view resolves its columns from: `map`, plus the
1277/// widths `frozen` holds for it while they still apply. An entry in `map`
1278/// wins over a frozen one.
1279pub(crate) fn width_overrides<'a>(
1280    map: HashMap<String, f32>,
1281    frozen: Option<&SharedFrozenWidths>,
1282    own: impl Iterator<Item = &'a str>,
1283) -> HashMap<String, f32> {
1284    let Some(frozen) = frozen else {
1285        return map;
1286    };
1287    let frozen = frozen.borrow();
1288    if frozen.frozen.is_empty() || !frozen.hold(&map, own) {
1289        return map;
1290    }
1291    let mut out = map;
1292    for (id, &w) in &frozen.frozen {
1293        out.entry(id.clone()).or_insert(w);
1294    }
1295    out
1296}
1297
1298/// Tiny chevron drawn as a triangle path.
1299#[derive(Debug)]
1300struct SortIndicator {
1301    direction: Option<SortDirection>,
1302    size: f32,
1303}
1304
1305impl SortIndicator {
1306    fn new(direction: Option<SortDirection>, size: f32) -> Self {
1307        Self { direction, size }
1308    }
1309}
1310
1311impl Widget for SortIndicator {
1312    fn layout_response(
1313        &self,
1314        _proposal: SizeProposal,
1315        _ctx: &LayoutContext,
1316    ) -> teksilo_core::widget::LayoutResponse {
1317        Size::new(self.size, self.size).into()
1318    }
1319
1320    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
1321        let Some(dir) = self.direction else {
1322            return;
1323        };
1324        let color = TextRole::Accent.resolve(&ctx.theme.colors);
1325        let cx = bounds.x + bounds.width / 2.0;
1326        let pad = bounds.height * 0.15;
1327        let top_y = bounds.y + pad;
1328        let bot_y = bounds.y + bounds.height - pad;
1329        let half_w = bounds.width / 2.0 - pad;
1330        let mut path = Path::new();
1331        match dir {
1332            SortDirection::Ascending => {
1333                path.move_to(Point::new(cx, top_y));
1334                path.line_to(Point::new(cx + half_w, bot_y));
1335                path.line_to(Point::new(cx - half_w, bot_y));
1336                path.close();
1337            }
1338            SortDirection::Descending => {
1339                path.move_to(Point::new(cx - half_w, top_y));
1340                path.line_to(Point::new(cx + half_w, top_y));
1341                path.line_to(Point::new(cx, bot_y));
1342                path.close();
1343            }
1344        }
1345        canvas.fill_path(&path, color);
1346    }
1347
1348    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1349        builder.set_hidden();
1350    }
1351}