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