use std::collections::BTreeSet;
use super::{ChoiceState, TextConfig};
#[must_use]
pub fn find_next(options: &[String], from: usize, ch: char) -> Option<usize> {
if options.is_empty() {
return None;
}
let count = options.len();
let wanted = ch.to_uppercase().next().unwrap_or(ch);
let mut at = from;
for _ in 0..count {
at = (at + 1) % count;
let first = options
.get(at)
.and_then(|o| o.chars().next())
.map(|c| c.to_uppercase().next().unwrap_or(c));
if first == Some(wanted) {
return Some(at);
}
}
Some(at)
}
#[must_use]
pub fn in_range(state: &ChoiceState, index: usize) -> bool {
index < state.options.len()
}
pub fn set_index_selected(state: &mut ChoiceState, index: usize, selected: bool) -> bool {
if !in_range(state, index) {
return false;
}
if state.config.combo {
if !selected {
return false;
}
state.selected.clear();
state.selected.insert(index);
state.caret_index = Some(index);
return true;
}
if selected {
if !state.config.multi_select {
state.selected.clear();
}
state.selected.insert(index);
} else {
state.selected.remove(&index);
}
state.caret_index = Some(index);
true
}
#[must_use]
pub fn is_index_selected(state: &ChoiceState, index: usize) -> bool {
in_range(state, index) && state.selected.contains(&index)
}
pub fn select_only(state: &mut ChoiceState, index: usize) -> bool {
if !in_range(state, index) {
return false;
}
state.selected.clear();
state.selected.insert(index);
state.caret_index = Some(index);
state.anchor = Some(index);
true
}
pub fn select_range_to(state: &mut ChoiceState, index: usize) -> bool {
if !in_range(state, index) {
return false;
}
if !state.config.multi_select {
return select_only(state, index);
}
let anchor = state.anchor.unwrap_or(index);
let (lo, hi) = if anchor <= index {
(anchor, index)
} else {
(index, anchor)
};
state.selected = (lo..=hi).collect();
state.caret_index = Some(index);
true
}
pub fn toggle_index(state: &mut ChoiceState, index: usize) -> bool {
if !in_range(state, index) {
return false;
}
if !state.config.multi_select {
return select_only(state, index);
}
if state.selected.contains(&index) {
state.selected.remove(&index);
} else {
state.selected.insert(index);
}
state.caret_index = Some(index);
state.anchor = Some(index);
true
}
pub fn move_selection(state: &mut ChoiceState, delta: i32) -> bool {
if state.options.is_empty() {
return false;
}
let last = state.options.len() - 1;
let current = state
.caret_index
.or_else(|| state.selected.iter().next().copied())
.unwrap_or(0);
let next = if delta < 0 {
current.saturating_sub(delta.unsigned_abs() as usize)
} else {
(current + delta.unsigned_abs() as usize).min(last)
};
select_only(state, next)
}
pub fn move_caret_by(state: &mut ChoiceState, delta: i32, shift: bool, ctrl: bool) -> bool {
if state.options.is_empty() {
return false;
}
let last = state.options.len() - 1;
let current = state
.caret_index
.or_else(|| state.selected.iter().next().copied())
.unwrap_or(0);
let next = if delta < 0 {
current.saturating_sub(delta.unsigned_abs() as usize)
} else {
(current + delta.unsigned_abs() as usize).min(last)
};
if !state.config.multi_select {
return select_only(state, next);
}
if ctrl {
let moved = state.caret_index != Some(next);
state.caret_index = Some(next);
return moved;
}
if shift {
return select_range_to(state, next);
}
select_only(state, next)
}
pub fn type_ahead(state: &mut ChoiceState, ch: char) -> bool {
let labels: Vec<String> = state.options.iter().map(|o| o.label.clone()).collect();
let from = state
.caret_index
.or_else(|| state.selected.iter().next().copied())
.unwrap_or(0);
match find_next(&labels, from, ch) {
Some(index) => select_only(state, index),
None => false,
}
}
#[must_use]
pub fn initial_selection(
options: &[String],
values: &[String],
indices: &[usize],
) -> BTreeSet<usize> {
if !values.is_empty() {
return values
.iter()
.filter_map(|v| options.iter().position(|o| o == v))
.collect();
}
indices
.iter()
.copied()
.filter(|i| *i < options.len())
.collect()
}
#[must_use]
pub fn top_visible_for(count: usize, visible_rows: usize, first_selected: usize) -> usize {
if visible_rows == 0 || count <= visible_rows {
return 0;
}
let max_top = count - visible_rows;
first_selected.min(max_top)
}
#[must_use]
pub fn combo_text_config(editable: bool, read_only: bool) -> TextConfig {
TextConfig {
multi_line: false,
password: false,
comb: false,
max_len: None,
read_only: read_only || !editable,
undo_enabled: true,
auto_scroll: true,
}
}
pub fn scroll_into_view(state: &mut ChoiceState, index: usize, visible_rows: usize) -> bool {
if visible_rows == 0 {
return false;
}
let was = state.top_visible;
if index < state.top_visible {
state.top_visible = index;
} else if index >= state.top_visible.saturating_add(visible_rows) {
state.top_visible = index.saturating_sub(visible_rows - 1);
}
state.top_visible != was
}
pub fn set_select_text(
state: &mut ChoiceState,
config: &pdfrum_doc::vt::Config,
metrics: &pdfrum_doc::vt::Metrics<'_>,
) -> bool {
let Some(index) = state
.caret_index
.or_else(|| state.selected.iter().next().copied())
else {
return false;
};
let Some(option) = state.options.get(index) else {
return false;
};
let text = option.label.clone();
let edit = state
.edit
.get_or_insert_with(|| Box::new(crate::edit::TextEdit::new("", config, metrics, true)));
edit.select_all();
crate::edit::ops::replace_selection(edit, config, metrics, &text, None);
edit.select_all();
state.edit_text.clone_from(&edit.text);
true
}
#[cfg(test)]
mod tests {
use super::*;
use crate::field::{ChoiceConfig, ChoiceOption};
fn options(labels: &[&str]) -> Vec<ChoiceOption> {
labels
.iter()
.map(|l| ChoiceOption {
label: (*l).to_string(),
value: (*l).to_string(),
})
.collect()
}
fn combo(labels: &[&str]) -> ChoiceState {
ChoiceState::new(
options(labels),
ChoiceConfig {
combo: true,
..ChoiceConfig::default()
},
)
}
fn single_list(labels: &[&str]) -> ChoiceState {
ChoiceState::new(options(labels), ChoiceConfig::default())
}
fn multi_list(labels: &[&str]) -> ChoiceState {
ChoiceState::new(
options(labels),
ChoiceConfig {
multi_select: true,
..ChoiceConfig::default()
},
)
}
const FRUIT: [&str; 5] = ["Apple", "Banana", "Cherry", "Date", "Elderberry"];
#[test]
fn only_a_list_box_can_be_emptied() {
let mut combo = combo(&FRUIT);
assert!(set_index_selected(&mut combo, 1, true));
assert!(
!set_index_selected(&mut combo, 1, false),
"a combo box has no empty state"
);
assert!(is_index_selected(&combo, 1), "and it did not clear");
let mut list = single_list(&FRUIT);
assert!(set_index_selected(&mut list, 1, true));
assert!(set_index_selected(&mut list, 1, false));
assert!(list.selected.is_empty());
assert_eq!(list.focused_text(), "Banana", "the caret stayed on it");
}
#[test]
fn an_out_of_range_row_is_rejected() {
let mut list = single_list(&FRUIT);
assert!(set_index_selected(&mut list, 0, true));
assert!(!set_index_selected(&mut list, 100, true));
assert!(!set_index_selected(&mut list, 5, true));
assert!(is_index_selected(&list, 0));
assert!(!is_index_selected(&list, 100));
}
#[test]
fn a_clear_that_changes_nothing_still_moves_the_caret() {
let mut list = multi_list(&FRUIT);
set_index_selected(&mut list, 0, true);
assert_eq!(list.focused_text(), "Apple");
assert!(set_index_selected(&mut list, 3, false));
assert!(!is_index_selected(&list, 3));
assert!(is_index_selected(&list, 0), "row 0 is untouched");
assert_eq!(list.focused_text(), "Date", "but the caret moved");
}
#[test]
fn focused_text_follows_the_last_row_acted_upon() {
let mut list = multi_list(&FRUIT);
set_index_selected(&mut list, 1, true);
set_index_selected(&mut list, 2, true);
set_index_selected(&mut list, 4, true);
assert_eq!(list.focused_text(), "Elderberry");
set_index_selected(&mut list, 4, false);
set_index_selected(&mut list, 1, false);
assert_eq!(list.focused_text(), "Banana");
}
#[test]
fn multi_select_accumulates_and_single_select_does_not() {
let mut single = single_list(&FRUIT);
set_index_selected(&mut single, 0, true);
set_index_selected(&mut single, 2, true);
assert_eq!(single.selected.len(), 1);
assert!(is_index_selected(&single, 2));
let mut multi = multi_list(&FRUIT);
set_index_selected(&mut multi, 0, true);
set_index_selected(&mut multi, 2, true);
assert_eq!(multi.selected.len(), 2);
assert!(is_index_selected(&multi, 0));
assert!(is_index_selected(&multi, 2));
}
#[test]
fn type_ahead_jumps_rather_than_accumulating() {
let mut combo = combo(&FRUIT);
assert!(type_ahead(&mut combo, 'A'));
assert_eq!(combo.focused_text(), "Apple");
type_ahead(&mut combo, 'A');
type_ahead(&mut combo, 'B');
type_ahead(&mut combo, 'C');
assert_eq!(combo.focused_text(), "Cherry");
type_ahead(&mut combo, 'A');
type_ahead(&mut combo, 'B');
assert_eq!(combo.focused_text(), "Banana");
}
#[test]
fn type_ahead_ignores_case() {
let mut combo = combo(&FRUIT);
type_ahead(&mut combo, 'd');
assert_eq!(combo.focused_text(), "Date");
}
#[test]
fn type_ahead_wraps_around() {
let labels: Vec<String> = FRUIT.iter().map(|s| (*s).to_string()).collect();
assert_eq!(find_next(&labels, 4, 'A'), Some(0));
assert_eq!(find_next(&labels, 0, 'B'), Some(1));
}
#[test]
fn an_unmatched_character_lands_on_the_last_row_probed() {
let labels: Vec<String> = FRUIT.iter().map(|s| (*s).to_string()).collect();
assert_eq!(find_next(&labels, 2, 'Z'), Some(2));
}
#[test]
fn type_ahead_on_an_empty_field_does_nothing() {
let mut empty = combo(&[]);
assert!(!type_ahead(&mut empty, 'A'));
assert_eq!(find_next(&[], 0, 'A'), None);
}
#[test]
fn the_value_wins_over_the_index_entry() {
let options: Vec<String> = FRUIT.iter().map(|s| (*s).to_string()).collect();
let by_index = initial_selection(&options, &[], &[2, 3]);
assert_eq!(by_index, BTreeSet::from([2, 3]));
let by_value = initial_selection(&options, &["Apple".to_string()], &[]);
assert_eq!(by_value, BTreeSet::from([0]));
let conflicting = initial_selection(&options, &["Banana".to_string()], &[3, 4]);
assert_eq!(conflicting, BTreeSet::from([1]));
}
#[test]
fn an_out_of_range_index_entry_is_dropped() {
let options: Vec<String> = FRUIT.iter().map(|s| (*s).to_string()).collect();
assert_eq!(
initial_selection(&options, &[], &[1, 99]),
BTreeSet::from([1])
);
}
#[test]
fn a_list_does_not_overscroll_past_its_last_full_page() {
assert_eq!(top_visible_for(10, 3, 9), 7);
assert_eq!(top_visible_for(10, 3, 4), 4);
assert_eq!(top_visible_for(3, 5, 2), 0);
assert_eq!(top_visible_for(10, 0, 5), 0);
}
#[test]
fn a_range_selection_keeps_its_pivot() {
let mut list = multi_list(&FRUIT);
select_only(&mut list, 1);
assert_eq!(list.anchor, Some(1));
select_range_to(&mut list, 3);
assert_eq!(list.selected, BTreeSet::from([1, 2, 3]));
assert_eq!(list.anchor, Some(1), "the pivot did not move");
select_range_to(&mut list, 2);
assert_eq!(list.selected, BTreeSet::from([1, 2]));
}
#[test]
fn a_backwards_range_selects_the_same_rows() {
let mut list = multi_list(&FRUIT);
select_only(&mut list, 3);
select_range_to(&mut list, 1);
assert_eq!(list.selected, BTreeSet::from([1, 2, 3]));
}
#[test]
fn toggling_keeps_the_rest_and_re_anchors() {
let mut list = multi_list(&FRUIT);
select_only(&mut list, 0);
toggle_index(&mut list, 3);
assert_eq!(list.selected, BTreeSet::from([0, 3]));
assert_eq!(list.anchor, Some(3));
toggle_index(&mut list, 3);
assert_eq!(list.selected, BTreeSet::from([0]));
}
#[test]
fn a_single_select_list_ignores_the_modifiers() {
let mut list = single_list(&FRUIT);
select_only(&mut list, 0);
select_range_to(&mut list, 3);
assert_eq!(list.selected, BTreeSet::from([3]));
toggle_index(&mut list, 1);
assert_eq!(list.selected, BTreeSet::from([1]));
}
#[test]
fn moving_the_selection_clamps_at_both_ends() {
let mut list = single_list(&FRUIT);
select_only(&mut list, 0);
move_selection(&mut list, -1);
assert!(is_index_selected(&list, 0), "clamped at the top");
for _ in 0..10 {
move_selection(&mut list, 1);
}
assert!(is_index_selected(&list, 4), "clamped at the bottom");
}
#[test]
fn moving_an_empty_field_does_nothing() {
let mut empty = single_list(&[]);
assert!(!move_selection(&mut empty, 1));
}
#[test]
fn a_non_editable_combo_still_has_read_only_text() {
let editable = combo_text_config(true, false);
assert!(!editable.read_only);
let fixed = combo_text_config(false, false);
assert!(fixed.read_only);
assert!(combo_text_config(true, true).read_only);
}
#[test]
fn a_combo_text_half_is_single_line_and_unlimited() {
let config = combo_text_config(true, false);
assert!(!config.multi_line);
assert_eq!(config.max_len, None);
assert!(config.undo_enabled);
}
}