use azul_core::selection::{CursorAffinity, GraphemeClusterId, SelectionRange, TextCursor};
use crate::text3::cache::{
is_word_char, PositionedItem, ShapedCluster, ShapedItem, UnifiedLayout,
};
#[must_use] pub fn select_word_at_cursor(
cursor: &TextCursor,
layout: &UnifiedLayout,
) -> Option<SelectionRange> {
let (item_idx, _cluster) = find_cluster_at_cursor(cursor, layout)?;
let (line_text, cluster_map) = extract_line_text_and_clusters(item_idx, layout);
let cursor_byte_offset = cluster_map
.iter()
.take_while(|(id, _)| *id != cursor.cluster_id)
.map(|(_, len)| len)
.sum::<usize>();
let (word_start, word_end) = find_word_boundaries(&line_text, cursor_byte_offset);
let start_cluster_id = byte_offset_to_cluster_id(&cluster_map, word_start)?;
let end_cluster_id = byte_offset_to_cluster_id(&cluster_map, word_end.saturating_sub(1))
.unwrap_or(start_cluster_id);
Some(SelectionRange {
start: TextCursor {
cluster_id: start_cluster_id,
affinity: CursorAffinity::Leading,
},
end: TextCursor {
cluster_id: end_cluster_id,
affinity: CursorAffinity::Trailing,
},
})
}
#[must_use] pub fn select_paragraph_at_cursor(
cursor: &TextCursor,
layout: &UnifiedLayout,
) -> Option<SelectionRange> {
let (item_idx, _) = find_cluster_at_cursor(cursor, layout)?;
let item = &layout.items[item_idx];
let line_index = item.line_index;
let line_items: Vec<(usize, &PositionedItem)> = layout
.items
.iter()
.enumerate()
.filter(|(_, item)| item.line_index == line_index)
.collect();
if line_items.is_empty() {
return None;
}
let first_cluster = line_items
.iter()
.find_map(|(_, item)| item.item.as_cluster())?;
let last_cluster = line_items
.iter()
.rev()
.find_map(|(_, item)| item.item.as_cluster())?;
Some(SelectionRange {
start: TextCursor {
cluster_id: first_cluster.source_cluster_id,
affinity: CursorAffinity::Leading,
},
end: TextCursor {
cluster_id: last_cluster.source_cluster_id,
affinity: CursorAffinity::Trailing,
},
})
}
fn find_cluster_at_cursor<'a>(
cursor: &TextCursor,
layout: &'a UnifiedLayout,
) -> Option<(usize, &'a ShapedCluster)> {
layout.items.iter().enumerate().find_map(|(idx, item)| {
if let ShapedItem::Cluster(cluster) = &item.item {
if cluster.source_cluster_id == cursor.cluster_id {
return Some((idx, cluster));
}
}
None
})
}
fn extract_line_text_and_clusters(
item_idx: usize,
layout: &UnifiedLayout,
) -> (String, Vec<(GraphemeClusterId, usize)>) {
let Some(source_run) = layout.items[item_idx]
.item
.as_cluster()
.map(|c| c.source_cluster_id.source_run)
else {
return (String::new(), Vec::new());
};
let mut clusters: Vec<&ShapedCluster> = layout
.items
.iter()
.filter_map(|item| item.item.as_cluster())
.filter(|c| c.source_cluster_id.source_run == source_run)
.collect();
clusters.sort_by_key(|c| c.source_cluster_id.start_byte_in_run);
let mut text = String::new();
let mut cluster_map = Vec::new();
for c in clusters {
let s = c.text.as_str();
cluster_map.push((c.source_cluster_id, s.len()));
text.push_str(s);
}
(text, cluster_map)
}
fn byte_offset_to_cluster_id(
cluster_map: &[(GraphemeClusterId, usize)],
byte_offset: usize,
) -> Option<GraphemeClusterId> {
let mut cumulative = 0;
for (id, len) in cluster_map {
if byte_offset < cumulative + len {
return Some(*id);
}
cumulative += len;
}
cluster_map.last().map(|(id, _)| *id)
}
fn find_word_boundaries(text: &str, cursor_offset: usize) -> (usize, usize) {
let cursor_offset = cursor_offset.min(text.len());
let mut word_start = 0;
let char_indices: Vec<(usize, char)> = text.char_indices().collect();
for (i, (byte_idx, ch)) in char_indices.iter().enumerate().rev() {
if *byte_idx >= cursor_offset {
continue;
}
if !is_word_char(*ch) {
word_start = if i + 1 < char_indices.len() {
char_indices[i + 1].0
} else {
text.len()
};
break;
}
}
let mut word_end = text.len();
for (byte_idx, ch) in &char_indices {
if *byte_idx <= cursor_offset {
continue;
}
if !is_word_char(*ch) {
word_end = *byte_idx;
break;
}
}
if let Some((_, ch)) = char_indices.iter().find(|(idx, _)| *idx == cursor_offset) {
if !is_word_char(*ch) {
let start = char_indices
.iter()
.rev()
.find(|(idx, c)| *idx < cursor_offset && is_word_char(*c))
.map_or(0, |(idx, c)| idx + c.len_utf8());
let end = char_indices
.iter()
.find(|(idx, c)| *idx > cursor_offset && is_word_char(*c))
.map_or(text.len(), |(idx, _)| *idx);
return (start, end);
}
}
(word_start, word_end)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_word_boundaries_simple() {
let text = "Hello World";
let (start, end) = find_word_boundaries(text, 2);
assert_eq!(&text[start..end], "Hello");
let (start, end) = find_word_boundaries(text, 7);
assert_eq!(&text[start..end], "World");
let (start, end) = find_word_boundaries(text, 5);
assert_eq!(&text[start..end], " ");
}
#[test]
fn test_word_boundaries_start_end() {
let text = "Hello";
let (start, end) = find_word_boundaries(text, 0);
assert_eq!(&text[start..end], "Hello");
let (start, end) = find_word_boundaries(text, 5);
assert_eq!(&text[start..end], "Hello");
}
#[test]
fn test_word_boundaries_punctuation() {
let text = "Hello, World!";
let (start, end) = find_word_boundaries(text, 2);
assert_eq!(&text[start..end], "Hello");
let (start, end) = find_word_boundaries(text, 5);
assert_eq!(&text[start..end], ", ");
let (start, end) = find_word_boundaries(text, 8);
assert_eq!(&text[start..end], "World");
}
#[test]
fn test_word_boundaries_underscore() {
let text = "hello_world";
let (start, end) = find_word_boundaries(text, 5);
assert_eq!(&text[start..end], "hello_world");
}
#[test]
fn test_is_word_char() {
assert!(is_word_char('a'));
assert!(is_word_char('Z'));
assert!(is_word_char('0'));
assert!(is_word_char('_'));
assert!(!is_word_char(' '));
assert!(!is_word_char(','));
assert!(!is_word_char('!'));
}
#[test]
fn test_word_boundaries_empty() {
let (start, end) = find_word_boundaries("", 0);
assert_eq!(start, 0);
assert_eq!(end, 0);
}
#[test]
fn test_byte_offset_to_cluster_id_basic() {
let id0 = GraphemeClusterId { source_run: 0, start_byte_in_run: 0 };
let id1 = GraphemeClusterId { source_run: 0, start_byte_in_run: 5 };
let id2 = GraphemeClusterId { source_run: 0, start_byte_in_run: 6 };
let map = vec![(id0, 5), (id1, 1), (id2, 5)];
assert_eq!(byte_offset_to_cluster_id(&map, 0), Some(id0));
assert_eq!(byte_offset_to_cluster_id(&map, 4), Some(id0));
assert_eq!(byte_offset_to_cluster_id(&map, 5), Some(id1));
assert_eq!(byte_offset_to_cluster_id(&map, 6), Some(id2));
assert_eq!(byte_offset_to_cluster_id(&map, 100), Some(id2));
}
}
#[cfg(test)]
#[allow(
clippy::cast_possible_truncation,
clippy::similar_names,
clippy::too_many_lines
)]
mod autotest_generated {
use std::sync::Arc;
use azul_core::selection::ContentIndex;
use super::*;
use crate::text3::cache::{
BidiDirection, OverflowInfo, Point, Rect, ShapedGlyphVec, StyleProperties,
};
const fn gid(run: u32, byte: u32) -> GraphemeClusterId {
GraphemeClusterId {
source_run: run,
start_byte_in_run: byte,
}
}
const fn ci(run: u32, item: u32) -> ContentIndex {
ContentIndex {
run_index: run,
item_index: item,
}
}
fn cluster(text: &str, id: GraphemeClusterId) -> ShapedCluster {
ShapedCluster {
text: text.to_string(),
source_cluster_id: id,
source_content_index: ci(id.source_run, id.start_byte_in_run),
source_node_id: None,
glyphs: ShapedGlyphVec::new(),
advance: 10.0,
direction: BidiDirection::Ltr,
style: Arc::new(StyleProperties::default()),
marker_position_outside: None,
is_first_fragment: true,
is_last_fragment: true,
}
}
fn cl(text: &str, id: GraphemeClusterId, line: usize) -> PositionedItem {
PositionedItem {
item: ShapedItem::Cluster(cluster(text, id)),
position: Point::default(),
line_index: line,
}
}
fn tab(line: usize) -> PositionedItem {
PositionedItem {
item: ShapedItem::Tab {
source: ci(0, 0),
bounds: Rect::default(),
},
position: Point::default(),
line_index: line,
}
}
fn layout_of(items: Vec<PositionedItem>) -> UnifiedLayout {
UnifiedLayout {
items,
overflow: OverflowInfo::default(),
}
}
fn layout_from_str(text: &str, run: u32) -> UnifiedLayout {
layout_of(
text.char_indices()
.map(|(byte_idx, ch)| {
let mut buf = [0u8; 4];
cl(ch.encode_utf8(&mut buf), gid(run, byte_idx as u32), 0)
})
.collect(),
)
}
const fn cursor_at(id: GraphemeClusterId) -> TextCursor {
TextCursor {
cluster_id: id,
affinity: CursorAffinity::Leading,
}
}
const NASTY: &[&str] = &[
"",
" ",
"_",
"a",
"!",
"Hello World",
"Hello, World!",
" ",
"ab ",
" ab",
"héllo wörld",
"日本語のテキスト",
"👍👍",
"a👍b",
"مرحبا بالعالم",
"a\u{00A0}b",
"a\u{0301}b",
"!!!???",
"foo_bar42",
"\n\t\r ",
];
#[test]
fn word_boundaries_empty_text_at_any_offset_is_zero_zero() {
for off in [0, 1, 7, usize::MAX / 2, usize::MAX] {
assert_eq!(
find_word_boundaries("", off),
(0, 0),
"empty text must collapse to (0, 0) for offset {off}"
);
}
}
#[test]
fn word_boundaries_usize_max_offset_is_clamped_to_text_len() {
let text = "Hello World";
let at_max = find_word_boundaries(text, usize::MAX);
let at_len = find_word_boundaries(text, text.len());
assert_eq!(at_max, at_len, "usize::MAX must clamp to text.len()");
assert_eq!(&text[at_max.0..at_max.1], "World");
}
#[test]
fn word_boundaries_invariants_hold_for_every_offset_of_nasty_unicode() {
for &text in NASTY {
let probes = (0..=text.len() + 4)
.chain([usize::MAX - 1, usize::MAX])
.collect::<Vec<_>>();
for off in probes {
let (start, end) = find_word_boundaries(text, off);
assert!(
start <= end,
"{text:?} @ {off}: start {start} > end {end} (inverted range)"
);
assert!(
end <= text.len(),
"{text:?} @ {off}: end {end} past len {}",
text.len()
);
assert!(
text.is_char_boundary(start),
"{text:?} @ {off}: start {start} splits a char"
);
assert!(
text.is_char_boundary(end),
"{text:?} @ {off}: end {end} splits a char"
);
let _slice = &text[start..end];
}
}
}
#[test]
fn word_boundaries_offset_inside_multibyte_char_does_not_split_it() {
let text = "héllo";
let (start, end) = find_word_boundaries(text, 2);
assert_eq!(&text[start..end], "héllo");
let text = "a\u{00A0}b";
let (start, end) = find_word_boundaries(text, 2);
assert!(text.is_char_boundary(start) && text.is_char_boundary(end));
assert_eq!(&text[start..end], "b");
}
#[test]
fn word_boundaries_offset_past_trailing_boundary_char_yields_empty_range() {
let text = "ab ";
assert_eq!(find_word_boundaries(text, 3), (3, 3));
assert_eq!(&text[3..3], "");
}
#[test]
fn word_boundaries_combining_mark_splits_a_word() {
let decomposed = "a\u{0301}b"; let (start, end) = find_word_boundaries(decomposed, 0);
assert_eq!(
&decomposed[start..end],
"a",
"combining mark is treated as a word boundary"
);
let precomposed = "áb";
let (start, end) = find_word_boundaries(precomposed, 0);
assert_eq!(&precomposed[start..end], "áb");
}
#[test]
fn word_boundaries_emoji_is_a_boundary_char_cjk_is_a_word_char() {
let emoji = "👍👍";
assert_eq!(find_word_boundaries(emoji, 0), (0, emoji.len()));
let cjk = "日本語";
let (start, end) = find_word_boundaries(cjk, 3);
assert_eq!(&cjk[start..end], "日本語");
let mixed = "a👍b";
let (start, end) = find_word_boundaries(mixed, 0);
assert_eq!(&mixed[start..end], "a");
}
#[test]
fn word_boundaries_all_boundary_chars_selects_the_whole_run() {
let text = "!!!???";
assert_eq!(find_word_boundaries(text, 0), (0, 6));
assert_eq!(find_word_boundaries(text, 3), (0, 6));
assert_eq!(find_word_boundaries(text, 5), (0, 6));
}
#[test]
fn word_boundaries_huge_text_with_max_offset_does_not_overflow() {
let text = "a".repeat(64 * 1024);
let (start, end) = find_word_boundaries(&text, usize::MAX);
assert_eq!((start, end), (0, text.len()));
let sep = " ".repeat(64 * 1024);
let (start, end) = find_word_boundaries(&sep, usize::MAX);
assert!(start <= end && end <= sep.len());
}
#[test]
fn byte_offset_to_cluster_id_empty_map_is_none_for_every_offset() {
for off in [0, 1, usize::MAX / 2, usize::MAX] {
assert_eq!(byte_offset_to_cluster_id(&[], off), None);
}
}
#[test]
fn byte_offset_to_cluster_id_non_empty_map_is_always_some() {
let map = [(gid(0, 0), 3), (gid(0, 3), 1), (gid(0, 4), 2)];
let total: usize = map.iter().map(|(_, l)| l).sum();
for off in (0..=total + 8).chain([usize::MAX - 1, usize::MAX]) {
assert!(
byte_offset_to_cluster_id(&map, off).is_some(),
"offset {off} returned None for a non-empty map"
);
}
assert_eq!(byte_offset_to_cluster_id(&map, 0), Some(gid(0, 0)));
assert_eq!(byte_offset_to_cluster_id(&map, total - 1), Some(gid(0, 4)));
assert_eq!(byte_offset_to_cluster_id(&map, usize::MAX), Some(gid(0, 4)));
}
#[test]
fn byte_offset_to_cluster_id_zero_length_clusters_are_skipped() {
let map = [(gid(0, 0), 0), (gid(0, 1), 2), (gid(0, 3), 0)];
assert_eq!(
byte_offset_to_cluster_id(&map, 0),
Some(gid(0, 1)),
"leading zero-length cluster must be skipped, not returned"
);
assert_eq!(byte_offset_to_cluster_id(&map, 1), Some(gid(0, 1)));
assert_eq!(byte_offset_to_cluster_id(&map, 2), Some(gid(0, 3)));
}
#[test]
fn byte_offset_to_cluster_id_all_zero_length_map_returns_last_never_none() {
let map = [(gid(0, 0), 0), (gid(0, 1), 0), (gid(0, 2), 0)];
for off in [0, 1, usize::MAX] {
assert_eq!(byte_offset_to_cluster_id(&map, off), Some(gid(0, 2)));
}
}
#[test]
fn byte_offset_to_cluster_id_huge_lengths_do_not_overflow() {
let single = [(gid(0, 0), usize::MAX)];
assert_eq!(byte_offset_to_cluster_id(&single, 0), Some(gid(0, 0)));
assert_eq!(
byte_offset_to_cluster_id(&single, usize::MAX - 1),
Some(gid(0, 0))
);
assert_eq!(
byte_offset_to_cluster_id(&single, usize::MAX),
Some(gid(0, 0))
);
let half = usize::MAX / 2; let pair = [(gid(0, 0), half), (gid(0, 1), half)];
assert_eq!(byte_offset_to_cluster_id(&pair, 0), Some(gid(0, 0)));
assert_eq!(byte_offset_to_cluster_id(&pair, half - 1), Some(gid(0, 0)));
assert_eq!(byte_offset_to_cluster_id(&pair, half), Some(gid(0, 1)));
assert_eq!(byte_offset_to_cluster_id(&pair, usize::MAX), Some(gid(0, 1)));
}
#[test]
fn find_cluster_at_cursor_empty_layout_is_none() {
let layout = layout_of(vec![]);
assert!(find_cluster_at_cursor(&cursor_at(gid(0, 0)), &layout).is_none());
assert!(find_cluster_at_cursor(&cursor_at(gid(u32::MAX, u32::MAX)), &layout).is_none());
}
#[test]
fn find_cluster_at_cursor_unknown_id_is_none() {
let layout = layout_from_str("abc", 0);
assert!(find_cluster_at_cursor(&cursor_at(gid(0, 99)), &layout).is_none());
assert!(find_cluster_at_cursor(&cursor_at(gid(7, 0)), &layout).is_none());
assert!(find_cluster_at_cursor(&cursor_at(gid(u32::MAX, u32::MAX)), &layout).is_none());
}
#[test]
fn find_cluster_at_cursor_skips_non_cluster_items_and_reports_visual_index() {
let layout = layout_of(vec![tab(0), cl("a", gid(0, 0), 0), tab(0)]);
let (idx, found) = find_cluster_at_cursor(&cursor_at(gid(0, 0)), &layout).unwrap();
assert_eq!(idx, 1, "index must be into layout.items, skipping the tab");
assert_eq!(found.text, "a");
}
#[test]
fn find_cluster_at_cursor_duplicate_ids_return_the_first_match() {
let layout = layout_of(vec![cl("x", gid(0, 0), 0), cl("y", gid(0, 0), 1)]);
let (idx, found) = find_cluster_at_cursor(&cursor_at(gid(0, 0)), &layout).unwrap();
assert_eq!(idx, 0);
assert_eq!(found.text, "x");
}
#[test]
#[should_panic(expected = "index out of bounds")]
fn extract_line_text_out_of_bounds_index_panics_on_empty_layout() {
let layout = layout_of(vec![]);
let _ = extract_line_text_and_clusters(0, &layout);
}
#[test]
#[should_panic(expected = "index out of bounds")]
fn extract_line_text_usize_max_index_panics() {
let layout = layout_from_str("abc", 0);
let _ = extract_line_text_and_clusters(usize::MAX, &layout);
}
#[test]
fn extract_line_text_non_cluster_item_yields_empty_text_and_map() {
let layout = layout_of(vec![tab(0), cl("a", gid(0, 0), 0)]);
let (text, map) = extract_line_text_and_clusters(0, &layout);
assert!(text.is_empty());
assert!(map.is_empty());
}
#[test]
fn extract_line_text_zero_index_on_a_cluster_gathers_the_whole_run() {
let layout = layout_from_str("hi there", 0);
let (text, map) = extract_line_text_and_clusters(0, &layout);
assert_eq!(text, "hi there");
assert_eq!(map.len(), 8);
assert_eq!(map[0], (gid(0, 0), 1));
}
#[test]
fn extract_line_text_restores_logical_order_from_reversed_visual_items() {
let layout = layout_of(vec![
cl("o", gid(0, 4), 0),
cl("l", gid(0, 3), 0),
cl("l", gid(0, 2), 0),
cl("e", gid(0, 1), 0),
cl("H", gid(0, 0), 0),
]);
let (text, map) = extract_line_text_and_clusters(0, &layout);
assert_eq!(text, "Hello", "visual order must not leak into the text");
assert_eq!(
map.iter().map(|(id, _)| id.start_byte_in_run).collect::<Vec<_>>(),
vec![0, 1, 2, 3, 4]
);
}
#[test]
fn extract_line_text_crosses_visual_lines_and_excludes_other_runs() {
let layout = layout_of(vec![
cl("H", gid(0, 0), 0),
cl("e", gid(0, 1), 0),
cl("l", gid(0, 2), 1), cl("X", gid(1, 0), 1), cl("o", gid(0, 3), 2), ]);
let (text, map) = extract_line_text_and_clusters(0, &layout);
assert_eq!(text, "Helo");
assert_eq!(map.len(), 4);
assert!(map.iter().all(|(id, _)| id.source_run == 0));
}
#[test]
fn extract_line_text_byte_lengths_are_utf8_lengths_not_char_counts() {
let layout = layout_from_str("é日👍", 0);
let (text, map) = extract_line_text_and_clusters(0, &layout);
assert_eq!(text, "é日👍");
assert_eq!(
map.iter().map(|(_, len)| *len).collect::<Vec<_>>(),
vec![2, 3, 4]
);
assert_eq!(map.iter().map(|(_, l)| l).sum::<usize>(), text.len());
}
#[test]
fn select_word_empty_layout_is_none() {
let layout = layout_of(vec![]);
assert!(select_word_at_cursor(&cursor_at(gid(0, 0)), &layout).is_none());
}
#[test]
fn select_word_unknown_cursor_is_none() {
let layout = layout_from_str("Hello", 0);
assert!(select_word_at_cursor(&cursor_at(gid(3, 0)), &layout).is_none());
assert!(select_word_at_cursor(&cursor_at(gid(0, u32::MAX)), &layout).is_none());
}
#[test]
fn select_word_selects_the_word_under_the_cursor_with_correct_affinities() {
let layout = layout_from_str("Hello World", 0);
let range = select_word_at_cursor(&cursor_at(gid(0, 1)), &layout).unwrap();
assert_eq!(range.start.cluster_id, gid(0, 0), "start of \"Hello\"");
assert_eq!(range.end.cluster_id, gid(0, 4), "last cluster of \"Hello\"");
assert_eq!(range.start.affinity, CursorAffinity::Leading);
assert_eq!(range.end.affinity, CursorAffinity::Trailing);
let range = select_word_at_cursor(&cursor_at(gid(0, 8)), &layout).unwrap();
assert_eq!(range.start.cluster_id, gid(0, 6));
assert_eq!(range.end.cluster_id, gid(0, 10));
}
#[test]
fn select_word_spans_a_soft_wrap() {
let layout = layout_of(vec![
cl("H", gid(0, 0), 0),
cl("e", gid(0, 1), 0),
cl("l", gid(0, 2), 0),
cl("l", gid(0, 3), 1), cl("o", gid(0, 4), 1),
]);
let range = select_word_at_cursor(&cursor_at(gid(0, 1)), &layout).unwrap();
assert_eq!(range.start.cluster_id, gid(0, 0));
assert_eq!(range.end.cluster_id, gid(0, 4), "must cross the line break");
}
#[test]
fn select_word_uses_logical_not_visual_order() {
let layout = layout_of(vec![
cl("o", gid(0, 4), 0),
cl("l", gid(0, 3), 0),
cl("l", gid(0, 2), 0),
cl("e", gid(0, 1), 0),
cl("H", gid(0, 0), 0),
]);
let range = select_word_at_cursor(&cursor_at(gid(0, 3)), &layout).unwrap();
assert_eq!(range.start.cluster_id, gid(0, 0));
assert_eq!(range.end.cluster_id, gid(0, 4));
}
#[test]
fn select_word_is_idempotent_from_its_own_start_cursor() {
for text in ["Hello, World! foo_bar 42", "a b", "héllo wörld", "!!!a"] {
let layout = layout_from_str(text, 0);
for (byte_idx, _) in text.char_indices() {
let cur = cursor_at(gid(0, byte_idx as u32));
let first = select_word_at_cursor(&cur, &layout)
.unwrap_or_else(|| panic!("{text:?} @ {byte_idx}: no selection"));
let again = select_word_at_cursor(&first.start, &layout)
.unwrap_or_else(|| panic!("{text:?} @ {byte_idx}: re-select failed"));
assert_eq!(
first, again,
"{text:?} @ {byte_idx}: selection is not a fixpoint"
);
}
}
}
#[test]
fn select_word_invariants_hold_for_every_cursor_of_nasty_unicode() {
for &text in NASTY {
let layout = layout_from_str(text, 0);
let ids: Vec<GraphemeClusterId> = text
.char_indices()
.map(|(b, _)| gid(0, b as u32))
.collect();
for id in &ids {
let range = select_word_at_cursor(&cursor_at(*id), &layout)
.unwrap_or_else(|| panic!("{text:?} @ {id:?}: expected a selection"));
assert!(
range.start.cluster_id <= range.end.cluster_id,
"{text:?} @ {id:?}: inverted range {range:?}"
);
assert!(
ids.contains(&range.start.cluster_id),
"{text:?} @ {id:?}: start is not a cluster of the layout"
);
assert!(
ids.contains(&range.end.cluster_id),
"{text:?} @ {id:?}: end is not a cluster of the layout"
);
assert_eq!(range.start.affinity, CursorAffinity::Leading);
assert_eq!(range.end.affinity, CursorAffinity::Trailing);
}
}
}
#[test]
fn select_word_on_zero_length_cluster_resolves_to_a_neighbour() {
let layout = layout_of(vec![cl("", gid(0, 0), 0), cl("x", gid(0, 1), 0)]);
let range = select_word_at_cursor(&cursor_at(gid(0, 0)), &layout).unwrap();
assert_eq!(range.start.cluster_id, gid(0, 1));
assert_eq!(range.end.cluster_id, gid(0, 1));
}
#[test]
fn select_word_all_clusters_empty_does_not_panic() {
let layout = layout_of(vec![cl("", gid(0, 0), 0), cl("", gid(0, 1), 0)]);
let range = select_word_at_cursor(&cursor_at(gid(0, 0)), &layout).unwrap();
assert_eq!(range.start.cluster_id, gid(0, 1));
assert_eq!(range.end.cluster_id, gid(0, 1));
}
#[test]
fn select_word_with_inconsistent_cluster_metadata_does_not_panic() {
let layout = layout_of(vec![
cl("abc", gid(0, 0), 0), cl("def", gid(0, 1), 0),
cl("👍", gid(0, 2), 0), ]);
for id in [gid(0, 0), gid(0, 1), gid(0, 2)] {
let range = select_word_at_cursor(&cursor_at(id), &layout);
assert!(range.is_some(), "{id:?} must still resolve");
}
}
#[test]
fn select_word_large_layout_stays_correct_and_does_not_panic() {
let text = "word ".repeat(800);
let layout = layout_from_str(&text, 0);
let word_start = 400 * 5;
let range = select_word_at_cursor(&cursor_at(gid(0, word_start as u32 + 2)), &layout)
.expect("mid-word cursor must select");
assert_eq!(range.start.cluster_id, gid(0, word_start as u32));
assert_eq!(range.end.cluster_id, gid(0, word_start as u32 + 3));
let sep = word_start + 4;
let range = select_word_at_cursor(&cursor_at(gid(0, sep as u32)), &layout)
.expect("separator cursor must select");
assert_eq!(range.start.cluster_id, gid(0, sep as u32));
assert_eq!(range.end.cluster_id, gid(0, sep as u32));
}
#[test]
fn select_paragraph_empty_layout_is_none() {
let layout = layout_of(vec![]);
assert!(select_paragraph_at_cursor(&cursor_at(gid(0, 0)), &layout).is_none());
}
#[test]
fn select_paragraph_unknown_cursor_is_none() {
let layout = layout_from_str("abc", 0);
assert!(select_paragraph_at_cursor(&cursor_at(gid(9, 9)), &layout).is_none());
assert!(
select_paragraph_at_cursor(&cursor_at(gid(u32::MAX, u32::MAX)), &layout).is_none()
);
}
#[test]
fn select_paragraph_covers_only_the_cursors_line() {
let layout = layout_of(vec![
cl("a", gid(0, 0), 0),
cl("b", gid(0, 1), 0),
cl("c", gid(0, 2), 1),
cl("d", gid(0, 3), 1),
]);
let range = select_paragraph_at_cursor(&cursor_at(gid(0, 3)), &layout).unwrap();
assert_eq!(range.start.cluster_id, gid(0, 2), "line 1 starts at 'c'");
assert_eq!(range.end.cluster_id, gid(0, 3));
assert_eq!(range.start.affinity, CursorAffinity::Leading);
assert_eq!(range.end.affinity, CursorAffinity::Trailing);
let range = select_paragraph_at_cursor(&cursor_at(gid(0, 0)), &layout).unwrap();
assert_eq!(range.start.cluster_id, gid(0, 0));
assert_eq!(range.end.cluster_id, gid(0, 1), "must not spill onto line 1");
}
#[test]
fn select_paragraph_ignores_non_cluster_items_at_the_line_edges() {
let layout = layout_of(vec![
tab(0),
cl("a", gid(0, 0), 0),
cl("b", gid(0, 1), 0),
tab(0),
]);
let range = select_paragraph_at_cursor(&cursor_at(gid(0, 1)), &layout).unwrap();
assert_eq!(range.start.cluster_id, gid(0, 0));
assert_eq!(range.end.cluster_id, gid(0, 1));
}
#[test]
fn select_paragraph_handles_saturated_line_index() {
let layout = layout_of(vec![
cl("a", gid(0, 0), 0),
cl("b", gid(0, 1), usize::MAX),
cl("c", gid(0, 2), usize::MAX),
]);
let range = select_paragraph_at_cursor(&cursor_at(gid(0, 2)), &layout).unwrap();
assert_eq!(range.start.cluster_id, gid(0, 1));
assert_eq!(range.end.cluster_id, gid(0, 2));
}
#[test]
fn select_paragraph_returns_a_logically_inverted_range_for_reordered_runs() {
let layout = layout_of(vec![
cl("o", gid(0, 4), 0),
cl("l", gid(0, 3), 0),
cl("l", gid(0, 2), 0),
cl("e", gid(0, 1), 0),
cl("H", gid(0, 0), 0),
]);
let range = select_paragraph_at_cursor(&cursor_at(gid(0, 2)), &layout).unwrap();
assert_eq!(range.start.cluster_id, gid(0, 4), "visually-first cluster");
assert_eq!(range.end.cluster_id, gid(0, 0), "visually-last cluster");
assert!(
range.start.cluster_id > range.end.cluster_id,
"pinned: the range is logically inverted for visual order"
);
}
#[test]
fn select_paragraph_is_some_for_every_reachable_cursor() {
for &text in NASTY {
let layout = layout_from_str(text, 0);
for (byte_idx, _) in text.char_indices() {
let cur = cursor_at(gid(0, byte_idx as u32));
assert!(
select_paragraph_at_cursor(&cur, &layout).is_some(),
"{text:?} @ {byte_idx}: cursor found a cluster but no paragraph"
);
}
}
}
}