use azul_core::selection::{
CursorAffinity, GraphemeClusterId, Selection, SelectionRange, TextCursor,
};
use crate::text3::cache::{InlineContent, StyledRun};
#[derive(Debug, Clone)]
pub enum TextEdit {
Insert(String),
DeleteBackward,
DeleteForward,
}
const fn selection_start_run(selection: &Selection) -> u32 {
match selection {
Selection::Cursor(c) => c.cluster_id.source_run,
Selection::Range(r) => r.start.cluster_id.source_run,
}
}
const fn selection_start_byte(selection: &Selection) -> u32 {
match selection {
Selection::Cursor(c) => c.cluster_id.start_byte_in_run,
Selection::Range(r) => r.start.cluster_id.start_byte_in_run,
}
}
fn sort_selections_back_to_front(selections: &[Selection]) -> Vec<Selection> {
let mut sorted = selections.to_vec();
sorted.sort_by(|a, b| {
let cursor_a = match a {
Selection::Cursor(c) => c,
Selection::Range(r) => &r.start,
};
let cursor_b = match b {
Selection::Cursor(c) => c,
Selection::Range(r) => &r.start,
};
cursor_b.cluster_id.cmp(&cursor_a.cluster_id) });
sorted
}
#[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)] fn adjust_cursors(
selections: &mut [Selection],
edit_run: u32,
edit_byte: u32,
byte_offset_change: i32,
) {
for sel in selections.iter_mut() {
if let Selection::Cursor(cursor) = sel {
if cursor.cluster_id.source_run == edit_run
&& cursor.cluster_id.start_byte_in_run >= edit_byte
{
cursor.cluster_id.start_byte_in_run =
(cursor.cluster_id.start_byte_in_run as i32 + byte_offset_change).max(0) as u32;
}
}
}
}
#[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)] fn adjust_cursor_runs(selections: &mut [Selection], boundary_run: u32, run_count_change: i32) {
if run_count_change == 0 {
return;
}
for sel in selections.iter_mut() {
if let Selection::Cursor(cursor) = sel {
if cursor.cluster_id.source_run > boundary_run {
let shifted = (cursor.cluster_id.source_run as i32 + run_count_change)
.max(boundary_run as i32);
cursor.cluster_id.source_run = shifted as u32;
}
}
}
}
fn run_text_len(content: &[InlineContent], run_idx: u32) -> usize {
match content.get(run_idx as usize) {
Some(InlineContent::Text(run)) => run.text.len(),
_ => 0,
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] #[must_use] pub fn edit_text(
content: &[InlineContent],
selections: &[Selection],
edit: &TextEdit,
) -> (Vec<InlineContent>, Vec<Selection>) {
if selections.is_empty() {
return (content.to_vec(), Vec::new());
}
let mut new_content = content.to_vec();
let mut new_selections = Vec::new();
let sorted_selections = sort_selections_back_to_front(selections);
for selection in sorted_selections {
let edit_run = selection_start_run(&selection);
let edit_byte = selection_start_byte(&selection);
let old_run_len = run_text_len(&new_content, edit_run);
let old_run_count = new_content.len();
let (temp_content, new_cursor) =
apply_edit_to_selection(&new_content, &selection, edit);
let new_run_len = run_text_len(&temp_content, edit_run);
let byte_offset_change = new_run_len as i32 - old_run_len as i32;
let run_count_change = temp_content.len() as i32 - old_run_count as i32;
adjust_cursors(&mut new_selections, edit_run, edit_byte, byte_offset_change);
adjust_cursor_runs(&mut new_selections, edit_run, run_count_change);
new_content = temp_content;
new_selections.push(Selection::Cursor(new_cursor));
}
new_selections.reverse();
(new_content, new_selections)
}
#[must_use] pub fn apply_edit_to_selection(
content: &[InlineContent],
selection: &Selection,
edit: &TextEdit,
) -> (Vec<InlineContent>, TextCursor) {
let mut new_content = content.to_vec();
match selection {
Selection::Range(range) => {
let (content_after_delete, cursor_pos) = delete_range(&new_content, range);
match edit {
TextEdit::Insert(text_to_insert) => {
let mut c = content_after_delete;
insert_text(&c, &cursor_pos, text_to_insert)
}
TextEdit::DeleteBackward | TextEdit::DeleteForward => {
(content_after_delete, cursor_pos)
}
}
}
Selection::Cursor(cursor) => {
match edit {
TextEdit::Insert(text_to_insert) => {
insert_text(&new_content, cursor, text_to_insert)
}
TextEdit::DeleteBackward => delete_backward(&new_content, cursor),
TextEdit::DeleteForward => delete_forward(&new_content, cursor),
}
}
}
}
pub(crate) fn cursor_byte_offset_in_run(text: &str, cursor: &TextCursor) -> usize {
use unicode_segmentation::UnicodeSegmentation;
let csb = cursor.cluster_id.start_byte_in_run as usize;
match cursor.affinity {
CursorAffinity::Leading => csb.min(text.len()),
CursorAffinity::Trailing => {
if csb >= text.len() {
text.len()
} else {
text[csb..]
.grapheme_indices(true)
.next()
.map_or(text.len(), |(_, g)| csb + g.len())
}
}
}
}
#[allow(clippy::cast_possible_truncation)] #[must_use] pub fn delete_range(
content: &[InlineContent],
range: &SelectionRange,
) -> (Vec<InlineContent>, TextCursor) {
let mut new_content = content.to_vec();
let start_run_idx = range.start.cluster_id.source_run as usize;
let end_run_idx = range.end.cluster_id.source_run as usize;
let mut cursor_after = range.start;
if start_run_idx == end_run_idx {
if let Some(InlineContent::Text(run)) = new_content.get_mut(start_run_idx) {
let a = cursor_byte_offset_in_run(&run.text, &range.start);
let b = cursor_byte_offset_in_run(&run.text, &range.end);
let lo = a.min(b);
let hi = a.max(b);
if hi <= run.text.len() && lo < hi {
run.text.drain(lo..hi);
cursor_after = TextCursor {
cluster_id: GraphemeClusterId {
source_run: start_run_idx as u32,
start_byte_in_run: lo as u32,
},
affinity: CursorAffinity::Leading,
};
}
} else if start_run_idx < new_content.len() && range.start != range.end {
new_content.remove(start_run_idx);
cursor_after = TextCursor {
cluster_id: GraphemeClusterId {
source_run: start_run_idx as u32,
start_byte_in_run: 0,
},
affinity: CursorAffinity::Leading,
};
}
} else {
let (lo_run, lo_cursor, hi_run, hi_cursor) = if start_run_idx <= end_run_idx {
(start_run_idx, range.start, end_run_idx, range.end)
} else {
(end_run_idx, range.end, start_run_idx, range.start)
};
let lo_byte = match new_content.get(lo_run) {
Some(InlineContent::Text(run)) => cursor_byte_offset_in_run(&run.text, &lo_cursor),
_ => 0,
};
let hi_byte = match new_content.get(hi_run) {
Some(InlineContent::Text(run)) => cursor_byte_offset_in_run(&run.text, &hi_cursor),
_ => 0,
};
let head_len = if let Some(InlineContent::Text(run)) = new_content.get_mut(lo_run) {
let cut = lo_byte.min(run.text.len());
run.text.truncate(cut);
cut
} else {
0
};
if let Some(InlineContent::Text(run)) = new_content.get_mut(hi_run) {
let cut = hi_byte.min(run.text.len());
run.text.drain(..cut);
}
let drain_end = hi_run.min(new_content.len());
if drain_end > lo_run + 1 {
new_content.drain((lo_run + 1)..drain_end);
}
let tail_idx = lo_run + 1;
let mergeable = matches!(
(new_content.get(lo_run), new_content.get(tail_idx)),
(Some(InlineContent::Text(a)), Some(InlineContent::Text(b)))
if a.style == b.style
);
if mergeable {
if let InlineContent::Text(tail) = new_content.remove(tail_idx) {
if let Some(InlineContent::Text(head)) = new_content.get_mut(lo_run) {
head.text.push_str(&tail.text);
}
}
}
cursor_after = TextCursor {
cluster_id: GraphemeClusterId {
source_run: lo_run as u32,
start_byte_in_run: head_len as u32,
},
affinity: CursorAffinity::Leading,
};
}
(new_content, cursor_after) }
#[allow(clippy::cast_possible_truncation)] #[must_use]
pub fn insert_text(
content: &[InlineContent],
cursor: &TextCursor,
text_to_insert: &str,
) -> (Vec<InlineContent>, TextCursor) {
use unicode_segmentation::UnicodeSegmentation;
let mut new_content = content.to_vec();
let run_idx = cursor.cluster_id.source_run as usize;
let cluster_start_byte = cursor.cluster_id.start_byte_in_run as usize;
if let Some(InlineContent::Text(run)) = new_content.get_mut(run_idx) {
let byte_offset = match cursor.affinity {
CursorAffinity::Leading => {
cluster_start_byte
},
CursorAffinity::Trailing => {
if cluster_start_byte >= run.text.len() {
run.text.len()
} else {
run.text[cluster_start_byte..]
.grapheme_indices(true)
.next()
.map_or(run.text.len(), |(_, grapheme)| cluster_start_byte + grapheme.len())
}
},
};
if byte_offset <= run.text.len() {
run.text.insert_str(byte_offset, text_to_insert);
let new_cursor = TextCursor {
cluster_id: GraphemeClusterId {
source_run: run_idx as u32,
start_byte_in_run: (byte_offset + text_to_insert.len()) as u32,
},
affinity: CursorAffinity::Leading,
};
return (new_content, new_cursor);
}
}
(content.to_vec(), *cursor)
}
#[allow(clippy::cast_possible_truncation)] #[allow(clippy::too_many_lines)] #[must_use]
pub fn delete_backward(
content: &[InlineContent],
cursor: &TextCursor,
) -> (Vec<InlineContent>, TextCursor) {
use unicode_segmentation::UnicodeSegmentation;
let mut new_content = content.to_vec();
let run_idx = cursor.cluster_id.source_run as usize;
let cluster_start_byte = cursor.cluster_id.start_byte_in_run as usize;
if new_content.get(run_idx).is_some()
&& !matches!(new_content.get(run_idx), Some(InlineContent::Text(_)))
{
return match cursor.affinity {
CursorAffinity::Trailing => {
new_content.remove(run_idx);
(
new_content,
TextCursor {
cluster_id: GraphemeClusterId {
source_run: run_idx as u32,
start_byte_in_run: 0,
},
affinity: CursorAffinity::Leading,
},
)
}
CursorAffinity::Leading if run_idx > 0 => {
let prev_byte = match content.get(run_idx - 1) {
Some(InlineContent::Text(r)) => r.text.len() as u32,
_ => 0,
};
delete_backward(
content,
&TextCursor {
cluster_id: GraphemeClusterId {
source_run: (run_idx - 1) as u32,
start_byte_in_run: prev_byte,
},
affinity: CursorAffinity::Trailing,
},
)
}
CursorAffinity::Leading => (content.to_vec(), *cursor),
};
}
if let Some(InlineContent::Text(run)) = new_content.get_mut(run_idx) {
let byte_offset = match cursor.affinity {
CursorAffinity::Leading => cluster_start_byte,
CursorAffinity::Trailing => {
if cluster_start_byte >= run.text.len() {
run.text.len()
} else {
run.text[cluster_start_byte..]
.grapheme_indices(true)
.next()
.map_or(run.text.len(), |(_, grapheme)| cluster_start_byte + grapheme.len())
}
},
};
if byte_offset > 0 {
let prev_grapheme_start = run.text[..byte_offset]
.grapheme_indices(true)
.next_back()
.map_or(0, |(i, _)| i);
run.text.drain(prev_grapheme_start..byte_offset);
let new_cursor = TextCursor {
cluster_id: GraphemeClusterId {
source_run: run_idx as u32,
start_byte_in_run: prev_grapheme_start as u32,
},
affinity: CursorAffinity::Leading,
};
return (new_content, new_cursor);
} else if run_idx > 0 {
match content.get(run_idx - 1).cloned() {
Some(InlineContent::Text(prev_run)) => {
let mut merged_text = prev_run.text;
let new_cursor_byte_offset = merged_text.len();
merged_text.push_str(&run.text);
new_content[run_idx - 1] = InlineContent::Text(StyledRun {
text: merged_text,
style: prev_run.style,
logical_start_byte: prev_run.logical_start_byte,
source_node_id: prev_run.source_node_id,
});
new_content.remove(run_idx);
let new_cursor = TextCursor {
cluster_id: GraphemeClusterId {
source_run: (run_idx - 1) as u32,
start_byte_in_run: new_cursor_byte_offset as u32,
},
affinity: CursorAffinity::Leading,
};
return (new_content, new_cursor);
}
Some(_) => {
new_content.remove(run_idx - 1);
let new_cursor = TextCursor {
cluster_id: GraphemeClusterId {
source_run: (run_idx - 1) as u32,
start_byte_in_run: 0,
},
affinity: CursorAffinity::Leading,
};
return (new_content, new_cursor);
}
None => {}
}
}
}
(content.to_vec(), *cursor)
}
#[allow(clippy::cast_possible_truncation)] #[must_use]
pub fn delete_forward(
content: &[InlineContent],
cursor: &TextCursor,
) -> (Vec<InlineContent>, TextCursor) {
use unicode_segmentation::UnicodeSegmentation;
let mut new_content = content.to_vec();
let run_idx = cursor.cluster_id.source_run as usize;
let cluster_start_byte = cursor.cluster_id.start_byte_in_run as usize;
if new_content.get(run_idx).is_some()
&& !matches!(new_content.get(run_idx), Some(InlineContent::Text(_)))
{
return match cursor.affinity {
CursorAffinity::Leading => {
new_content.remove(run_idx);
(
new_content,
TextCursor {
cluster_id: GraphemeClusterId {
source_run: run_idx as u32,
start_byte_in_run: 0,
},
affinity: CursorAffinity::Leading,
},
)
}
CursorAffinity::Trailing if run_idx + 1 < content.len() => delete_forward(
content,
&TextCursor {
cluster_id: GraphemeClusterId {
source_run: (run_idx + 1) as u32,
start_byte_in_run: 0,
},
affinity: CursorAffinity::Leading,
},
),
CursorAffinity::Trailing => (content.to_vec(), *cursor),
};
}
if let Some(InlineContent::Text(run)) = new_content.get_mut(run_idx) {
let byte_offset = match cursor.affinity {
CursorAffinity::Leading => cluster_start_byte,
CursorAffinity::Trailing => {
if cluster_start_byte >= run.text.len() {
run.text.len()
} else {
run.text[cluster_start_byte..]
.grapheme_indices(true)
.next()
.map_or(run.text.len(), |(_, grapheme)| cluster_start_byte + grapheme.len())
}
},
};
if byte_offset < run.text.len() {
let next_grapheme_end = run.text[byte_offset..]
.grapheme_indices(true)
.nth(1)
.map_or(run.text.len(), |(i, _)| byte_offset + i);
run.text.drain(byte_offset..next_grapheme_end);
let new_cursor = TextCursor {
cluster_id: GraphemeClusterId {
source_run: run_idx as u32,
start_byte_in_run: byte_offset as u32,
},
affinity: CursorAffinity::Leading,
};
return (new_content, new_cursor);
} else if run_idx < content.len() - 1 {
match content.get(run_idx + 1).cloned() {
Some(InlineContent::Text(next_run)) => {
let mut merged_text = run.text.clone();
merged_text.push_str(&next_run.text);
new_content[run_idx] = InlineContent::Text(StyledRun {
text: merged_text,
style: run.style.clone(),
logical_start_byte: run.logical_start_byte,
source_node_id: run.source_node_id,
});
new_content.remove(run_idx + 1);
return (new_content, *cursor);
}
Some(_) => {
new_content.remove(run_idx + 1);
return (new_content, *cursor);
}
None => {}
}
}
}
(content.to_vec(), *cursor)
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] #[must_use] pub fn edit_text_multi(
content: &[InlineContent],
selections: &[Selection],
texts: &[&str],
) -> (Vec<InlineContent>, Vec<Selection>) {
assert_eq!(
selections.len(),
texts.len(),
"edit_text_multi: selections and texts must have the same length"
);
if selections.is_empty() {
return (content.to_vec(), Vec::new());
}
let mut new_content = content.to_vec();
let mut new_selections = Vec::new();
let mut pairs: Vec<(Selection, &str)> = selections
.iter()
.copied()
.zip(texts.iter().copied())
.collect();
pairs.sort_by(|a, b| {
let cursor_a = match &a.0 {
Selection::Cursor(c) => c,
Selection::Range(r) => &r.start,
};
let cursor_b = match &b.0 {
Selection::Cursor(c) => c,
Selection::Range(r) => &r.start,
};
cursor_b.cluster_id.cmp(&cursor_a.cluster_id) });
for (selection, text) in &pairs {
let edit = TextEdit::Insert((*text).to_string());
let edit_run = selection_start_run(selection);
let edit_byte = selection_start_byte(selection);
let old_run_len = run_text_len(&new_content, edit_run);
let old_run_count = new_content.len();
let (temp_content, new_cursor) =
apply_edit_to_selection(&new_content, selection, &edit);
let new_run_len = run_text_len(&temp_content, edit_run);
let byte_offset_change = new_run_len as i32 - old_run_len as i32;
let run_count_change = temp_content.len() as i32 - old_run_count as i32;
adjust_cursors(&mut new_selections, edit_run, edit_byte, byte_offset_change);
adjust_cursor_runs(&mut new_selections, edit_run, run_count_change);
new_content = temp_content;
new_selections.push(Selection::Cursor(new_cursor));
}
new_selections.reverse();
(new_content, new_selections)
}
#[must_use] pub fn inspect_delete(
content: &[InlineContent],
selection: &Selection,
forward: bool,
) -> Option<(SelectionRange, String)> {
match selection {
Selection::Range(range) => {
let deleted_text = extract_text_in_range(content, range);
Some((*range, deleted_text))
}
Selection::Cursor(cursor) => {
if forward {
inspect_delete_forward(content, cursor)
} else {
inspect_delete_backward(content, cursor)
}
}
}
}
#[allow(clippy::cast_possible_truncation)] fn inspect_delete_forward(
content: &[InlineContent],
cursor: &TextCursor,
) -> Option<(SelectionRange, String)> {
use unicode_segmentation::UnicodeSegmentation;
let run_idx = cursor.cluster_id.source_run as usize;
if let Some(InlineContent::Text(run)) = content.get(run_idx) {
let byte_offset = cursor_byte_offset_in_run(&run.text, cursor);
if byte_offset < run.text.len() {
let next_grapheme_end = run.text[byte_offset..]
.grapheme_indices(true)
.nth(1)
.map_or(run.text.len(), |(i, _)| byte_offset + i);
let deleted_text = run.text[byte_offset..next_grapheme_end].to_string();
let range = SelectionRange {
start: *cursor,
end: TextCursor {
cluster_id: GraphemeClusterId {
source_run: run_idx as u32,
start_byte_in_run: next_grapheme_end as u32,
},
affinity: CursorAffinity::Leading,
},
};
return Some((range, deleted_text));
} else if run_idx < content.len() - 1 {
if let Some(InlineContent::Text(next_run)) = content.get(run_idx + 1) {
let deleted_text = next_run.text.graphemes(true).next()?.to_string();
let next_grapheme_end = next_run
.text
.grapheme_indices(true)
.nth(1)
.map_or(next_run.text.len(), |(i, _)| i);
let range = SelectionRange {
start: *cursor,
end: TextCursor {
cluster_id: GraphemeClusterId {
source_run: (run_idx + 1) as u32,
start_byte_in_run: next_grapheme_end as u32,
},
affinity: CursorAffinity::Leading,
},
};
return Some((range, deleted_text));
}
}
}
None }
#[allow(clippy::cast_possible_truncation)] fn inspect_delete_backward(
content: &[InlineContent],
cursor: &TextCursor,
) -> Option<(SelectionRange, String)> {
use unicode_segmentation::UnicodeSegmentation;
let run_idx = cursor.cluster_id.source_run as usize;
if let Some(InlineContent::Text(run)) = content.get(run_idx) {
let byte_offset = cursor_byte_offset_in_run(&run.text, cursor);
if byte_offset > 0 {
let prev_grapheme_start = run.text[..byte_offset]
.grapheme_indices(true)
.next_back()
.map_or(0, |(i, _)| i);
let deleted_text = run.text[prev_grapheme_start..byte_offset].to_string();
let range = SelectionRange {
start: TextCursor {
cluster_id: GraphemeClusterId {
source_run: run_idx as u32,
start_byte_in_run: prev_grapheme_start as u32,
},
affinity: CursorAffinity::Leading,
},
end: *cursor,
};
return Some((range, deleted_text));
} else if run_idx > 0 {
if let Some(InlineContent::Text(prev_run)) = content.get(run_idx - 1) {
let deleted_text = prev_run.text.graphemes(true).next_back()?.to_string();
let prev_grapheme_start = prev_run.text[..]
.grapheme_indices(true)
.next_back()
.map_or(0, |(i, _)| i);
let range = SelectionRange {
start: TextCursor {
cluster_id: GraphemeClusterId {
source_run: (run_idx - 1) as u32,
start_byte_in_run: prev_grapheme_start as u32,
},
affinity: CursorAffinity::Leading,
},
end: *cursor,
};
return Some((range, deleted_text));
}
}
}
None }
fn extract_text_in_range(content: &[InlineContent], range: &SelectionRange) -> String {
let start_run = range.start.cluster_id.source_run as usize;
let end_run = range.end.cluster_id.source_run as usize;
let start_byte = range.start.cluster_id.start_byte_in_run as usize;
let end_byte = range.end.cluster_id.start_byte_in_run as usize;
if start_run == end_run {
if let Some(InlineContent::Text(run)) = content.get(start_run) {
if start_byte <= end_byte && end_byte <= run.text.len() {
return run.text[start_byte..end_byte].to_string();
}
}
} else {
let mut result = String::new();
for (idx, item) in content.iter().enumerate() {
if let InlineContent::Text(run) = item {
if idx == start_run {
if start_byte < run.text.len() {
result.push_str(&run.text[start_byte..]);
}
} else if idx > start_run && idx < end_run {
result.push_str(&run.text);
} else if idx == end_run {
if end_byte <= run.text.len() {
result.push_str(&run.text[..end_byte]);
}
break;
}
}
}
return result;
}
String::new()
}
#[cfg(test)]
#[allow(clippy::float_cmp, clippy::too_many_lines)]
mod autotest_generated {
use std::sync::Arc;
use unicode_segmentation::UnicodeSegmentation;
use super::*;
use crate::text3::cache::StyleProperties;
fn style_a() -> Arc<StyleProperties> {
Arc::new(StyleProperties::default())
}
fn style_b() -> Arc<StyleProperties> {
Arc::new(StyleProperties {
font_size_px: 99.0,
..StyleProperties::default()
})
}
fn text(s: &str) -> InlineContent {
InlineContent::Text(StyledRun {
text: s.to_string(),
style: style_a(),
logical_start_byte: 0,
source_node_id: None,
})
}
fn text_styled(s: &str, style: Arc<StyleProperties>) -> InlineContent {
InlineContent::Text(StyledRun {
text: s.to_string(),
style,
logical_start_byte: 0,
source_node_id: None,
})
}
fn obj() -> InlineContent {
InlineContent::Tab { style: style_a() }
}
fn dump(content: &[InlineContent]) -> Vec<String> {
content
.iter()
.map(|c| match c {
InlineContent::Text(r) => r.text.clone(),
_ => "<obj>".to_string(),
})
.collect()
}
fn lead(run: u32, byte: u32) -> TextCursor {
TextCursor {
cluster_id: GraphemeClusterId {
source_run: run,
start_byte_in_run: byte,
},
affinity: CursorAffinity::Leading,
}
}
fn trail(run: u32, byte: u32) -> TextCursor {
TextCursor {
cluster_id: GraphemeClusterId {
source_run: run,
start_byte_in_run: byte,
},
affinity: CursorAffinity::Trailing,
}
}
fn range_sel(start: TextCursor, end: TextCursor) -> Selection {
Selection::Range(SelectionRange { start, end })
}
fn cursor_of(sel: &Selection) -> TextCursor {
match sel {
Selection::Cursor(c) => *c,
Selection::Range(r) => r.start,
}
}
const FAMILY: &str = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}";
#[test]
fn family_constant_is_one_grapheme_of_18_bytes() {
assert_eq!(FAMILY.len(), 18);
assert_eq!(FAMILY.graphemes(true).count(), 1);
}
#[test]
fn selection_start_run_and_byte_read_the_cursor() {
let sel = Selection::Cursor(lead(3, 7));
assert_eq!(selection_start_run(&sel), 3);
assert_eq!(selection_start_byte(&sel), 7);
}
#[test]
fn selection_start_run_and_byte_read_range_start_not_end() {
let sel = range_sel(lead(9, 40), lead(1, 2));
assert_eq!(selection_start_run(&sel), 9);
assert_eq!(selection_start_byte(&sel), 40);
}
#[test]
fn selection_start_accessors_survive_u32_max() {
let sel = Selection::Cursor(trail(u32::MAX, u32::MAX));
assert_eq!(selection_start_run(&sel), u32::MAX);
assert_eq!(selection_start_byte(&sel), u32::MAX);
let sel = range_sel(lead(u32::MAX, u32::MAX), lead(0, 0));
assert_eq!(selection_start_run(&sel), u32::MAX);
assert_eq!(selection_start_byte(&sel), u32::MAX);
}
#[test]
fn sort_back_to_front_empty_and_single() {
assert!(sort_selections_back_to_front(&[]).is_empty());
let one = [Selection::Cursor(lead(0, 0))];
assert_eq!(sort_selections_back_to_front(&one).len(), 1);
}
#[test]
fn sort_back_to_front_is_descending_by_cluster_id() {
let sels = [
Selection::Cursor(lead(0, 0)),
Selection::Cursor(lead(2, 5)),
Selection::Cursor(lead(1, 3)),
Selection::Cursor(lead(2, 1)),
];
let sorted = sort_selections_back_to_front(&sels);
let keys: Vec<(u32, u32)> = sorted
.iter()
.map(|s| {
let c = cursor_of(s).cluster_id;
(c.source_run, c.start_byte_in_run)
})
.collect();
assert_eq!(keys, vec![(2, 5), (2, 1), (1, 3), (0, 0)]);
assert!(keys.windows(2).all(|w| w[0] >= w[1]));
}
#[test]
fn sort_back_to_front_is_a_permutation_with_duplicates() {
let sels = [
Selection::Cursor(lead(1, 1)),
Selection::Cursor(lead(1, 1)),
Selection::Cursor(lead(0, 0)),
];
let sorted = sort_selections_back_to_front(&sels);
assert_eq!(sorted.len(), 3);
let mut got: Vec<Selection> = sorted;
let mut want: Vec<Selection> = sels.to_vec();
got.sort();
want.sort();
assert_eq!(got, want);
}
#[test]
fn sort_back_to_front_keys_ranges_on_their_start() {
let sels = [
Selection::Cursor(lead(0, 0)),
range_sel(lead(5, 0), lead(0, 0)),
];
let sorted = sort_selections_back_to_front(&sels);
assert!(matches!(sorted[0], Selection::Range(_)));
assert!(matches!(sorted[1], Selection::Cursor(_)));
}
#[test]
fn sort_back_to_front_handles_u32_max_keys() {
let sels = [
Selection::Cursor(lead(u32::MAX, u32::MAX)),
Selection::Cursor(lead(0, 0)),
];
let sorted = sort_selections_back_to_front(&sels);
assert_eq!(cursor_of(&sorted[0]).cluster_id.source_run, u32::MAX);
}
fn byte_at(sels: &[Selection], i: usize) -> u32 {
cursor_of(&sels[i]).cluster_id.start_byte_in_run
}
#[test]
fn adjust_cursors_empty_slice_is_a_noop() {
let mut sels: Vec<Selection> = Vec::new();
adjust_cursors(&mut sels, 0, 0, i32::MIN);
assert!(sels.is_empty());
}
#[test]
fn adjust_cursors_zero_change_leaves_everything_alone() {
let mut sels = vec![
Selection::Cursor(lead(0, 0)),
Selection::Cursor(lead(0, 10)),
];
adjust_cursors(&mut sels, 0, 0, 0);
assert_eq!(byte_at(&sels, 0), 0);
assert_eq!(byte_at(&sels, 1), 10);
}
#[test]
fn adjust_cursors_only_shifts_at_or_after_edit_byte_in_the_edit_run() {
let mut sels = vec![
Selection::Cursor(lead(0, 2)), Selection::Cursor(lead(0, 5)), Selection::Cursor(lead(0, 9)), Selection::Cursor(lead(1, 0)), ];
adjust_cursors(&mut sels, 0, 5, 3);
assert_eq!(byte_at(&sels, 0), 2);
assert_eq!(byte_at(&sels, 1), 8);
assert_eq!(byte_at(&sels, 2), 12);
assert_eq!(byte_at(&sels, 3), 0);
}
#[test]
fn adjust_cursors_negative_change_clamps_at_zero() {
let mut sels = vec![Selection::Cursor(lead(0, 3))];
adjust_cursors(&mut sels, 0, 0, -100);
assert_eq!(byte_at(&sels, 0), 0, "documented clamp-to-zero");
}
#[test]
fn adjust_cursors_i32_extremes_do_not_panic() {
let mut sels = vec![Selection::Cursor(lead(0, 0))];
adjust_cursors(&mut sels, 0, 0, i32::MAX);
assert_eq!(byte_at(&sels, 0), i32::MAX as u32);
let mut sels = vec![Selection::Cursor(lead(0, 0))];
adjust_cursors(&mut sels, 0, 0, i32::MIN);
assert_eq!(byte_at(&sels, 0), 0);
}
#[test]
fn adjust_cursors_u32_max_byte_collapses_to_zero() {
let mut sels = vec![Selection::Cursor(lead(0, u32::MAX))];
adjust_cursors(&mut sels, 0, 0, 0);
assert_eq!(byte_at(&sels, 0), 0);
}
#[test]
fn adjust_cursors_never_touches_range_selections() {
let mut sels = vec![range_sel(lead(0, 4), lead(0, 8))];
adjust_cursors(&mut sels, 0, 0, 100);
match &sels[0] {
Selection::Range(r) => {
assert_eq!(r.start.cluster_id.start_byte_in_run, 4);
assert_eq!(r.end.cluster_id.start_byte_in_run, 8);
}
Selection::Cursor(_) => panic!("range must stay a range"),
}
}
fn run_at(sels: &[Selection], i: usize) -> u32 {
cursor_of(&sels[i]).cluster_id.source_run
}
#[test]
fn adjust_cursor_runs_zero_change_returns_early() {
let mut sels = vec![Selection::Cursor(lead(u32::MAX, 0))];
adjust_cursor_runs(&mut sels, 0, 0);
assert_eq!(run_at(&sels, 0), u32::MAX, "zero change must not touch runs");
}
#[test]
fn adjust_cursor_runs_shifts_only_runs_strictly_after_the_boundary() {
let mut sels = vec![
Selection::Cursor(lead(0, 0)), Selection::Cursor(lead(1, 0)), Selection::Cursor(lead(2, 0)), Selection::Cursor(lead(3, 0)), ];
adjust_cursor_runs(&mut sels, 1, -1);
assert_eq!(run_at(&sels, 0), 0);
assert_eq!(run_at(&sels, 1), 1);
assert_eq!(run_at(&sels, 2), 1);
assert_eq!(run_at(&sels, 3), 2);
}
#[test]
fn adjust_cursor_runs_positive_change_shifts_up() {
let mut sels = vec![Selection::Cursor(lead(2, 0))];
adjust_cursor_runs(&mut sels, 0, 3);
assert_eq!(run_at(&sels, 0), 5);
}
#[test]
fn adjust_cursor_runs_negative_overshoot_clamps_to_the_boundary_run() {
let mut sels = vec![Selection::Cursor(lead(3, 0))];
adjust_cursor_runs(&mut sels, 1, -100);
assert_eq!(run_at(&sels, 0), 1, "never drops below the surviving run");
}
#[test]
fn adjust_cursor_runs_i32_min_clamps_instead_of_wrapping() {
let mut sels = vec![Selection::Cursor(lead(3, 0))];
adjust_cursor_runs(&mut sels, 2, i32::MIN);
assert_eq!(run_at(&sels, 0), 2);
let mut sels = vec![Selection::Cursor(lead(1, 0))];
adjust_cursor_runs(&mut sels, 0, i32::MIN);
assert_eq!(run_at(&sels, 0), 0);
}
#[test]
fn adjust_cursor_runs_i32_max_is_inert_when_no_cursor_qualifies() {
let mut sels = vec![
Selection::Cursor(lead(0, 0)),
Selection::Cursor(lead(5, 0)),
];
adjust_cursor_runs(&mut sels, 5, i32::MAX);
assert_eq!(run_at(&sels, 0), 0);
assert_eq!(run_at(&sels, 1), 5);
}
#[test]
fn adjust_cursor_runs_never_touches_range_selections() {
let mut sels = vec![range_sel(lead(9, 0), lead(9, 1))];
adjust_cursor_runs(&mut sels, 0, -5);
match &sels[0] {
Selection::Range(r) => assert_eq!(r.start.cluster_id.source_run, 9),
Selection::Cursor(_) => panic!("range must stay a range"),
}
}
#[test]
fn run_text_len_counts_bytes_not_chars() {
let content = vec![text("héllo")]; assert_eq!(run_text_len(&content, 0), 6);
assert_eq!(content_text_chars(&content), 5);
}
fn content_text_chars(content: &[InlineContent]) -> usize {
content
.iter()
.map(|c| match c {
InlineContent::Text(r) => r.text.chars().count(),
_ => 0,
})
.sum()
}
#[test]
fn run_text_len_zero_for_empty_missing_and_non_text_runs() {
let content = vec![text(""), obj()];
assert_eq!(run_text_len(&content, 0), 0, "empty text run");
assert_eq!(run_text_len(&content, 1), 0, "non-text run");
assert_eq!(run_text_len(&content, 2), 0, "one past the end");
assert_eq!(run_text_len(&content, u32::MAX), 0, "u32::MAX index");
assert_eq!(run_text_len(&[], 0), 0, "empty content");
}
#[test]
fn edit_text_empty_selections_returns_content_unchanged() {
let content = vec![text("hello")];
let (new_content, sels) = edit_text(&content, &[], &TextEdit::Insert("x".into()));
assert_eq!(dump(&new_content), vec!["hello"]);
assert!(sels.is_empty());
}
#[test]
fn edit_text_on_empty_content_does_not_panic() {
let (new_content, sels) = edit_text(
&[],
&[Selection::Cursor(lead(0, 0))],
&TextEdit::Insert("x".into()),
);
assert!(new_content.is_empty());
assert_eq!(sels.len(), 1, "the cursor survives, unmoved");
assert_eq!(cursor_of(&sels[0]), lead(0, 0));
}
#[test]
fn edit_text_out_of_range_cursor_is_a_noop_not_a_panic() {
let content = vec![text("hi")];
let (new_content, sels) = edit_text(
&content,
&[Selection::Cursor(lead(u32::MAX, 0))],
&TextEdit::DeleteBackward,
);
assert_eq!(dump(&new_content), vec!["hi"]);
assert_eq!(sels.len(), 1);
}
#[test]
fn edit_text_multi_cursor_insert_keeps_both_cursors_correct() {
let content = vec![text("hello")];
let sels = [
Selection::Cursor(lead(0, 0)),
Selection::Cursor(lead(0, 3)),
];
let (new_content, new_sels) = edit_text(&content, &sels, &TextEdit::Insert("X".into()));
assert_eq!(dump(&new_content), vec!["XhelXlo"]);
assert_eq!(cursor_of(&new_sels[0]), lead(0, 1));
assert_eq!(cursor_of(&new_sels[1]), lead(0, 5));
}
#[test]
fn edit_text_multi_cursor_insert_shifts_by_multibyte_length_not_one() {
let content = vec![text("ab")];
let sels = [
Selection::Cursor(lead(0, 0)),
Selection::Cursor(lead(0, 2)),
];
let (new_content, new_sels) = edit_text(&content, &sels, &TextEdit::Insert("👍".into()));
assert_eq!(dump(&new_content), vec!["👍ab👍"]);
assert_eq!(cursor_of(&new_sels[0]), lead(0, 4));
assert_eq!(cursor_of(&new_sels[1]), lead(0, 10)); }
#[test]
fn edit_text_backspace_with_a_range_deletes_the_range_only() {
let content = vec![text("hello")];
let sel = [range_sel(lead(0, 1), lead(0, 3))];
let (new_content, _) = edit_text(&content, &sel, &TextEdit::DeleteBackward);
assert_eq!(dump(&new_content), vec!["hlo"]);
}
#[test]
fn apply_edit_range_insert_replaces_the_range() {
let content = vec![text("hello")];
let sel = range_sel(lead(0, 1), lead(0, 4));
let (new_content, cursor) =
apply_edit_to_selection(&content, &sel, &TextEdit::Insert("EY".into()));
assert_eq!(dump(&new_content), vec!["hEYo"]);
assert_eq!(cursor, lead(0, 3));
}
#[test]
fn apply_edit_range_delete_forward_deletes_range_only() {
let content = vec![text("hello")];
let sel = range_sel(lead(0, 1), lead(0, 3));
let (new_content, cursor) =
apply_edit_to_selection(&content, &sel, &TextEdit::DeleteForward);
assert_eq!(dump(&new_content), vec!["hlo"]);
assert_eq!(cursor, lead(0, 1));
}
#[test]
fn apply_edit_cursor_insert_of_empty_string_only_moves_the_caret() {
let content = vec![text("hello")];
let sel = Selection::Cursor(lead(0, 2));
let (new_content, cursor) =
apply_edit_to_selection(&content, &sel, &TextEdit::Insert(String::new()));
assert_eq!(dump(&new_content), vec!["hello"]);
assert_eq!(cursor, lead(0, 2));
}
#[test]
fn cursor_byte_offset_leading_clamps_past_the_end() {
assert_eq!(cursor_byte_offset_in_run("hi", &lead(0, 999)), 2);
assert_eq!(cursor_byte_offset_in_run("hi", &lead(0, u32::MAX)), 2);
assert_eq!(cursor_byte_offset_in_run("", &lead(0, 5)), 0);
}
#[test]
fn cursor_byte_offset_trailing_clamps_past_the_end() {
assert_eq!(cursor_byte_offset_in_run("hi", &trail(0, 2)), 2);
assert_eq!(cursor_byte_offset_in_run("hi", &trail(0, u32::MAX)), 2);
assert_eq!(cursor_byte_offset_in_run("", &trail(0, 0)), 0);
}
#[test]
fn cursor_byte_offset_trailing_lands_after_the_whole_grapheme() {
assert_eq!(cursor_byte_offset_in_run("e\u{0301}x", &trail(0, 0)), 3);
assert_eq!(cursor_byte_offset_in_run("👍x", &trail(0, 0)), 4);
assert_eq!(cursor_byte_offset_in_run(FAMILY, &trail(0, 0)), 18);
}
#[test]
fn cursor_byte_offset_leading_is_the_raw_offset() {
assert_eq!(cursor_byte_offset_in_run(FAMILY, &lead(0, 0)), 0);
assert_eq!(cursor_byte_offset_in_run("abc", &lead(0, 1)), 1);
}
#[test]
fn delete_range_within_one_run() {
let content = vec![text("hello")];
let r = SelectionRange {
start: lead(0, 1),
end: lead(0, 3),
};
let (new_content, cursor) = delete_range(&content, &r);
assert_eq!(dump(&new_content), vec!["hlo"]);
assert_eq!(cursor, lead(0, 1));
}
#[test]
fn delete_range_backward_range_is_normalized() {
let content = vec![text("hello")];
let backward = SelectionRange {
start: lead(0, 3),
end: lead(0, 1),
};
let (new_content, cursor) = delete_range(&content, &backward);
assert_eq!(dump(&new_content), vec!["hlo"]);
assert_eq!(cursor, lead(0, 1), "caret collapses to the LOW end");
}
#[test]
fn delete_range_collapsed_range_deletes_nothing() {
let content = vec![text("hello")];
let r = SelectionRange {
start: lead(0, 2),
end: lead(0, 2),
};
let (new_content, cursor) = delete_range(&content, &r);
assert_eq!(dump(&new_content), vec!["hello"]);
assert_eq!(cursor, lead(0, 2));
}
#[test]
fn delete_range_select_all_with_trailing_end_covers_the_last_cluster() {
let content = vec![text("hello")];
let r = SelectionRange {
start: lead(0, 0),
end: trail(0, 4),
};
let (new_content, cursor) = delete_range(&content, &r);
assert_eq!(dump(&new_content), vec![""]);
assert_eq!(cursor, lead(0, 0));
}
#[test]
fn delete_range_spanning_runs_merges_matching_styles() {
let content = vec![text("abc"), text("def")];
let r = SelectionRange {
start: lead(0, 1),
end: lead(1, 2),
};
let (new_content, cursor) = delete_range(&content, &r);
assert_eq!(dump(&new_content), vec!["af"], "same style -> one run");
assert_eq!(cursor, lead(0, 1));
}
#[test]
fn delete_range_spanning_runs_keeps_differing_styles_apart() {
let content = vec![text_styled("abc", style_a()), text_styled("def", style_b())];
let r = SelectionRange {
start: lead(0, 1),
end: lead(1, 2),
};
let (new_content, cursor) = delete_range(&content, &r);
assert_eq!(dump(&new_content), vec!["a", "f"], "styles differ -> no merge");
assert_eq!(cursor, lead(0, 1));
}
#[test]
fn delete_range_drops_the_runs_strictly_between_the_boundaries() {
let content = vec![text("abc"), text("XYZ"), obj(), text("def")];
let r = SelectionRange {
start: lead(0, 1),
end: lead(3, 2),
};
let (new_content, _) = delete_range(&content, &r);
assert_eq!(dump(&new_content), vec!["af"], "middle text AND obj dropped");
}
#[test]
fn delete_range_over_a_single_non_text_item_removes_it() {
let content = vec![text("ab"), obj(), text("cd")];
let r = SelectionRange {
start: lead(1, 0),
end: trail(1, 0),
};
let (new_content, cursor) = delete_range(&content, &r);
assert_eq!(dump(&new_content), vec!["ab", "cd"]);
assert_eq!(cursor, lead(1, 0));
}
#[test]
fn delete_range_collapsed_on_a_non_text_item_keeps_it() {
let content = vec![text("ab"), obj()];
let r = SelectionRange {
start: lead(1, 0),
end: lead(1, 0),
};
let (new_content, _) = delete_range(&content, &r);
assert_eq!(dump(&new_content), vec!["ab", "<obj>"]);
}
#[test]
fn delete_range_out_of_bounds_runs_do_not_panic() {
let content = vec![text("ab")];
let r = SelectionRange {
start: lead(99, 0),
end: lead(99, 5),
};
let (new_content, _) = delete_range(&content, &r);
assert_eq!(dump(&new_content), vec!["ab"]);
let r = SelectionRange {
start: lead(0, 0),
end: lead(u32::MAX, 0),
};
let (new_content, cursor) = delete_range(&content, &r);
assert_eq!(dump(&new_content), vec![""]);
assert_eq!(cursor, lead(0, 0));
}
#[test]
fn delete_range_backward_across_runs_is_normalized() {
let content = vec![text("abc"), text("def")];
let backward = SelectionRange {
start: lead(1, 2),
end: lead(0, 1),
};
let (new_content, cursor) = delete_range(&content, &backward);
assert_eq!(dump(&new_content), vec!["af"]);
assert_eq!(cursor, lead(0, 1));
}
#[test]
fn insert_text_leading_inserts_before_the_cluster() {
let content = vec![text("hello")];
let (new_content, cursor) = insert_text(&content, &lead(0, 2), "XY");
assert_eq!(dump(&new_content), vec!["heXYllo"]);
assert_eq!(cursor, lead(0, 4));
}
#[test]
fn insert_text_trailing_inserts_after_the_whole_grapheme() {
let content = vec![text("👍z")];
let (new_content, cursor) = insert_text(&content, &trail(0, 0), "X");
assert_eq!(dump(&new_content), vec!["👍Xz"]);
assert_eq!(cursor, lead(0, 5));
}
#[test]
fn insert_text_trailing_past_the_end_appends() {
let content = vec![text("hi")];
let (new_content, cursor) = insert_text(&content, &trail(0, 999), "!");
assert_eq!(dump(&new_content), vec!["hi!"]);
assert_eq!(cursor, lead(0, 3));
}
#[test]
fn insert_text_leading_past_the_end_is_a_noop() {
let content = vec![text("hi")];
let (new_content, cursor) = insert_text(&content, &lead(0, 999), "!");
assert_eq!(dump(&new_content), vec!["hi"]);
assert_eq!(cursor, lead(0, 999));
}
#[test]
fn insert_text_into_missing_or_non_text_run_is_a_noop() {
let content = vec![obj()];
let (new_content, cursor) = insert_text(&content, &lead(0, 0), "x");
assert_eq!(dump(&new_content), vec!["<obj>"]);
assert_eq!(cursor, lead(0, 0));
let (new_content, cursor) = insert_text(&content, &lead(u32::MAX, 0), "x");
assert_eq!(dump(&new_content), vec!["<obj>"]);
assert_eq!(cursor, lead(u32::MAX, 0));
let (new_content, _) = insert_text(&[], &lead(0, 0), "x");
assert!(new_content.is_empty());
}
#[test]
fn insert_text_empty_string_leaves_the_text_alone() {
let content = vec![text("hi")];
let (new_content, cursor) = insert_text(&content, &lead(0, 1), "");
assert_eq!(dump(&new_content), vec!["hi"]);
assert_eq!(cursor, lead(0, 1));
}
#[test]
fn insert_text_cursor_advances_by_bytes_not_chars() {
let content = vec![text("")];
let (new_content, cursor) = insert_text(&content, &lead(0, 0), FAMILY);
assert_eq!(dump(&new_content), vec![FAMILY]);
assert_eq!(cursor, lead(0, 18));
}
#[test]
fn insert_text_of_a_huge_string_does_not_panic() {
let big = "a".repeat(200_000);
let content = vec![text("hi")];
let (new_content, cursor) = insert_text(&content, &lead(0, 1), &big);
assert_eq!(run_text_len(&new_content, 0), 200_002);
assert_eq!(cursor, lead(0, 200_001));
}
#[test]
fn delete_backward_on_empty_content_is_a_noop() {
let (new_content, cursor) = delete_backward(&[], &lead(0, 0));
assert!(new_content.is_empty());
assert_eq!(cursor, lead(0, 0));
}
#[test]
fn delete_backward_at_the_start_of_the_document_is_a_noop() {
let content = vec![text("hi")];
let (new_content, cursor) = delete_backward(&content, &lead(0, 0));
assert_eq!(dump(&new_content), vec!["hi"]);
assert_eq!(cursor, lead(0, 0));
}
#[test]
fn delete_backward_removes_a_whole_grapheme_cluster() {
let content = vec![text(&format!("a{FAMILY}"))];
let (new_content, cursor) = delete_backward(&content, &lead(0, 19));
assert_eq!(dump(&new_content), vec!["a"], "all 18 bytes go at once");
assert_eq!(cursor, lead(0, 1));
}
#[test]
fn delete_backward_trailing_affinity_removes_the_current_cluster() {
let content = vec![text("ab")];
let (new_content, cursor) = delete_backward(&content, &trail(0, 0));
assert_eq!(dump(&new_content), vec!["b"]);
assert_eq!(cursor, lead(0, 0));
}
#[test]
fn delete_backward_merges_across_a_run_boundary() {
let content = vec![text("ab"), text("cd")];
let (new_content, cursor) = delete_backward(&content, &lead(1, 0));
assert_eq!(dump(&new_content), vec!["abcd"]);
assert_eq!(cursor, lead(0, 2), "caret sits at the join point");
}
#[test]
fn delete_backward_removes_a_non_text_item_sitting_before_the_caret() {
let content = vec![text("ab"), obj(), text("cd")];
let (new_content, cursor) = delete_backward(&content, &lead(2, 0));
assert_eq!(dump(&new_content), vec!["ab", "cd"]);
assert_eq!(cursor, lead(1, 0));
}
#[test]
fn delete_backward_with_the_caret_after_a_non_text_item_removes_the_item() {
let content = vec![text("ab"), obj()];
let (new_content, _) = delete_backward(&content, &trail(1, 0));
assert_eq!(dump(&new_content), vec!["ab"]);
}
#[test]
fn delete_backward_with_the_caret_before_a_non_text_item_acts_on_the_previous_run() {
let content = vec![text("ab"), obj()];
let (new_content, cursor) = delete_backward(&content, &lead(1, 0));
assert_eq!(dump(&new_content), vec!["a", "<obj>"], "the item survives");
assert_eq!(cursor, lead(0, 1));
}
#[test]
fn delete_backward_before_a_leading_non_text_item_at_run_zero_is_a_noop() {
let content = vec![obj()];
let (new_content, cursor) = delete_backward(&content, &lead(0, 0));
assert_eq!(dump(&new_content), vec!["<obj>"]);
assert_eq!(cursor, lead(0, 0));
}
#[test]
fn delete_backward_out_of_range_run_is_a_noop() {
let content = vec![text("hi")];
let (new_content, cursor) = delete_backward(&content, &lead(u32::MAX, u32::MAX));
assert_eq!(dump(&new_content), vec!["hi"]);
assert_eq!(cursor, lead(u32::MAX, u32::MAX));
}
#[test]
fn delete_forward_on_empty_content_is_a_noop() {
let (new_content, cursor) = delete_forward(&[], &lead(0, 0));
assert!(new_content.is_empty());
assert_eq!(cursor, lead(0, 0));
}
#[test]
fn delete_forward_at_the_end_of_the_document_is_a_noop() {
let content = vec![text("hi")];
let (new_content, cursor) = delete_forward(&content, &lead(0, 2));
assert_eq!(dump(&new_content), vec!["hi"]);
assert_eq!(cursor, lead(0, 2));
}
#[test]
fn delete_forward_removes_a_whole_grapheme_cluster() {
let content = vec![text(&format!("{FAMILY}z"))];
let (new_content, cursor) = delete_forward(&content, &lead(0, 0));
assert_eq!(dump(&new_content), vec!["z"]);
assert_eq!(cursor, lead(0, 0));
}
#[test]
fn delete_forward_merges_across_a_run_boundary() {
let content = vec![text("ab"), text("cd")];
let (new_content, cursor) = delete_forward(&content, &lead(0, 2));
assert_eq!(dump(&new_content), vec!["abcd"]);
assert_eq!(cursor, lead(0, 2));
}
#[test]
fn delete_forward_removes_a_non_text_item_sitting_after_the_caret() {
let content = vec![text("ab"), obj()];
let (new_content, _) = delete_forward(&content, &lead(0, 2));
assert_eq!(dump(&new_content), vec!["ab"]);
}
#[test]
fn delete_forward_with_the_caret_before_a_non_text_item_removes_the_item() {
let content = vec![obj(), text("ab")];
let (new_content, cursor) = delete_forward(&content, &lead(0, 0));
assert_eq!(dump(&new_content), vec!["ab"]);
assert_eq!(cursor, lead(0, 0));
}
#[test]
fn delete_forward_with_the_caret_after_a_non_text_item_acts_on_the_next_run() {
let content = vec![obj(), text("ab")];
let (new_content, cursor) = delete_forward(&content, &trail(0, 0));
assert_eq!(dump(&new_content), vec!["<obj>", "b"], "the item survives");
assert_eq!(cursor, lead(1, 0));
}
#[test]
fn delete_forward_after_a_trailing_non_text_item_at_the_last_run_is_a_noop() {
let content = vec![text("ab"), obj()];
let (new_content, cursor) = delete_forward(&content, &trail(1, 0));
assert_eq!(dump(&new_content), vec!["ab", "<obj>"]);
assert_eq!(cursor, trail(1, 0));
}
#[test]
fn delete_forward_out_of_range_run_is_a_noop() {
let content = vec![text("hi")];
let (new_content, cursor) = delete_forward(&content, &lead(u32::MAX, u32::MAX));
assert_eq!(dump(&new_content), vec!["hi"]);
assert_eq!(cursor, lead(u32::MAX, u32::MAX));
}
#[test]
#[should_panic(expected = "same length")]
fn edit_text_multi_panics_on_a_length_mismatch() {
let content = vec![text("hi")];
let sels = [Selection::Cursor(lead(0, 0))];
let _ = edit_text_multi(&content, &sels, &["a", "b"]);
}
#[test]
fn edit_text_multi_with_no_selections_returns_content_unchanged() {
let content = vec![text("hi")];
let (new_content, sels) = edit_text_multi(&content, &[], &[]);
assert_eq!(dump(&new_content), vec!["hi"]);
assert!(sels.is_empty());
}
#[test]
fn edit_text_multi_gives_each_cursor_its_own_text() {
let content = vec![text("ab")];
let sels = [
Selection::Cursor(lead(0, 0)),
Selection::Cursor(lead(0, 2)),
];
let (new_content, new_sels) = edit_text_multi(&content, &sels, &["X", "Y"]);
assert_eq!(dump(&new_content), vec!["XabY"]);
assert_eq!(cursor_of(&new_sels[0]), lead(0, 1));
assert_eq!(cursor_of(&new_sels[1]), lead(0, 4));
}
#[test]
fn edit_text_multi_with_empty_texts_only_moves_the_carets() {
let content = vec![text("ab")];
let sels = [
Selection::Cursor(lead(0, 0)),
Selection::Cursor(lead(0, 1)),
];
let (new_content, new_sels) = edit_text_multi(&content, &sels, &["", ""]);
assert_eq!(dump(&new_content), vec!["ab"]);
assert_eq!(new_sels.len(), 2);
}
#[test]
fn inspect_delete_forward_at_the_end_of_the_document_is_none() {
let content = vec![text("hi")];
assert!(inspect_delete(&content, &Selection::Cursor(lead(0, 2)), true).is_none());
assert!(inspect_delete(&[], &Selection::Cursor(lead(0, 0)), true).is_none());
}
#[test]
fn inspect_delete_backward_at_the_start_of_the_document_is_none() {
let content = vec![text("hi")];
assert!(inspect_delete(&content, &Selection::Cursor(lead(0, 0)), false).is_none());
assert!(inspect_delete(&[], &Selection::Cursor(lead(0, 0)), false).is_none());
}
#[test]
fn inspect_delete_forward_reports_exactly_what_delete_forward_removes() {
let content = vec![text("héllo")]; let cursor = lead(0, 1);
let (_, reported) = inspect_delete(&content, &Selection::Cursor(cursor), true).unwrap();
let (after, _) = delete_forward(&content, &cursor);
assert_eq!(reported, "é");
assert_eq!(dump(&after), vec!["hllo"], "inspect and delete agree");
}
#[test]
fn inspect_delete_backward_reports_exactly_what_delete_backward_removes() {
let content = vec![text(&format!("a{FAMILY}"))];
let cursor = lead(0, 19);
let (range, reported) =
inspect_delete(&content, &Selection::Cursor(cursor), false).unwrap();
let (after, _) = delete_backward(&content, &cursor);
assert_eq!(reported, FAMILY, "the whole ZWJ cluster, not one codepoint");
assert_eq!(range.start, lead(0, 1));
assert_eq!(dump(&after), vec!["a"]);
}
#[test]
fn inspect_delete_forward_honors_trailing_affinity() {
let content = vec![text("abc")];
let (_, reported) =
inspect_delete(&content, &Selection::Cursor(trail(0, 0)), true).unwrap();
assert_eq!(reported, "b");
}
#[test]
fn inspect_delete_across_a_run_boundary_reports_the_neighbouring_grapheme() {
let content = vec![text("ab"), text("cd")];
let (_, fwd) = inspect_delete(&content, &Selection::Cursor(lead(0, 2)), true).unwrap();
assert_eq!(fwd, "c");
let (_, back) = inspect_delete(&content, &Selection::Cursor(lead(1, 0)), false).unwrap();
assert_eq!(back, "b");
}
#[test]
fn inspect_delete_reports_none_for_a_non_text_neighbour_that_delete_would_remove() {
let content = vec![text("ab"), obj()];
assert!(inspect_delete(&content, &Selection::Cursor(lead(0, 2)), true).is_none());
let (after, _) = delete_forward(&content, &lead(0, 2));
assert_eq!(dump(&after), vec!["ab"], "...but the item IS removed");
let content = vec![obj(), text("ab")];
assert!(inspect_delete(&content, &Selection::Cursor(lead(1, 0)), false).is_none());
let (after, _) = delete_backward(&content, &lead(1, 0));
assert_eq!(dump(&after), vec!["ab"], "...but the item IS removed");
}
#[test]
fn inspect_delete_on_a_range_returns_the_range_and_its_text() {
let content = vec![text("hello")];
let sel = range_sel(lead(0, 1), lead(0, 3));
let (range, reported) = inspect_delete(&content, &sel, false).unwrap();
assert_eq!(range.start, lead(0, 1));
assert_eq!(range.end, lead(0, 3));
assert_eq!(reported, "el");
let (after, _) = apply_edit_to_selection(&content, &sel, &TextEdit::DeleteBackward);
assert_eq!(dump(&after), vec!["hlo"]);
}
#[test]
fn inspect_delete_out_of_range_cursor_is_none_not_a_panic() {
let content = vec![text("hi")];
assert!(inspect_delete(&content, &Selection::Cursor(lead(u32::MAX, 0)), true).is_none());
assert!(inspect_delete(&content, &Selection::Cursor(lead(u32::MAX, 0)), false).is_none());
}
#[test]
fn extract_text_in_range_single_run() {
let content = vec![text("hello")];
let r = SelectionRange {
start: lead(0, 1),
end: lead(0, 4),
};
assert_eq!(extract_text_in_range(&content, &r), "ell");
}
#[test]
fn extract_text_in_range_multi_run_concatenates_the_span() {
let content = vec![text("abc"), text("MID"), text("def")];
let r = SelectionRange {
start: lead(0, 1),
end: lead(2, 2),
};
assert_eq!(extract_text_in_range(&content, &r), "bcMIDde");
}
#[test]
fn extract_text_in_range_skips_non_text_items_in_the_span() {
let content = vec![text("abc"), obj(), text("def")];
let r = SelectionRange {
start: lead(0, 1),
end: lead(2, 2),
};
assert_eq!(extract_text_in_range(&content, &r), "bcde");
}
#[test]
fn extract_text_in_range_out_of_bounds_yields_empty_string() {
let content = vec![text("hi")];
let r = SelectionRange {
start: lead(0, 0),
end: lead(0, 99),
};
assert_eq!(extract_text_in_range(&content, &r), "");
let r = SelectionRange {
start: lead(9, 0),
end: lead(9, 1),
};
assert_eq!(extract_text_in_range(&content, &r), "");
let r = SelectionRange {
start: lead(0, 0),
end: lead(0, 1),
};
assert_eq!(extract_text_in_range(&[], &r), "");
}
#[test]
fn extract_text_in_range_backward_single_run_yields_empty_string() {
let content = vec![text("hello")];
let r = SelectionRange {
start: lead(0, 3),
end: lead(0, 1),
};
assert_eq!(extract_text_in_range(&content, &r), "");
}
#[test]
fn extract_text_in_range_ignores_affinity_and_drops_the_last_cluster() {
let content = vec![text("hello")];
let r = SelectionRange {
start: lead(0, 0),
end: trail(0, 4),
};
assert_eq!(extract_text_in_range(&content, &r), "hell", "the 'o' is missing");
let (after, _) = delete_range(&content, &r);
assert_eq!(dump(&after), vec![""], "...yet delete_range removes all of it");
}
}