Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-grid/src/selection.rs

//! **Excel-style cell selection** — an anchor + lead cell define a rectangular
//! range; shift extends it, ctrl adds disjoint ranges, and row/col/all headers
//! select whole bands. Coordinates are **visual** (post-sort) row indices, so a
//! selected range stays where the user dragged even after re-sorting maps it back
//! to source rows for copy. [`Selection::to_tsv`] serialises the bounding region
//! the spreadsheet way (header + tab-joined cells, newline rows).

use std::collections::BTreeSet;

use crate::data::GridData;

/// A rectangular cell range in **visual** (row, col) coordinates, inclusive.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CellRange {
    pub r0: usize,
    pub c0: usize,
    pub r1: usize,
    pub c1: usize,
}

impl CellRange {
    pub fn single(r: usize, c: usize) -> Self {
        Self { r0: r, c0: c, r1: r, c1: c }
    }
    /// Normalised bounds (min/max), so `contains` works regardless of drag dir.
    pub fn norm(self) -> (usize, usize, usize, usize) {
        (self.r0.min(self.r1), self.c0.min(self.c1), self.r0.max(self.r1), self.c0.max(self.c1))
    }
    pub fn contains(self, r: usize, c: usize) -> bool {
        let (rl, cl, rh, ch) = self.norm();
        r >= rl && r <= rh && c >= cl && c <= ch
    }
    pub fn rows(self) -> usize {
        let (rl, _, rh, _) = self.norm();
        rh - rl + 1
    }
    pub fn cols(self) -> usize {
        let (_, cl, _, ch) = self.norm();
        ch - cl + 1
    }
}

/// The grid's selection: a set of rectangular ranges + the active anchor/lead
/// (the most recent range, which shift-extend grows). The lead is the keyboard
/// cursor.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Selection {
    pub ranges: Vec<CellRange>,
    /// The anchor cell shift-extend pivots around (the start of the active range).
    pub anchor: Option<(usize, usize)>,
    /// The lead/cursor cell (where the keyboard caret is).
    pub lead: Option<(usize, usize)>,
}

impl Selection {
    pub fn is_empty(&self) -> bool {
        self.ranges.is_empty()
    }
    pub fn clear(&mut self) {
        self.ranges.clear();
        self.anchor = None;
        self.lead = None;
    }

    /// Plain click: replace the selection with a single cell.
    pub fn click(&mut self, r: usize, c: usize) {
        self.ranges = vec![CellRange::single(r, c)];
        self.anchor = Some((r, c));
        self.lead = Some((r, c));
    }

    /// Shift-click / shift-arrow: extend the active range from the anchor to
    /// (r, c). Keeps earlier ctrl-added ranges intact.
    pub fn extend_to(&mut self, r: usize, c: usize) {
        let (ar, ac) = self.anchor.unwrap_or((r, c));
        let range = CellRange { r0: ar, c0: ac, r1: r, c1: c };
        if self.ranges.is_empty() {
            self.ranges.push(range);
        } else {
            *self.ranges.last_mut().unwrap() = range;
        }
        if self.anchor.is_none() {
            self.anchor = Some((ar, ac));
        }
        self.lead = Some((r, c));
    }

    /// Ctrl-click: start a new disjoint single-cell range (multi-select).
    pub fn add(&mut self, r: usize, c: usize) {
        self.ranges.push(CellRange::single(r, c));
        self.anchor = Some((r, c));
        self.lead = Some((r, c));
    }

    /// Select a whole row band `[r, r]` across `ncols` columns.
    pub fn select_row(&mut self, r: usize, ncols: usize, additive: bool) {
        let range = CellRange { r0: r, c0: 0, r1: r, c1: ncols.saturating_sub(1) };
        if !additive {
            self.ranges.clear();
        }
        self.ranges.push(range);
        self.anchor = Some((r, 0));
        self.lead = Some((r, 0));
    }

    /// Select a whole column band across `nrows` rows.
    pub fn select_col(&mut self, c: usize, nrows: usize, additive: bool) {
        let range = CellRange { r0: 0, c0: c, r1: nrows.saturating_sub(1), c1: c };
        if !additive {
            self.ranges.clear();
        }
        self.ranges.push(range);
        self.anchor = Some((0, c));
        self.lead = Some((0, c));
    }

    /// Select the entire grid.
    pub fn select_all(&mut self, nrows: usize, ncols: usize) {
        self.ranges = vec![CellRange { r0: 0, c0: 0, r1: nrows.saturating_sub(1), c1: ncols.saturating_sub(1) }];
        self.anchor = Some((0, 0));
        self.lead = Some((0, 0));
    }

    /// Is visual cell (r, c) selected by any range?
    pub fn contains(&self, r: usize, c: usize) -> bool {
        self.ranges.iter().any(|rg| rg.contains(r, c))
    }

