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}