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/// Pin or unpin a single column. [`PinnedSide::None`] removes the override,
117/// reverting the column to its declared [`Column::pinned`].
118pub(crate) fn set_column_pinning(
119    signal: &Signal<HashMap<String, PinnedSide>>,
120    col_id: &str,
121    side: PinnedSide,
122) {
123    let mut m = signal.get();
124    let changed = if matches!(side, PinnedSide::None) {
125        m.remove(col_id).is_some()
126    } else {
127        m.insert(col_id.to_string(), side) != Some(side)
128    };
129    // Equality-guarded — see `set_column_widths`.
130    if changed {
131        signal.set(m);
132    }
133}
134
135/// Set or clear the filter text for a single column. An empty `text` removes
136/// the entry.
137pub(crate) fn set_filter(signal: &Signal<HashMap<String, String>>, col_id: &str, text: &str) {
138    let mut m = signal.get();
139    let changed = if text.is_empty() {
140        m.remove(col_id).is_some()
141    } else {
142        m.insert(col_id.to_string(), text.to_string()).as_deref() != Some(text)
143    };
144    // Equality-guarded — see `set_column_widths`.
145    if changed {
146        signal.set(m);
147    }
148}
149
150/// Replace a whole `Signal<T>`-held layout value, **only if it differs**.
151///
152/// The generic sibling of [`set_column_widths`] for the remaining persisted
153/// layout signals (sort, filters, order). Same rationale: the documented
154/// settings round trip observes in both directions, and `Signal::set` has no
155/// equality check of its own.
156pub(crate) fn set_if_changed<T: Clone + PartialEq + 'static>(signal: &Signal<T>, next: T) {
157    if signal.get() != next {
158        signal.set(next);
159    }
160}
161
162/// Resolve `(row, col_id)` to a `(row, display_position)` edit target.
163///
164/// Returns `None` — leaving any existing editor untouched — when `col_id` is
165/// not a declared column, when it is not currently displayed, or when `row` is
166/// outside the visible range. Without the row check an out-of-range
167/// `begin_edit` would strand `editing_cell` on a row that can never match,
168/// which nothing but an explicit `end_edit` would clear.
169pub(crate) fn resolve_edit_target<T: 'static>(
170    row: usize,
171    col_id: &str,
172    columns: &[Column<T>],
173    display_indices: &[usize],
174    row_count: usize,
175) -> Option<(usize, usize)> {
176    if row >= row_count {
177        return None;
178    }
179    let decl_index = columns.iter().position(|c| c.id == col_id)?;
180    let display_pos = display_indices.iter().position(|&i| i == decl_index)?;
181    Some((row, display_pos))
182}
183
184/// Remap `focused_cell` / `editing_cell` / an optional `cell_selection`'s
185/// stored `(row, display_pos)` pairs through `old_to_new` — indexed by the
186/// display position they were computed against *before* a column reorder or
187/// pin-toggle rebuild, each entry giving that column's position under the
188/// *new* order, or `None` if the column dropped out of the visible set.
189///
190/// Both views recompute display order on every rebuild but only key it by
191/// stable column identity for the columns themselves — the display-position
192/// pairs a caller stashed in `focused_cell` (keyboard focus), `editing_cell`
193/// (an open F2 editor), or a cell-selection rectangle are otherwise left
194/// pointing at whatever column now sits at that position, silently
195/// relabeling onto the wrong data. `old_to_new` being the identity
196/// permutation (the common case: a rebuild triggered by something other
197/// than order/pinning) makes every remap here a no-op.
198pub(crate) fn remap_cell_state(
199    focused_cell: &Signal<Option<(usize, usize)>>,
200    editing_cell: &Signal<Option<(usize, usize)>>,
201    cell_selection: Option<&CellSelectionModel>,
202    old_to_new: &[Option<usize>],
203) {
204    let remap = |cell: Option<(usize, usize)>| {
205        cell.and_then(|(row, col)| old_to_new.get(col).copied().flatten().map(|nc| (row, nc)))
206    };
207    let old_focus = focused_cell.get();
208    let new_focus = remap(old_focus);
209    if new_focus != old_focus {
210        focused_cell.set(new_focus);
211    }
212    let old_edit = editing_cell.get();
213    let new_edit = remap(old_edit);
214    if new_edit != old_edit {
215        editing_cell.set(new_edit);
216    }
217    if let Some(cs) = cell_selection {
218        cs.remap_columns(old_to_new);
219    }
220}