use std::ops::Range;
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,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SelectionSource {
pub source: String,
pub visible: String,
pub spans: Vec<SelectionSourceSpan>,
pub full_selection_group: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SelectionSourceSpan {
pub visible: Range<usize>,
pub source: Range<usize>,
pub source_full: Range<usize>,
pub atomic: bool,
}
impl SelectionSource {
pub fn new(source: impl Into<String>, visible: impl Into<String>) -> Self {
Self {
source: source.into(),
visible: visible.into(),
spans: Vec::new(),
full_selection_group: None,
}
}
pub fn identity(text: impl Into<String>) -> Self {
let text = text.into();
let len = text.len();
Self {
source: text.clone(),
visible: text,
spans: vec![SelectionSourceSpan {
visible: 0..len,
source: 0..len,
source_full: 0..len,
atomic: false,
}],
full_selection_group: None,
}
}
pub fn full_selection_group(mut self, group: impl Into<String>) -> Self {
self.full_selection_group = Some(group.into());
self
}
pub fn push_span(&mut self, visible: Range<usize>, source: Range<usize>, atomic: bool) {
self.push_span_with_full_source(visible, source.clone(), source, atomic);
}
pub fn push_span_with_full_source(
&mut self,
visible: Range<usize>,
source: Range<usize>,
source_full: Range<usize>,
atomic: bool,
) {
if visible.start <= visible.end
&& visible.end <= self.visible.len()
&& source.start <= source.end
&& source.end <= self.source.len()
&& source_full.start <= source_full.end
&& source_full.end <= self.source.len()
{
self.spans.push(SelectionSourceSpan {
visible,
source,
source_full,
atomic,
});
}
}
pub fn visible_len(&self) -> usize {
self.visible.len()
}
pub fn source_slice_for_visible(&self, a: usize, b: usize) -> Option<&str> {
let (a, b) = (a.min(b), a.max(b));
if a == 0 && b >= self.visible.len() && !self.source.is_empty() {
return Some(&self.source);
}
let a = clamp_to_char_boundary(&self.visible, a.min(self.visible.len()));
let b = clamp_to_char_boundary(&self.visible, b.min(self.visible.len()));
let lo = self.source_offset_for_visible(a, Bias::Start)?;
let hi = self.source_offset_for_visible(b, Bias::End)?;
let (lo, hi) = (lo.min(hi), lo.max(hi));
let lo = clamp_to_char_boundary(&self.source, lo.min(self.source.len()));
let hi = clamp_to_char_boundary(&self.source, hi.min(self.source.len()));
(lo < hi).then(|| &self.source[lo..hi])
}
pub fn source_text_for_visible(&self, a: usize, b: usize) -> Option<String> {
let (a, b) = (a.min(b), a.max(b));
if a == 0 && b >= self.visible.len() && !self.source.is_empty() {
return Some(self.source.clone());
}
let a = clamp_to_char_boundary(&self.visible, a.min(self.visible.len()));
let b = clamp_to_char_boundary(&self.visible, b.min(self.visible.len()));
if a >= b {
return None;
}
if self.spans.is_empty() {
return self.source_slice_for_visible(a, b).map(str::to_string);
}
let mut out = String::new();
for span in &self.spans {
let start = a.max(span.visible.start);
let end = b.min(span.visible.end);
if start >= end {
continue;
}
if span.atomic || (start == span.visible.start && end == span.visible.end) {
out.push_str(&self.source[span.source_full.clone()]);
continue;
}
let lo = source_offset_in_span(span, start, Bias::Start)?;
let hi = source_offset_in_span(span, end, Bias::End)?;
let (lo, hi) = (lo.min(hi), lo.max(hi));
let lo = clamp_to_char_boundary(&self.source, lo.min(self.source.len()));
let hi = clamp_to_char_boundary(&self.source, hi.min(self.source.len()));
if lo < hi {
out.push_str(&self.source[lo..hi]);
}
}
if out.is_empty() { None } else { Some(out) }
}
fn full_group_for_visible(&self, start: usize, end: usize) -> Option<&str> {
(start == 0 && end >= self.visible.len())
.then_some(self.full_selection_group.as_deref())
.flatten()
}
fn source_offset_for_visible(&self, byte: usize, bias: Bias) -> Option<usize> {
if self.spans.is_empty() {
return Some(byte.min(self.source.len()));
}
for span in &self.spans {
if byte < span.visible.start || byte > span.visible.end {
continue;
}
if byte == span.visible.end && byte != span.visible.start && matches!(bias, Bias::Start)
{
continue;
}
if span.atomic {
return Some(match bias {
Bias::Start => span.source.start,
Bias::End => span.source.end,
});
}
let visible_len = span.visible.end.saturating_sub(span.visible.start);
let source_len = span.source.end.saturating_sub(span.source.start);
if visible_len == 0 {
return Some(match bias {
Bias::Start => span.source.start,
Bias::End => span.source.end,
});
}
let offset = byte.saturating_sub(span.visible.start).min(visible_len);
let mapped = if source_len == visible_len {
span.source.start + offset
} else {
span.source.start
+ ((offset as f32 / visible_len as f32) * source_len as f32) as usize
};
return Some(mapped.min(span.source.end));
}
let first = self.spans.first()?;
if byte <= first.visible.start {
return Some(first.source.start);
}
let last = self.spans.last()?;
if byte >= last.visible.end {
return Some(last.source.end);
}
self.spans
.windows(2)
.find(|pair| byte > pair[0].visible.end && byte < pair[1].visible.start)
.map(|pair| match bias {
Bias::Start => pair[0].source.end,
Bias::End => pair[1].source.start,
})
}
}
fn source_offset_in_span(span: &SelectionSourceSpan, byte: usize, bias: Bias) -> Option<usize> {
if span.atomic {
return Some(match bias {
Bias::Start => span.source_full.start,
Bias::End => span.source_full.end,
});
}
let visible_len = span.visible.end.saturating_sub(span.visible.start);
let source_len = span.source.end.saturating_sub(span.source.start);
if visible_len == 0 {
return Some(match bias {
Bias::Start => span.source.start,
Bias::End => span.source.end,
});
}
let offset = byte.saturating_sub(span.visible.start).min(visible_len);
let mapped = if source_len == visible_len {
span.source.start + offset
} else {
span.source.start + ((offset as f32 / visible_len as f32) * source_len as f32) as usize
};
Some(mapped.min(span.source.end))
}
#[derive(Clone, Copy)]
enum Bias {
Start,
End,
}
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 {
if let Some(source) = find_keyed_selection_source(tree, &r.anchor.key) {
let lo = r.anchor.byte.min(r.head.byte);
let hi = r.anchor.byte.max(r.head.byte);
return source.source_text_for_visible(lo, hi);
}
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, LeafSelectionText)> = Vec::new();
collect_keyed_selection_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();
let mut last_group: Option<String> = None;
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.visible_len())
} else {
0
};
let end = if i == hi_idx {
hi_byte.min(value.visible_len())
} else {
value.visible_len()
};
if start >= end {
continue;
}
let Some(slice) = value.source_text_for_visible(start, end) else {
continue;
};
let group = value.full_group_for_visible(start, end).map(str::to_string);
if group.is_some() && group == last_group {
continue;
}
if !out.is_empty() {
out.push('\n');
}
out.push_str(&slice);
last_group = group;
}
if out.is_empty() { None } else { Some(out) }
}
pub(crate) fn find_keyed_text(node: &El, key: &str) -> Option<String> {
if node.key.as_deref() == Some(key) {
if let Some(source) = &node.selection_source {
return Some(source.visible.clone());
}
if matches!(node.kind, Kind::Text | Kind::Heading)
&& let Some(t) = &node.text
{
return Some(t.clone());
}
let mut out = String::new();
collect_text_content(node, &mut out);
if !out.is_empty() {
return Some(out);
}
}
node.children.iter().find_map(|c| find_keyed_text(c, key))
}
pub(crate) fn find_keyed_selection_source(node: &El, key: &str) -> Option<SelectionSource> {
if node.key.as_deref() == Some(key)
&& let Some(source) = &node.selection_source
{
return Some(source.clone());
}
node.children
.iter()
.find_map(|c| find_keyed_selection_source(c, key))
}
fn collect_text_content(node: &El, out: &mut String) {
if matches!(node.kind, Kind::Text | Kind::Heading)
&& let Some(t) = &node.text
{
out.push_str(t);
}
for c in &node.children {
collect_text_content(c, out);
}
}
enum LeafSelectionText {
Source(SelectionSource),
Text(String),
}
impl LeafSelectionText {
fn visible_len(&self) -> usize {
match self {
LeafSelectionText::Source(source) => source.visible_len(),
LeafSelectionText::Text(text) => text.len(),
}
}
fn source_text_for_visible(&self, start: usize, end: usize) -> Option<String> {
match self {
LeafSelectionText::Source(source) => source.source_text_for_visible(start, end),
LeafSelectionText::Text(text) => {
let start = start.min(text.len());
let end = end.min(text.len());
(start < end).then(|| text[start..end].to_string())
}
}
}
fn full_group_for_visible(&self, start: usize, end: usize) -> Option<&str> {
match self {
LeafSelectionText::Source(source) => source.full_group_for_visible(start, end),
LeafSelectionText::Text(_) => None,
}
}
}
fn collect_keyed_selection_leaves(node: &El, out: &mut Vec<(String, LeafSelectionText)>) {
if let (Some(k), Some(source)) = (&node.key, &node.selection_source) {
out.push((k.clone(), LeafSelectionText::Source(source.clone())));
return;
}
if matches!(node.kind, Kind::Text | Kind::Heading)
&& let (Some(k), Some(t)) = (&node.key, &node.text)
{
out.push((k.clone(), LeafSelectionText::Text(t.clone())));
}
for c in &node.children {
collect_keyed_selection_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_reads_text_inside_keyed_composite_widget() {
let sel = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new("name", 1),
head: SelectionPoint::new("name", 4),
}),
};
let tree = crate::widgets::text_input::text_input("hello", &sel, "name");
assert_eq!(selected_text(&tree, &sel).as_deref(), Some("ell"));
}
#[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 selected_text_uses_source_payload_for_single_leaf() {
let mut source = SelectionSource::new("This is **bold**.", "This is bold.");
source.push_span(0..8, 0..8, false);
source.push_span_with_full_source(8..12, 10..14, 8..16, false);
source.push_span(12..13, 16..17, false);
let tree = crate::text_runs([crate::text("This is "), crate::text("bold").bold()])
.key("md:p")
.selectable()
.selection_source(source);
let inner_only = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new("md:p", 8),
head: SelectionPoint::new("md:p", 12),
}),
};
assert_eq!(
selected_text(&tree, &inner_only).as_deref(),
Some("**bold**")
);
let partial_inner = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new("md:p", 9),
head: SelectionPoint::new("md:p", 11),
}),
};
assert_eq!(selected_text(&tree, &partial_inner).as_deref(), Some("ol"));
let through_styled_span = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new("md:p", 0),
head: SelectionPoint::new("md:p", 12),
}),
};
assert_eq!(
selected_text(&tree, &through_styled_span).as_deref(),
Some("This is **bold**")
);
let whole = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new("md:p", 0),
head: SelectionPoint::new("md:p", 13),
}),
};
assert_eq!(
selected_text(&tree, &whole).as_deref(),
Some("This is **bold**.")
);
}
#[test]
fn selected_text_dedupes_adjacent_full_source_group_leaves() {
let mut first = SelectionSource::new("| **Ada** | dev |", "Ada");
first.push_span_with_full_source(0..3, 4..7, 0..17, false);
let first = first.full_selection_group("row:0");
let mut second = SelectionSource::new("| **Ada** | dev |", "dev");
second.push_span_with_full_source(0..3, 12..15, 0..17, false);
let second = second.full_selection_group("row:0");
let tree = crate::row([
crate::text("Ada")
.key("a")
.selectable()
.selection_source(first),
crate::text("dev")
.key("b")
.selectable()
.selection_source(second),
]);
let sel = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new("a", 0),
head: SelectionPoint::new("b", 3),
}),
};
assert_eq!(
selected_text(&tree, &sel).as_deref(),
Some("| **Ada** | dev |")
);
}
#[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),
tooltip: None,
scroll_offset_y: 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));
}
}