teksilo-widgets 0.9.2

Widget library for Teksilo — over a hundred widgets and layout primitives, from Button to TreeTableView.
Documentation
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! Imperative-API helpers shared by [`TableView`](crate::TableView) and
//! [`TreeTableView`](crate::TreeTableView).
//!
//! Both widgets expose the same "drive the table from code" surface — scroll to
//! a row, override a column width, pin a column, open a cell editor. The public
//! methods stay inherent on each widget (that is the discoverable API), but the
//! *logic* lives here once, so a fix lands on both instead of drifting.
//!
//! Everything takes the widget's signals/handles as parameters rather than the
//! widget itself, which keeps this module free of both concrete types.

use std::collections::HashMap;

use teksilo_core::signal::Signal;

use crate::common::row_metrics::SharedRowMetrics;
use crate::table_view::column::{Column, PinnedSide};
use crate::table_view::selection::CellSelectionModel;

/// Scroll so that `row` is aligned to the top of the viewport.
///
/// A no-op before the first layout pass: `max_scroll_y` is still `0`, so the
/// clamp collapses any target to `0`.
pub(crate) fn scroll_to_row(
    row: usize,
    row_metrics: &SharedRowMetrics,
    scroll_y: &Signal<f32>,
    max_scroll_y: &Signal<f32>,
) {
    // `try_borrow_mut`: the metrics cell is also borrowed during layout and by
    // `row_height_fn`, so a call from inside a cell delegate or an activation
    // handler could re-enter. Skipping the scroll beats panicking.
    let Ok(mut metrics) = row_metrics.try_borrow_mut() else {
        return;
    };
    let target = metrics.row_top(row);
    drop(metrics);
    let max = max_scroll_y.get();
    scroll_y.set(target.clamp(0.0, max));
}

/// Scroll the minimum distance needed to make `row` visible.
///
/// A no-op before the first layout pass — `viewport_height` still holds its
/// construction placeholder then, so the computed offset would be measured
/// against a viewport that was never laid out.
pub(crate) fn ensure_row_visible(
    row: usize,
    row_metrics: &SharedRowMetrics,
    scroll_y: &Signal<f32>,
    max_scroll_y: &Signal<f32>,
    viewport_height: f32,
    laid_out: bool,
) {
    if !laid_out {
        return;
    }
    let Ok(mut metrics) = row_metrics.try_borrow_mut() else {
        return;
    };
    let scroll = scroll_y.get();
    let new_scroll =
        metrics.scroll_for_ensure_visible(row, scroll, viewport_height, max_scroll_y.get());
    drop(metrics);
    if (new_scroll - scroll).abs() > f32::EPSILON {
        scroll_y.set(new_scroll);
    }
}

/// Set or remove a single column's user-resized width override. A non-positive
/// or non-finite `width` removes the entry, reverting the column to its
/// declared width policy.
///
/// The value is stored verbatim — it is the app stating a preference, not a
/// drag position — and re-clamped to the column's `[min_width, max_width]`
/// every time
/// [`ColumnSolver::resolve_in_order`](super::layout::ColumnSolver::resolve_in_order)
/// runs. So an override outside those bounds renders clamped while surviving
/// intact in the signal, ready to take effect if the column's bounds later
/// widen. (The *drag* path deliberately clamps before writing, so
/// `column_widths_signal` always mirrors what the user sees the table do.)
pub(crate) fn set_column_width(signal: &Signal<HashMap<String, f32>>, col_id: &str, width: f32) {
    let mut m = signal.get();
    let changed = if width.is_finite() && width > 0.0 {
        m.insert(col_id.to_string(), width) != Some(width)
    } else {
        m.remove(col_id).is_some()
    };
    // Equality-guarded — see `set_column_widths`.
    if changed {
        signal.set(m);
    }
}

/// Replace the whole width-override map, **only if it actually differs**.
///
/// The guard is load-bearing, not an optimisation. The documented persistence
/// shape (docs/table-view.md, "Persistence") observes the settings signal into
/// the table and the table's signal back into settings; `Signal::set` carries
/// no equality check by design, so an unguarded write here closes that pair
/// into an unbounded mutual recursion — a `NotifyDepthGuard` panic in debug, a
/// stack overflow in release — on the very first `PointerMove` of a
/// `ColumnResizePolicy::Live` drag, which writes a width on every tick.
pub(crate) fn set_column_widths(
    signal: &Signal<HashMap<String, f32>>,
    widths: HashMap<String, f32>,
) {
    if signal.get() != widths {
        signal.set(widths);
    }
}

