teksilo_widgets/table_view/imperative.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Imperative-API helpers shared by [`TableView`](crate::TableView) and
5//! [`TreeTableView`](crate::TreeTableView).
6//!
7//! Both widgets expose the same "drive the table from code" surface — scroll to
8//! a row, override a column width, pin a column, open a cell editor. The public
9//! methods stay inherent on each widget (that is the discoverable API), but the
10//! *logic* lives here once, so a fix lands on both instead of drifting.
11//!
12//! Everything takes the widget's signals/handles as parameters rather than the
13//! widget itself, which keeps this module free of both concrete types.
14
15use std::collections::HashMap;
16
17use teksilo_core::signal::Signal;
18
19use crate::common::row_metrics::SharedRowMetrics;
20use crate::table_view::column::{Column, PinnedSide};
21use crate::table_view::selection::CellSelectionModel;
22
23/// Scroll so that `row` is aligned to the top of the viewport.
24///
25/// A no-op before the first layout pass: `max_scroll_y` is still `0`, so the
26/// clamp collapses any target to `0`.
27pub(crate) fn scroll_to_row(
28 row: usize,
29 row_metrics: &SharedRowMetrics,
30 scroll_y: &Signal<f32>,
31 max_scroll_y: &Signal<f32>,
32) {
33 // `try_borrow_mut`: the metrics cell is also borrowed during layout and by
34 // `row_height_fn`, so a call from inside a cell delegate or an activation
35 // handler could re-enter. Skipping the scroll beats panicking.
36 let Ok(mut metrics) = row_metrics.try_borrow_mut() else {
37 return;
38 };
39 let target = metrics.row_top(row);
40 drop(metrics);
41 let max = max_scroll_y.get();
42 scroll_y.set(target.clamp(0.0, max));
43}
44
45/// Scroll the minimum distance needed to make `row` visible.
46///
47/// A no-op before the first layout pass — `viewport_height` still holds its
48/// construction placeholder then, so the computed offset would be measured
49/// against a viewport that was never laid out.
50pub(crate) fn ensure_row_visible(
51 row: usize,
52 row_metrics: &SharedRowMetrics,
53 scroll_y: &Signal<f32>,
54 max_scroll_y: &Signal<f32>,
55 viewport_height: f32,
56 laid_out: bool,
57) {
58 if !laid_out {
59 return;
60 }
61 let Ok(mut metrics) = row_metrics.try_borrow_mut() else {
62 return;
63 };
64 let scroll = scroll_y.get();
65 let new_scroll =
66 metrics.scroll_for_ensure_visible(row, scroll, viewport_height, max_scroll_y.get());
67 drop(metrics);
68 if (new_scroll - scroll).abs() > f32::EPSILON {
69 scroll_y.set(new_scroll);
70 }
71}
72
73/// Set or remove a single column's user-resized width override. A non-positive
74/// or non-finite `width` removes the entry, reverting the column to its
75/// declared width policy.
76///
77/// The value is stored verbatim — it is the app stating a preference, not a
78/// drag position — and re-clamped to the column's `[min_width, max_width]`
79/// every time
80/// [`ColumnSolver::resolve_in_order`](super::layout::ColumnSolver::resolve_in_order)
81/// runs. So an override outside those bounds renders clamped while surviving
82/// intact in the signal, ready to take effect if the column's bounds later
83/// widen. (The *drag* path deliberately clamps before writing, so
84/// `column_widths_signal` always mirrors what the user sees the table do.)
85pub(crate) fn set_column_width(signal: &Signal<HashMap<String, f32>>, col_id: &str, width: f32) {
86 let mut m = signal.get();
87 let changed = if width.is_finite() && width > 0.0 {
88 m.insert(col_id.to_string(), width) != Some(width)
89 } else {
90 m.remove(col_id).is_some()
91 };
92 // Equality-guarded — see `set_column_widths`.
93 if changed {
94 signal.set(m);
95 }
96}
97
98/// Replace the whole width-override map, **only if it actually differs**.
99///
100/// The guard is load-bearing, not an optimisation. The documented persistence
101/// shape (docs/table-view.md, "Persistence") observes the settings signal into
102/// the table and the table's signal back into settings; `Signal::set` carries
103/// no equality check by design, so an unguarded write here closes that pair
104/// into an unbounded mutual recursion — a `NotifyDepthGuard` panic in debug, a
105/// stack overflow in release — on the very first `PointerMove` of a
106/// `ColumnResizePolicy::Live` drag, which writes a width on every tick.
107pub(crate) fn set_column_widths(
108 signal: &Signal<HashMap<String, f32>>,
109 widths: HashMap<String, f32>,
110) {
111 if signal.get() != widths {
112 signal.set(widths);
113 }
114}
115
116/// Put a single column on `side`, where `declared` is the side the column
117/// declares ([`Column::pinned`]; `PinnedSide::None` for an id the view does
118/// not declare).
119///
120/// The rule a header drag follows: the map holds an entry only for a column
121/// that is not on its declared side, [`PinnedSide::None`] included, so
122/// unpinning a column declared pinned writes `None` and putting a column
123/// back on its declared side removes its entry.
124pub(crate) fn set_column_pinning(
125 signal: &Signal<HashMap<String, PinnedSide>>,
126 col_id: &str,
127 side: PinnedSide,
128 declared: PinnedSide,
129) {
130 let mut m = signal.get();
131 let changed = if side == declared {
132 m.remove(col_id).is_some()
133 } else {
134 m.insert(col_id.to_string(), side) != Some(side)
135 };
136 // Equality-guarded — see `set_column_widths`.
137 if changed {
138 signal.set(m);
139 }
140}
141
142/// Set or clear the filter text for a single column. An empty `text` removes
143/// the entry.
144pub(crate) fn set_filter(signal: &Signal<HashMap<String, String>>, col_id: &str, text: &str) {
145 let mut m = signal.get();
146 let changed = if text.is_empty() {
147 m.remove(col_id).is_some()
148 } else {
149 m.insert(col_id.to_string(), text.to_string()).as_deref() != Some(text)
150 };
151 // Equality-guarded — see `set_column_widths`.
152 if changed {
153 signal.set(m);
154 }
155}
156
157/// Replace a whole `Signal<T>`-held layout value, **only if it differs**.
158///
159/// The generic sibling of [`set_column_widths`] for the remaining persisted
160/// layout signals (sort, filters, order). Same rationale: the documented
161/// settings round trip observes in both directions, and `Signal::set` has no
162/// equality check of its own.
163pub(crate) fn set_if_changed<T: Clone + PartialEq + 'static>(signal: &Signal<T>, next: T) {
164 if signal.get() != next {
165 signal.set(next);
166 }
167}
168
169/// Resolve `(row, col_id)` to a `(row, display_position)` edit target.
170///
171/// Returns `None` — leaving any existing editor untouched — when `col_id` is
172/// not a declared column, when it is not currently displayed, or when `row` is
173/// outside the visible range. Without the row check an out-of-range
174/// `begin_edit` would strand `editing_cell` on a row that can never match,
175/// which nothing but an explicit `end_edit` would clear.
176pub(crate) fn resolve_edit_target<T: 'static>(
177 row: usize,
178 col_id: &str,
179 columns: &[Column<T>],
180 display_indices: &[usize],
181 row_count: usize,
182) -> Option<(usize, usize)> {
183 if row >= row_count {
184 return None;
185 }
186 let decl_index = columns.iter().position(|c| c.spec.id == col_id)?;
187 let display_pos = display_indices.iter().position(|&i| i == decl_index)?;
188 Some((row, display_pos))
189}
190
191/// Remap `focused_cell` / `editing_cell` / an optional `cell_selection`'s
192/// stored `(row, display_pos)` pairs through `old_to_new` — indexed by the
193/// display position they were computed against *before* a column reorder or
194/// pin-toggle rebuild, each entry giving that column's position under the
195/// *new* order, or `None` if the column dropped out of the visible set.
196///
197/// Both views recompute display order on every rebuild but only key it by
198/// stable column identity for the columns themselves — the display-position
199/// pairs a caller stashed in `focused_cell` (keyboard focus), `editing_cell`
200/// (an open F2 editor), or a cell-selection rectangle are otherwise left
201/// pointing at whatever column now sits at that position, silently
202/// relabeling onto the wrong data. `old_to_new` being the identity
203/// permutation (the common case: a rebuild triggered by something other
204/// than order/pinning) makes every remap here a no-op.
205pub(crate) fn remap_cell_state(
206 focused_cell: &Signal<Option<(usize, usize)>>,
207 editing_cell: &Signal<Option<(usize, usize)>>,
208 cell_selection: Option<&CellSelectionModel>,
209 old_to_new: &[Option<usize>],
210) {
211 let remap = |cell: Option<(usize, usize)>| {
212 cell.and_then(|(row, col)| old_to_new.get(col).copied().flatten().map(|nc| (row, nc)))
213 };
214 let old_focus = focused_cell.get();
215 let new_focus = remap(old_focus);
216 if new_focus != old_focus {
217 focused_cell.set(new_focus);
218 }
219 let old_edit = editing_cell.get();
220 let new_edit = remap(old_edit);
221 if new_edit != old_edit {
222 editing_cell.set(new_edit);
223 }
224 if let Some(cs) = cell_selection {
225 cs.remap_columns(old_to_new);
226 }
227}