use crate::elements::checkbox::CheckState;
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectionMode {
Single,
Multiple,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HighlightMotion {
Wrap,
Clamp,
}
pub fn wrap_index(current: Option<usize>, delta: isize, count: usize) -> Option<usize> {
if count == 0 {
return None;
}
Some(match current {
Some(current) => (current as isize + delta).rem_euclid(count as isize) as usize,
None if delta < 0 => count - 1,
None => 0,
})
}
pub fn clamp_index(current: Option<usize>, delta: isize, count: usize) -> Option<usize> {
if count == 0 {
return None;
}
Some(match current {
Some(current) => (current as isize + delta).clamp(0, count as isize - 1) as usize,
None if delta < 0 => count - 1,
None => 0,
})
}
#[derive(Debug, Clone)]
pub struct SelectionModel {
mode: SelectionMode,
selected: BTreeSet<usize>,
highlighted: Option<usize>,
anchor: Option<usize>,
}
impl SelectionModel {
pub fn new(mode: SelectionMode) -> Self {
Self {
mode,
selected: BTreeSet::new(),
highlighted: None,
anchor: None,
}
}
pub fn single() -> Self {
Self::new(SelectionMode::Single)
}
pub fn multiple() -> Self {
Self::new(SelectionMode::Multiple)
}
pub fn mode(&self) -> SelectionMode {
self.mode
}
pub fn is_selected(&self, ix: usize) -> bool {
self.selected.contains(&ix)
}
pub fn selected(&self) -> &BTreeSet<usize> {
&self.selected
}
pub fn selected_count(&self) -> usize {
self.selected.len()
}
pub fn single_selection(&self) -> Option<usize> {
self.selected.iter().next().copied()
}
pub fn highlighted(&self) -> Option<usize> {
self.highlighted
}
pub fn set_highlighted(&mut self, highlighted: Option<usize>) {
self.highlighted = highlighted;
}
pub fn anchor(&self) -> Option<usize> {
self.anchor
}
pub fn set_anchor(&mut self, anchor: Option<usize>) {
self.anchor = anchor;
}
pub fn select(&mut self, ix: usize) {
if self.mode == SelectionMode::Single {
self.selected.clear();
}
self.selected.insert(ix);
self.anchor = Some(ix);
}
pub fn deselect(&mut self, ix: usize) {
self.selected.remove(&ix);
}
pub fn clear(&mut self) {
self.selected.clear();
}
pub fn toggle(&mut self, ix: usize) -> bool {
match self.mode {
SelectionMode::Single => {
if self.is_selected(ix) {
false
} else {
self.select(ix);
true
}
}
SelectionMode::Multiple => {
if self.selected.remove(&ix) {
true
} else {
self.select(ix);
true
}
}
}
}
pub fn extend_to(&mut self, ix: usize) {
if self.mode == SelectionMode::Single {
self.select(ix);
return;
}
let Some(anchor) = self.anchor else {
self.select(ix);
return;
};
let (lo, hi) = if anchor <= ix {
(anchor, ix)
} else {
(ix, anchor)
};
self.selected = (lo..=hi).collect();
}
pub fn move_highlight(
&mut self,
delta: isize,
len: usize,
motion: HighlightMotion,
) -> Option<usize> {
let next = match motion {
HighlightMotion::Wrap => wrap_index(self.highlighted, delta, len),
HighlightMotion::Clamp => clamp_index(self.highlighted, delta, len),
};
self.highlighted = next;
next
}
pub fn check_state(&self, total: usize) -> CheckState {
let in_range = self.selected.iter().filter(|&&ix| ix < total).count();
CheckState::from_count(in_range, total)
}
}
pub trait HasSelection {
fn selection(&self) -> &SelectionModel;
fn selection_mut(&mut self) -> &mut SelectionModel;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrap_index_moves_and_wraps_at_both_ends() {
assert_eq!(wrap_index(Some(0), 1, 3), Some(1));
assert_eq!(
wrap_index(Some(2), 1, 3),
Some(0),
"past the end comes the front"
);
assert_eq!(
wrap_index(Some(0), -1, 3),
Some(2),
"before the front comes the end"
);
}
#[test]
fn wrap_index_enters_from_the_near_end_and_is_empty_on_nothing() {
assert_eq!(wrap_index(None, 1, 3), Some(0), "forward enters at the top");
assert_eq!(
wrap_index(None, -1, 3),
Some(2),
"backward enters at the bottom"
);
assert_eq!(
wrap_index(None, 1, 0),
None,
"an empty list highlights nothing"
);
assert_eq!(wrap_index(Some(0), 1, 0), None);
}
#[test]
fn clamp_index_holds_at_both_ends() {
assert_eq!(clamp_index(Some(1), 1, 3), Some(2));
assert_eq!(clamp_index(Some(2), 1, 3), Some(2), "the last item holds");
assert_eq!(clamp_index(Some(0), -1, 3), Some(0), "the first item holds");
assert_eq!(clamp_index(None, 1, 0), None);
}
#[test]
fn single_select_replaces() {
let mut model = SelectionModel::single();
model.select(0);
model.select(2);
assert!(!model.is_selected(0), "single-select replaces, not adds");
assert!(model.is_selected(2));
assert_eq!(model.single_selection(), Some(2));
}
#[test]
fn single_toggle_does_not_deselect_by_reclicking() {
let mut model = SelectionModel::single();
assert!(model.toggle(1), "first toggle selects and reports a change");
assert!(!model.toggle(1), "re-toggling the selected item is a no-op");
assert!(model.is_selected(1), "and leaves it selected");
}
#[test]
fn multiple_toggle_flips_membership() {
let mut model = SelectionModel::multiple();
model.toggle(0);
model.toggle(2);
assert!(model.is_selected(0) && model.is_selected(2));
assert!(model.toggle(0), "toggling a selected item removes it");
assert!(!model.is_selected(0));
assert!(model.is_selected(2), "and leaves the others alone");
}
#[test]
fn extend_to_selects_the_inclusive_run_from_the_anchor() {
let mut model = SelectionModel::multiple();
model.select(2); model.extend_to(5);
assert_eq!(
model.selected().iter().copied().collect::<Vec<_>>(),
vec![2, 3, 4, 5]
);
}
#[test]
fn extend_to_runs_backwards_from_the_anchor_too() {
let mut model = SelectionModel::multiple();
model.select(5); model.extend_to(2);
assert_eq!(
model.selected().iter().copied().collect::<Vec<_>>(),
vec![2, 3, 4, 5]
);
}
#[test]
fn extend_to_measures_every_step_from_the_same_anchor() {
let mut model = SelectionModel::multiple();
model.select(2);
model.extend_to(5);
model.extend_to(3); assert_eq!(
model.selected().iter().copied().collect::<Vec<_>>(),
vec![2, 3]
);
}
#[test]
fn extend_to_with_no_anchor_is_a_plain_select() {
let mut model = SelectionModel::multiple();
model.extend_to(4);
assert_eq!(
model.selected().iter().copied().collect::<Vec<_>>(),
vec![4]
);
assert_eq!(model.anchor(), Some(4));
}
#[test]
fn move_highlight_wraps_or_clamps_by_policy() {
let mut model = SelectionModel::single();
model.set_highlighted(Some(2));
assert_eq!(model.move_highlight(1, 3, HighlightMotion::Wrap), Some(0));
assert_eq!(model.highlighted(), Some(0));
model.set_highlighted(Some(2));
assert_eq!(model.move_highlight(1, 3, HighlightMotion::Clamp), Some(2));
}
#[test]
fn check_state_aggregates_over_a_total() {
let mut model = SelectionModel::multiple();
assert_eq!(model.check_state(3), CheckState::Unchecked);
model.toggle(0);
assert_eq!(model.check_state(3), CheckState::Indeterminate);
model.toggle(1);
model.toggle(2);
assert_eq!(model.check_state(3), CheckState::Checked);
}
#[test]
fn check_state_ignores_indices_a_shrunk_list_left_behind() {
let mut model = SelectionModel::multiple();
model.toggle(0);
model.toggle(5); assert_eq!(
model.check_state(3),
CheckState::Indeterminate,
"the stale index 5 does not count toward 'all of 3'"
);
}
}