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));
}
}