use crate::tree::{El, Kind};
use crate::widgets::text_input::TextSelection;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Selection {
pub range: Option<SelectionRange>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SelectionRange {
pub anchor: SelectionPoint,
pub head: SelectionPoint,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SelectionPoint {
pub key: String,
pub byte: usize,
}
impl SelectionPoint {
pub fn new(key: impl Into<String>, byte: usize) -> Self {
Self {
key: key.into(),
byte,
}
}
}
impl Selection {
pub fn caret(key: impl Into<String>, byte: usize) -> Self {
let pt = SelectionPoint::new(key, byte);
Self {
range: Some(SelectionRange {
anchor: pt.clone(),
head: pt,
}),
}
}
pub fn is_empty(&self) -> bool {
self.range.is_none()
}
pub fn is_within(&self, key: &str) -> bool {
match &self.range {
Some(r) => r.anchor.key == key && r.head.key == key,
None => false,
}
}
pub fn anchored_at(&self, key: &str) -> bool {
self.range.as_ref().is_some_and(|r| r.anchor.key == key)
}
pub fn within(&self, key: &str) -> Option<TextSelection> {
let r = self.range.as_ref()?;
if r.anchor.key == key && r.head.key == key {
Some(TextSelection {
anchor: r.anchor.byte,
head: r.head.byte,
})
} else {
None
}
}
pub fn set_within(&mut self, key: &str, sel: TextSelection) {
let Some(r) = self.range.as_mut() else { return };
if r.anchor.key == key && r.head.key == key {
r.anchor.byte = sel.anchor;
r.head.byte = sel.head;
}
}
pub fn clear(&mut self) {
self.range = None;
}
}
pub fn slice_for_leaf(
selection: &Selection,
order: &[crate::event::UiTarget],
key: &str,
text_len: usize,
) -> Option<(usize, usize)> {
let r = selection.range.as_ref()?;
if r.anchor.key == r.head.key {
if r.anchor.key != key {
return None;
}
let (lo, hi) = (
r.anchor.byte.min(r.head.byte).min(text_len),
r.anchor.byte.max(r.head.byte).min(text_len),
);
return (lo < hi).then_some((lo, hi));
}
let pos = |k: &str| order.iter().position(|t| t.key == k);
let (a_idx, h_idx, key_idx) = (pos(&r.anchor.key)?, pos(&r.head.key)?, pos(key)?);
let (lo_idx, lo_byte, hi_idx, hi_byte) = if a_idx <= h_idx {
(a_idx, r.anchor.byte, h_idx, r.head.byte)
} else {
(h_idx, r.head.byte, a_idx, r.anchor.byte)
};
if key_idx < lo_idx || key_idx > hi_idx {
return None;
}
let lo = if key_idx == lo_idx {
lo_byte.min(text_len)
} else {
0
};
let hi = if key_idx == hi_idx {
hi_byte.min(text_len)
} else {
text_len
};
(lo < hi).then_some((lo, hi))
}
pub fn selected_text(tree: &El, selection: &Selection) -> Option<String> {
let r = selection.range.as_ref()?;
if r.anchor.key == r.head.key {
let value = find_keyed_text(tree, &r.anchor.key)?;
let lo = r.anchor.byte.min(r.head.byte).min(value.len());
let hi = r.anchor.byte.max(r.head.byte).min(value.len());
if lo >= hi {
return None;
}
return Some(value[lo..hi].to_string());
}
let mut leaves: Vec<(String, String)> = Vec::new();
collect_keyed_text_leaves(tree, &mut leaves);
let anchor_idx = leaves.iter().position(|(k, _)| *k == r.anchor.key)?;
let head_idx = leaves.iter().position(|(k, _)| *k == r.head.key)?;
let (lo_idx, lo_byte, hi_idx, hi_byte) = if anchor_idx <= head_idx {
(anchor_idx, r.anchor.byte, head_idx, r.head.byte)
} else {
(head_idx, r.head.byte, anchor_idx, r.anchor.byte)
};
let mut out = String::new();
for (i, (_, value)) in leaves
.iter()
.enumerate()
.skip(lo_idx)
.take(hi_idx - lo_idx + 1)
{
let start = if i == lo_idx {
lo_byte.min(value.len())
} else {
0
};
let end = if i == hi_idx {
hi_byte.min(value.len())
} else {
value.len()
};
if start >= end {
continue;
}
if !out.is_empty() {
out.push('\n');
}
out.push_str(&value[start..end]);
}
if out.is_empty() { None } else { Some(out) }
}
pub(crate) fn find_keyed_text(node: &El, key: &str) -> Option<String> {
if matches!(node.kind, Kind::Text | Kind::Heading)
&& node.key.as_deref() == Some(key)
&& let Some(t) = &node.text
{
return Some(t.clone());
}
node.children.iter().find_map(|c| find_keyed_text(c, key))
}
fn collect_keyed_text_leaves(node: &El, out: &mut Vec<(String, String)>) {
if matches!(node.kind, Kind::Text | Kind::Heading)
&& let (Some(k), Some(t)) = (&node.key, &node.text)
{
out.push((k.clone(), t.clone()));
}
for c in &node.children {
collect_keyed_text_leaves(c, out);
}
}
pub fn word_range_at(text: &str, byte: usize) -> (usize, usize) {
if text.is_empty() {
return (0, 0);
}
let byte = clamp_to_char_boundary(text, byte.min(text.len()));
let probe = if byte == text.len() {
prev_char_boundary(text, byte)
} else {
byte
};
let probe_char = text[probe..].chars().next().unwrap_or(' ');
if !is_word_char(probe_char) {
return (probe, probe + probe_char.len_utf8());
}
let mut lo = probe;
while lo > 0 {
let p = prev_char_boundary(text, lo);
let ch = text[p..].chars().next().unwrap();
if !is_word_char(ch) {
break;
}
lo = p;
}
let mut hi = probe;
while hi < text.len() {
let ch = text[hi..].chars().next().unwrap();
if !is_word_char(ch) {
break;
}
hi += ch.len_utf8();
}
(lo, hi)
}
pub fn line_range_at(text: &str, byte: usize) -> (usize, usize) {
let byte = byte.min(text.len());
let lo = text[..byte].rfind('\n').map(|i| i + 1).unwrap_or(0);
let hi = text[byte..]
.find('\n')
.map(|i| byte + i)
.unwrap_or(text.len());
(lo, hi)
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '\''
}
fn clamp_to_char_boundary(text: &str, byte: usize) -> usize {
let mut b = byte;
while b > 0 && !text.is_char_boundary(b) {
b -= 1;
}
b
}
fn prev_char_boundary(text: &str, byte: usize) -> usize {
let mut b = byte.saturating_sub(1);
while b > 0 && !text.is_char_boundary(b) {
b -= 1;
}
b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_selection_has_no_views() {
let sel = Selection::default();
assert!(sel.is_empty());
assert!(!sel.is_within("name"));
assert!(sel.within("name").is_none());
}
#[test]
fn caret_constructor_is_within_its_key() {
let sel = Selection::caret("name", 3);
assert!(!sel.is_empty());
assert!(sel.is_within("name"));
assert!(!sel.is_within("email"));
let view = sel.within("name").expect("within name");
assert_eq!(view, TextSelection::caret(3));
}
#[test]
fn within_returns_none_for_cross_element_selection() {
let sel = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new("para_a", 0),
head: SelectionPoint::new("para_b", 5),
}),
};
assert!(sel.within("para_a").is_none());
assert!(sel.within("para_b").is_none());
assert!(sel.anchored_at("para_a"));
assert!(!sel.anchored_at("para_b"));
}
#[test]
fn set_within_writes_back_a_modified_slice() {
let mut sel = Selection::caret("name", 0);
let mut view = sel.within("name").expect("caret");
view.head = 5; sel.set_within("name", view);
let view_back = sel.within("name").expect("still within name");
assert_eq!(view_back, TextSelection::range(0, 5));
}
#[test]
fn set_within_is_a_noop_when_selection_is_not_in_key() {
let mut sel = Selection::caret("name", 0);
sel.set_within("email", TextSelection::range(0, 9));
assert_eq!(sel.within("name"), Some(TextSelection::caret(0)));
assert!(sel.within("email").is_none());
}
#[test]
fn selected_text_returns_single_leaf_substring() {
let tree = crate::widgets::text::text("Hello, world!").key("p");
let sel = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new("p", 7),
head: SelectionPoint::new("p", 12),
}),
};
assert_eq!(selected_text(&tree, &sel).as_deref(), Some("world"));
}
#[test]
fn selected_text_walks_tree_order_for_cross_leaf_selection() {
let tree = crate::column([
crate::widgets::text::text("alpha").key("a"),
crate::widgets::text::text("bravo").key("b"),
crate::widgets::text::text("charlie").key("c"),
]);
let sel = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new("a", 2),
head: SelectionPoint::new("c", 4),
}),
};
assert_eq!(
selected_text(&tree, &sel).as_deref(),
Some("pha\nbravo\nchar")
);
}
#[test]
fn slice_for_leaf_single_leaf() {
let order = order_for(&["a", "b", "c"]);
let sel = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new("b", 2),
head: SelectionPoint::new("b", 5),
}),
};
assert_eq!(slice_for_leaf(&sel, &order, "b", 10), Some((2, 5)));
assert_eq!(slice_for_leaf(&sel, &order, "a", 10), None);
assert_eq!(slice_for_leaf(&sel, &order, "c", 10), None);
}
#[test]
fn slice_for_leaf_cross_leaf_anchor_to_head_in_doc_order() {
let order = order_for(&["a", "b", "c"]);
let sel = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new("a", 2),
head: SelectionPoint::new("c", 4),
}),
};
assert_eq!(
slice_for_leaf(&sel, &order, "a", 10),
Some((2, 10)),
"anchor leaf: from anchor.byte to text_len"
);
assert_eq!(
slice_for_leaf(&sel, &order, "b", 8),
Some((0, 8)),
"middle leaf: fully selected"
);
assert_eq!(
slice_for_leaf(&sel, &order, "c", 10),
Some((0, 4)),
"head leaf: from 0 to head.byte"
);
}
#[test]
fn slice_for_leaf_cross_leaf_reversed_drag() {
let order = order_for(&["a", "b", "c"]);
let sel = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new("c", 3),
head: SelectionPoint::new("a", 1),
}),
};
assert_eq!(slice_for_leaf(&sel, &order, "a", 5), Some((1, 5)));
assert_eq!(slice_for_leaf(&sel, &order, "b", 6), Some((0, 6)));
assert_eq!(slice_for_leaf(&sel, &order, "c", 9), Some((0, 3)));
}
#[test]
fn slice_for_leaf_returns_none_for_leaves_outside_range() {
let order = order_for(&["a", "b", "c", "d", "e"]);
let sel = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new("b", 0),
head: SelectionPoint::new("d", 0),
}),
};
assert_eq!(slice_for_leaf(&sel, &order, "a", 10), None);
assert_eq!(slice_for_leaf(&sel, &order, "e", 10), None);
assert_eq!(slice_for_leaf(&sel, &order, "b", 4), Some((0, 4)));
assert_eq!(slice_for_leaf(&sel, &order, "c", 7), Some((0, 7)));
assert_eq!(slice_for_leaf(&sel, &order, "d", 5), None);
}
fn order_for(keys: &[&str]) -> Vec<crate::event::UiTarget> {
keys.iter()
.map(|k| crate::event::UiTarget {
key: (*k).to_string(),
node_id: format!("root.{k}"),
rect: crate::tree::Rect::new(0.0, 0.0, 0.0, 0.0),
})
.collect()
}
#[test]
fn selected_text_returns_none_for_empty_or_unknown_keys() {
let tree = crate::widgets::text::text("hi").key("p");
assert!(selected_text(&tree, &Selection::default()).is_none());
let unknown = Selection::caret("missing", 0);
assert!(selected_text(&tree, &unknown).is_none());
}
#[test]
fn word_range_at_picks_run_around_byte() {
let text = "Hello, world!";
assert_eq!(word_range_at(text, 0), (0, 5));
assert_eq!(word_range_at(text, 3), (0, 5));
assert_eq!(word_range_at(text, 5), (5, 6));
assert_eq!(word_range_at(text, 6), (6, 7));
assert_eq!(word_range_at(text, 7), (7, 12));
assert_eq!(word_range_at(text, 12), (12, 13));
}
#[test]
fn word_range_at_treats_apostrophe_and_underscore_as_word_chars() {
assert_eq!(word_range_at("don't stop", 2), (0, 5));
assert_eq!(word_range_at("foo_bar baz", 4), (0, 7));
}
#[test]
fn word_range_at_handles_end_of_text_and_empty() {
let text = "hello";
assert_eq!(word_range_at(text, 5), (0, 5));
assert_eq!(word_range_at("", 0), (0, 0));
}
#[test]
fn word_range_at_clamps_off_utf8_boundary() {
let text = "café";
let (lo, hi) = word_range_at(text, 1);
assert_eq!((lo, hi), (0, text.len()));
}
#[test]
fn line_range_at_returns_line_around_byte() {
let text = "first\nsecond line\nthird";
assert_eq!(line_range_at(text, 0), (0, 5));
assert_eq!(line_range_at(text, 3), (0, 5));
assert_eq!(line_range_at(text, 5), (0, 5));
assert_eq!(line_range_at(text, 6), (6, 17));
assert_eq!(line_range_at(text, 12), (6, 17));
assert_eq!(line_range_at(text, 17), (6, 17));
assert_eq!(line_range_at(text, 18), (18, 23));
assert_eq!(line_range_at(text, 23), (18, 23));
}
#[test]
fn line_range_at_handles_empty_and_single_line() {
assert_eq!(line_range_at("", 0), (0, 0));
assert_eq!(line_range_at("just one line", 4), (0, 13));
}
}