/// Pin or unpin a single column. [`PinnedSide::None`] removes the override,
/// reverting the column to its declared [`Column::pinned`].
pub(crate) fn set_column_pinning(
    signal: &Signal<HashMap<String, PinnedSide>>,
    col_id: &str,
    side: PinnedSide,
) {
    let mut m = signal.get();
    let changed = if matches!(side, PinnedSide::None) {
        m.remove(col_id).is_some()
    } else {
        m.insert(col_id.to_string(), side) != Some(side)
    };
    // Equality-guarded — see `set_column_widths`.
    if changed {
        signal.set(m);
    }
}

/// Set or clear the filter text for a single column. An empty `text` removes
/// the entry.
pub(crate) fn set_filter(signal: &Signal<HashMap<String, String>>, col_id: &str, text: &str) {
    let mut m = signal.get();
    let changed = if text.is_empty() {
        m.remove(col_id).is_some()
    } else {
        m.insert(col_id.to_string(), text.to_string()).as_deref() != Some(text)
    };
    // Equality-guarded — see `set_column_widths`.
    if changed {
        signal.set(m);
    }
}

/// Replace a whole `Signal<T>`-held layout value, **only if it differs**.
///
/// The generic sibling of [`set_column_widths`] for the remaining persisted
/// layout signals (sort, filters, order). Same rationale: the documented
/// settings round trip observes in both directions, and `Signal::set` has no
/// equality check of its own.
pub(crate) fn set_if_changed<T: Clone + PartialEq + 'static>(signal: &Signal<T>, next: T) {
    if signal.get() != next {
        signal.set(next);
    }
}

/// Resolve `(row, col_id)` to a `(row, display_position)` edit target.
///
/// Returns `None` — leaving any existing editor untouched — when `col_id` is
/// not a declared column, when it is not currently displayed, or when `row` is
/// outside the visible range. Without the row check an out-of-range
/// `begin_edit` would strand `editing_cell` on a row that can never match,
/// which nothing but an explicit `end_edit` would clear.
pub(crate) fn resolve_edit_target<T: 'static>(
    row: usize,
    col_id: &str,
    columns: &[Column<T>],
    display_indices: &[usize],
    row_count: usize,
) -> Option<(usize, usize)> {
    if row >= row_count {
        return None;
    }
    let decl_index = columns.iter().position(|c| c.id == col_id)?;
    let display_pos = display_indices.iter().position(|&i| i == decl_index)?;
    Some((row, display_pos))
}

/// Remap `focused_cell` / `editing_cell` / an optional `cell_selection`'s
/// stored `(row, display_pos)` pairs through `old_to_new` — indexed by the
/// display position they were computed against *before* a column reorder or
/// pin-toggle rebuild, each entry giving that column's position under the
/// *new* order, or `None` if the column dropped out of the visible set.
///
/// Both views recompute display order on every rebuild but only key it by
/// stable column identity for the columns themselves — the display-position
/// pairs a caller stashed in `focused_cell` (keyboard focus), `editing_cell`
/// (an open F2 editor), or a cell-selection rectangle are otherwise left
/// pointing at whatever column now sits at that position, silently
/// relabeling onto the wrong data. `old_to_new` being the identity
/// permutation (the common case: a rebuild triggered by something other
/// than order/pinning) makes every remap here a no-op.
pub(crate) fn remap_cell_state(
    focused_cell: &Signal<Option<(usize, usize)>>,
    editing_cell: &Signal<Option<(usize, usize)>>,
    cell_selection: Option<&CellSelectionModel>,
    old_to_new: &[Option<usize>],
) {
    let remap = |cell: Option<(usize, usize)>| {
        cell.and_then(|(row, col)| old_to_new.get(col).copied().flatten().map(|nc| (row, nc)))
    };
    let old_focus = focused_cell.get();
    let new_focus = remap(old_focus);
    if new_focus != old_focus {
        focused_cell.set(new_focus);
    }
    let old_edit = editing_cell.get();
    let new_edit = remap(old_edit);
    if new_edit != old_edit {
        editing_cell.set(new_edit);
    }
    if let Some(cs) = cell_selection {
        cs.remap_columns(old_to_new);
    }
}