Skip to main content

teksilo_widgets/table_view/
header.rs

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