Skip to main content

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}