    /// The distinct selected rows and columns (sorted) — used to build the TSV
    /// bounding region and the `state_json` summary.
    pub fn cells(&self) -> (BTreeSet<usize>, BTreeSet<usize>) {
        let mut rows = BTreeSet::new();
        let mut cols = BTreeSet::new();
        for rg in &self.ranges {
            let (rl, cl, rh, ch) = rg.norm();
            for r in rl..=rh {
                rows.insert(r);
            }
            for c in cl..=ch {
                cols.insert(c);
            }
        }
        (rows, cols)
    }

    /// Total selected cell count (union-aware would over-count overlaps; for the
    /// state summary we report the simple sum of range areas, deduped by a set on
    /// reasonable selections).
    pub fn cell_count(&self) -> usize {
        let mut set: BTreeSet<(usize, usize)> = BTreeSet::new();
        for rg in &self.ranges {
            let (rl, cl, rh, ch) = rg.norm();
            // Bound the work: don't materialise gigantic full-column selections.
            if (rh - rl + 1).saturating_mul(ch - cl + 1) > 1_000_000 {
                return usize::MAX; // "very large" sentinel for the summary
            }
            for r in rl..=rh {
                for c in cl..=ch {
                    set.insert((r, c));
                }
            }
        }
        set.len()
    }

    /// Serialise the selection to TSV the spreadsheet way: the bounding rectangle
    /// of all selected cells, with a header row of the involved columns. `order`
    /// maps visual rows → source rows so the data is correct after sorting.
    /// Returns `None` when nothing is selected.
    pub fn to_tsv(&self, data: &GridData, order: &[usize]) -> Option<String> {
        if self.ranges.is_empty() {
            return None;
        }
        let (rows, cols) = self.cells();
        if rows.is_empty() || cols.is_empty() {
            return None;
        }
        let cols: Vec<usize> = cols.into_iter().filter(|&c| c < data.ncols()).collect();
        if cols.is_empty() {
            return None;
        }
        let mut out = String::new();
        // header
        out.push_str(&cols.iter().map(|&c| data.names[c].as_str()).collect::<Vec<_>>().join("\t"));
        for &vr in &rows {
            let Some(&src) = order.get(vr) else { continue };
            out.push('\n');
            let line: Vec<String> = cols
                .iter()
                .map(|&c| if self.contains(vr, c) { data.cell_string(src, c) } else { String::new() })
                .collect();
            out.push_str(&line.join("\t"));
        }
        Some(out)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::data::Column;

    fn grid() -> GridData {
        GridData::new(vec![
            ("name".into(), Column::Text { v: vec!["knut".into(), "korp".into(), "skade".into()], nulls: vec![false; 3] }),
            ("ver".into(), Column::Text { v: vec!["0.1".into(), "0.2".into(), "0.3".into()], nulls: vec![false; 3] }),
            ("n".into(), Column::Int { v: vec![1, 2, 3], nulls: vec![false; 3] }),
        ])
    }

    #[test]
    fn click_then_shift_extends_a_range() {
        let mut s = Selection::default();
        s.click(0, 0);
        assert!(s.contains(0, 0) && !s.contains(1, 0));
        s.extend_to(2, 1);
        // range (0,0)..(2,1) inclusive
        assert!(s.contains(0, 0) && s.contains(2, 1) && s.contains(1, 1));
        assert!(!s.contains(0, 2));
    }

    #[test]
    fn ctrl_adds_disjoint_ranges() {
        let mut s = Selection::default();
        s.click(0, 0);
        s.add(2, 2);
        assert!(s.contains(0, 0) && s.contains(2, 2));
        assert!(!s.contains(1, 1));
        assert_eq!(s.ranges.len(), 2);
    }

    #[test]
    fn tsv_is_the_selected_rectangle_with_header() {
        let g = grid();
        let order: Vec<usize> = (0..3).collect();
        let mut s = Selection::default();
        s.click(0, 0);
        s.extend_to(1, 1); // name+ver for rows 0,1
        let tsv = s.to_tsv(&g, &order).unwrap();
        assert_eq!(tsv, "name\tver\nknut\t0.1\nkorp\t0.2");
    }

    #[test]
    fn tsv_follows_sort_order() {
        let g = grid();
        // visual order reversed: visual row 0 → source row 2.
        let order = vec![2, 1, 0];
        let mut s = Selection::default();
        s.click(0, 0);
        s.extend_to(0, 0); // just visual row 0
        let tsv = s.to_tsv(&g, &order).unwrap();
        assert_eq!(tsv, "name\nskade", "visual row 0 maps to source row 2");
    }

    #[test]
    fn select_all_and_count() {
        let mut s = Selection::default();
        s.select_all(3, 3);
        assert_eq!(s.cell_count(), 9);
        assert!(s.contains(2, 2));
    }

    #[test]
    fn select_row_spans_all_columns() {
        let mut s = Selection::default();
        s.select_row(1, 3, false);
        assert!(s.contains(1, 0) && s.contains(1, 2));
        assert!(!s.contains(0, 0));
    }
}