use std::borrow::Cow;
use std::ops::Range;
use kurbo::{Point, Rect, Size};
use parley::{BoundingBox, PlainEditor, StyleProperty};
use peniko::Brush;
use frust_scene::GlyphRun;
use crate::context::TextContext;
use crate::style::{
TextStyle, to_parley_family, to_parley_line_height, to_parley_style, to_parley_weight,
};
#[derive(Clone, Debug, PartialEq)]
pub enum EditOp {
Insert(String),
InsertNewline,
Backdelete,
Delete,
MoveLeft { select: bool },
MoveRight { select: bool },
MoveUp { select: bool },
MoveDown { select: bool },
MoveWordLeft { select: bool },
MoveWordRight { select: bool },
Home { select: bool },
End { select: bool },
SelectAll,
CollapseSelection,
MoveToPoint { x: f32, y: f32, select: bool },
SelectWordAtPoint { x: f32, y: f32 },
Compose {
text: String,
cursor: Option<(usize, usize)>,
},
FinishCompose,
ClearCompose,
ApplyEditingState(EditingStateBytes),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditingStateBytes {
pub text: String,
pub base: usize,
pub extent: usize,
pub composing: Option<Range<usize>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditingState {
pub text: String,
pub selection_base: i32,
pub selection_extent: i32,
pub composing_base: i32,
pub composing_extent: i32,
}
pub struct TextEditor {
editor: PlainEditor<Brush>,
platform_composing: Option<Range<usize>>,
}
impl TextEditor {
pub fn new(style: &TextStyle) -> Self {
let mut editor = PlainEditor::<Brush>::new(style.size);
editor.set_width(None);
let styles = editor.edit_styles();
styles.insert(StyleProperty::FontFamily(to_parley_family(&style.family)));
styles.insert(StyleProperty::FontWeight(to_parley_weight(style.weight)));
styles.insert(StyleProperty::FontStyle(to_parley_style(style.style)));
styles.insert(StyleProperty::LetterSpacing(style.letter_spacing));
styles.insert(StyleProperty::LineHeight(to_parley_line_height(
style.line_height,
)));
styles.insert(StyleProperty::Brush(Brush::Solid(style.color)));
Self {
editor,
platform_composing: None,
}
}
pub fn apply(&mut self, op: EditOp, ctx: &mut TextContext) {
let (font_cx, layout_cx) = ctx.driver_contexts();
match op {
EditOp::ApplyEditingState(state) => {
if self.editing_state_bytes() == state {
return;
}
if self.editor.raw_text() != state.text {
self.editor.set_text(&state.text);
}
{
let mut drv = self.editor.driver(font_cx, layout_cx);
drv.select_byte_range(state.base, state.extent);
}
self.platform_composing = state.composing;
}
other => {
let mut drv = self.editor.driver(font_cx, layout_cx);
match other {
EditOp::Insert(s) => drv.insert_or_replace_selection(&s),
EditOp::InsertNewline => drv.insert_or_replace_selection("\n"),
EditOp::Backdelete => drv.backdelete(),
EditOp::Delete => drv.delete(),
EditOp::MoveLeft { select } => {
sel(&mut drv, select, |d| d.move_left(), |d| d.select_left())
}
EditOp::MoveRight { select } => {
sel(&mut drv, select, |d| d.move_right(), |d| d.select_right())
}
EditOp::MoveUp { select } => {
sel(&mut drv, select, |d| d.move_up(), |d| d.select_up())
}
EditOp::MoveDown { select } => {
sel(&mut drv, select, |d| d.move_down(), |d| d.select_down())
}
EditOp::MoveWordLeft { select } => sel(
&mut drv,
select,
|d| d.move_word_left(),
|d| d.select_word_left(),
),
EditOp::MoveWordRight { select } => sel(
&mut drv,
select,
|d| d.move_word_right(),
|d| d.select_word_right(),
),
EditOp::Home { select } => sel(
&mut drv,
select,
|d| d.move_to_line_start(),
|d| d.select_to_line_start(),
),
EditOp::End { select } => sel(
&mut drv,
select,
|d| d.move_to_line_end(),
|d| d.select_to_line_end(),
),
EditOp::SelectAll => drv.select_all(),
EditOp::CollapseSelection => drv.collapse_selection(),
EditOp::MoveToPoint { x, y, select } => {
if select {
drv.extend_selection_to_point(x, y);
} else {
drv.move_to_point(x, y);
}
}
EditOp::SelectWordAtPoint { x, y } => drv.select_word_at_point(x, y),
EditOp::Compose { text, cursor } => drv.set_compose(&text, cursor),
EditOp::FinishCompose => drv.finish_compose(),
EditOp::ClearCompose => drv.clear_compose(),
EditOp::ApplyEditingState(_) => unreachable!(),
}
}
}
self.editor.refresh_layout(font_cx, layout_cx);
}
pub fn set_wrap_width(&mut self, width: Option<f32>, ctx: &mut TextContext) {
self.editor.set_width(width);
let (font_cx, layout_cx) = ctx.driver_contexts();
self.editor.refresh_layout(font_cx, layout_cx);
}
pub fn line_count(&self) -> usize {
self.editor
.try_layout()
.map(|layout| layout.len())
.unwrap_or(0)
}
pub fn text(&self) -> &str {
self.editor.raw_text()
}
pub fn selected_text(&self) -> Option<&str> {
let text = self.text();
let selection = self.editor.raw_selection();
let base = selection.anchor().index();
let extent = selection.focus().index();
if base == extent {
return None;
}
let start = base.min(extent);
let end = base.max(extent);
#[cfg(debug_assertions)]
assert!(
text.is_char_boundary(start) && text.is_char_boundary(end),
"selected text byte offsets are not char boundaries: start={}, end={}, text.len()={}",
start,
end,
text.len()
);
#[cfg(not(debug_assertions))]
if !text.is_char_boundary(start) || !text.is_char_boundary(end) {
return None;
}
Some(&text[start..end])
}
pub fn layout_size(&self) -> Size {
match self.editor.try_layout() {
Some(layout) => Size::new(layout.width() as f64, layout.height() as f64),
None => Size::ZERO,
}
}
pub fn editing_state_bytes(&self) -> EditingStateBytes {
let selection = self.editor.raw_selection();
EditingStateBytes {
text: self.editor.raw_text().to_string(),
base: selection.anchor().index(),
extent: selection.focus().index(),
composing: self.composing_range(),
}
}
pub fn editing_state_utf16(&self) -> EditingState {
let text = self.editor.raw_text();
let selection = self.editor.raw_selection();
let (composing_base, composing_extent) = match self.composing_range() {
Some(range) => (
byte_to_utf16(text, range.start) as i32,
byte_to_utf16(text, range.end) as i32,
),
None => (-1, -1),
};
EditingState {
text: text.to_string(),
selection_base: byte_to_utf16(text, selection.anchor().index()) as i32,
selection_extent: byte_to_utf16(text, selection.focus().index()) as i32,
composing_base,
composing_extent,
}
}
pub fn cursor_rect(&self, caret_width: f32) -> Option<Rect> {
self.editor.cursor_geometry(caret_width).map(bbox_to_rect)
}
pub fn selection_rects(&self) -> Vec<Rect> {
self.editor
.selection_geometry()
.into_iter()
.map(|(bbox, _line)| bbox_to_rect(bbox))
.collect()
}
pub fn ime_cursor_area(&self) -> Rect {
bbox_to_rect(self.editor.ime_cursor_area())
}
pub fn to_scene_runs(&self, origin: Point) -> Vec<GlyphRun> {
match self.editor.try_layout() {
Some(layout) => crate::convert::layout_to_scene_runs(layout, origin),
None => Vec::new(),
}
}
fn composing_range(&self) -> Option<Range<usize>> {
self.editor
.raw_compose()
.clone()
.or_else(|| self.platform_composing.clone())
}
}
fn sel<'a>(
drv: &mut parley::PlainEditorDriver<'a, Brush>,
select: bool,
mv: impl FnOnce(&mut parley::PlainEditorDriver<'a, Brush>),
ext: impl FnOnce(&mut parley::PlainEditorDriver<'a, Brush>),
) {
if select {
ext(drv);
} else {
mv(drv);
}
}
fn bbox_to_rect(bbox: BoundingBox) -> Rect {
Rect::new(bbox.x0, bbox.y0, bbox.x1, bbox.y1)
}
pub fn byte_to_utf16(text: &str, byte_idx: usize) -> usize {
let mut boundary = byte_idx.min(text.len());
while boundary > 0 && !text.is_char_boundary(boundary) {
boundary -= 1;
}
text[..boundary].chars().map(char::len_utf16).sum()
}
pub fn utf16_to_byte(text: &str, utf16_idx: usize) -> usize {
let mut units = 0usize;
for (byte_off, ch) in text.char_indices() {
if utf16_idx <= units {
return byte_off;
}
let next = units + ch.len_utf16();
if utf16_idx < next {
return byte_off;
}
units = next;
}
text.len()
}
pub fn sanitize_paste(text: &str, single_line: bool) -> Cow<'_, str> {
if single_line {
if text.contains('\r') || text.contains('\n') {
let sanitized = text.replace(['\r', '\n'], "");
Cow::Owned(sanitized)
} else {
Cow::Borrowed(text)
}
} else {
if text.contains('\r') {
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
Cow::Owned(normalized)
} else {
Cow::Borrowed(text)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::{FontStyle, FontWeight};
use peniko::Color;
fn style() -> TextStyle {
TextStyle::new(24.0, Color::BLACK)
}
fn editor() -> (TextEditor, TextContext) {
(TextEditor::new(&style()), TextContext::new())
}
const EMOJI: &str = "\u{1F600}";
const FAMILY: &str = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}";
#[test]
fn insert_appends_text_and_advances_caret() {
let (mut ed, mut cx) = editor();
ed.apply(EditOp::Insert("Hi".into()), &mut cx);
assert_eq!(ed.text(), "Hi");
let st = ed.editing_state_bytes();
assert_eq!(st.base, 2);
assert_eq!(st.extent, 2);
}
#[test]
fn backdelete_over_emoji_removes_whole_code_point() {
let (mut ed, mut cx) = editor();
ed.apply(EditOp::Insert(format!("a{EMOJI}")), &mut cx);
assert_eq!(ed.text(), format!("a{EMOJI}"));
ed.apply(EditOp::Backdelete, &mut cx);
assert_eq!(ed.text(), "a");
}
#[test]
fn backdelete_over_zwj_sequence_removes_a_whole_code_point() {
let (mut ed, mut cx) = editor();
ed.apply(EditOp::Insert(format!("x{FAMILY}")), &mut cx);
let before = ed.text().len();
ed.apply(EditOp::Backdelete, &mut cx);
let after = ed.text();
assert_eq!(before - after.len(), 4, "one 4-byte code point removed");
assert!(after.starts_with('x'));
}
#[test]
fn forward_delete_removes_following_grapheme() {
let (mut ed, mut cx) = editor();
ed.apply(EditOp::Insert("abc".into()), &mut cx);
ed.apply(EditOp::Home { select: false }, &mut cx);
ed.apply(EditOp::Delete, &mut cx);
assert_eq!(ed.text(), "bc");
}
#[test]
fn move_left_right_adjust_caret_without_selecting() {
let (mut ed, mut cx) = editor();
ed.apply(EditOp::Insert("abc".into()), &mut cx);
ed.apply(EditOp::MoveLeft { select: false }, &mut cx);
assert_eq!(ed.editing_state_bytes().extent, 2);
let st = ed.editing_state_bytes();
assert_eq!(st.base, st.extent, "plain move must collapse the selection");
ed.apply(EditOp::MoveRight { select: false }, &mut cx);
assert_eq!(ed.editing_state_bytes().extent, 3);
}
#[test]
fn move_left_with_select_extends_selection() {
let (mut ed, mut cx) = editor();
ed.apply(EditOp::Insert("abc".into()), &mut cx);
ed.apply(EditOp::MoveLeft { select: true }, &mut cx);
let st = ed.editing_state_bytes();
assert_ne!(st.base, st.extent, "select variant must extend selection");
assert_eq!(st.base, 3);
assert_eq!(st.extent, 2);
}
#[test]
fn word_move_jumps_over_a_word() {
let (mut ed, mut cx) = editor();
ed.apply(EditOp::Insert("hello world".into()), &mut cx);
ed.apply(EditOp::Home { select: false }, &mut cx);
assert_eq!(ed.editing_state_bytes().extent, 0);
ed.apply(EditOp::MoveWordRight { select: false }, &mut cx);
let after = ed.editing_state_bytes().extent;
assert!(
after > 0 && after <= 6,
"word-right should land near the space, got {after}"
);
}
#[test]
fn home_and_end_move_to_line_bounds() {
let (mut ed, mut cx) = editor();
ed.apply(EditOp::Insert("hello".into()), &mut cx);
ed.apply(EditOp::Home { select: false }, &mut cx);
assert_eq!(ed.editing_state_bytes().extent, 0);
ed.apply(EditOp::End { select: false }, &mut cx);
assert_eq!(ed.editing_state_bytes().extent, 5);
}
#[test]
fn select_all_then_collapse() {
let (mut ed, mut cx) = editor();
ed.apply(EditOp::Insert("hello".into()), &mut cx);
ed.apply(EditOp::SelectAll, &mut cx);
let st = ed.editing_state_bytes();
assert_eq!(st.base.min(st.extent), 0);
assert_eq!(st.base.max(st.extent), 5);
ed.apply(EditOp::CollapseSelection, &mut cx);
let st = ed.editing_state_bytes();
assert_eq!(st.base, st.extent, "collapse must drop the anchor");
}
#[test]
fn insert_replaces_selection() {
let (mut ed, mut cx) = editor();
ed.apply(EditOp::Insert("hello".into()), &mut cx);
ed.apply(EditOp::SelectAll, &mut cx);
ed.apply(EditOp::Insert("bye".into()), &mut cx);
assert_eq!(ed.text(), "bye");
}
#[test]
fn compose_lifecycle_commit() {
let (mut ed, mut cx) = editor();
ed.apply(
EditOp::Compose {
text: "ni".into(),
cursor: Some((2, 2)),
},
&mut cx,
);
let st = ed.editing_state_bytes();
assert_eq!(st.composing, Some(0..2), "preedit range should be reported");
assert_eq!(ed.text(), "ni");
ed.apply(EditOp::FinishCompose, &mut cx);
let st = ed.editing_state_bytes();
assert_eq!(st.composing, None, "commit clears the composing region");
assert_eq!(ed.text(), "ni");
}
#[test]
fn compose_lifecycle_clear_reverts() {
let (mut ed, mut cx) = editor();
ed.apply(EditOp::Insert("a".into()), &mut cx);
ed.apply(
EditOp::Compose {
text: "xyz".into(),
cursor: None,
},
&mut cx,
);
assert_eq!(ed.text(), "axyz");
ed.apply(EditOp::ClearCompose, &mut cx);
assert_eq!(ed.text(), "a");
assert_eq!(ed.editing_state_bytes().composing, None);
}
#[test]
fn apply_editing_state_sets_text_selection_and_composing() {
let (mut ed, mut cx) = editor();
let state = EditingStateBytes {
text: "hello".into(),
base: 1,
extent: 3,
composing: Some(0..5),
};
ed.apply(EditOp::ApplyEditingState(state.clone()), &mut cx);
assert_eq!(ed.text(), "hello");
assert_eq!(ed.editing_state_bytes(), state);
}
#[test]
fn apply_editing_state_is_idempotent_and_short_circuits() {
let (mut ed, mut cx) = editor();
let state = EditingStateBytes {
text: "world".into(),
base: 2,
extent: 2,
composing: None,
};
ed.apply(EditOp::ApplyEditingState(state.clone()), &mut cx);
let first = ed.editing_state_bytes();
ed.apply(EditOp::ApplyEditingState(state.clone()), &mut cx);
assert_eq!(ed.editing_state_bytes(), first);
assert_eq!(ed.editing_state_bytes(), state);
}
#[test]
fn apply_editing_state_selection_only_change_keeps_text() {
let (mut ed, mut cx) = editor();
ed.apply(
EditOp::ApplyEditingState(EditingStateBytes {
text: "abcd".into(),
base: 0,
extent: 0,
composing: None,
}),
&mut cx,
);
ed.apply(
EditOp::ApplyEditingState(EditingStateBytes {
text: "abcd".into(),
base: 1,
extent: 3,
composing: None,
}),
&mut cx,
);
let st = ed.editing_state_bytes();
assert_eq!(st.text, "abcd");
assert_eq!((st.base, st.extent), (1, 3));
}
#[test]
fn editing_state_utf16_converts_offsets_for_emoji() {
let (mut ed, mut cx) = editor();
ed.apply(
EditOp::ApplyEditingState(EditingStateBytes {
text: format!("a{EMOJI}"),
base: 5,
extent: 5,
composing: Some(1..5),
}),
&mut cx,
);
let st = ed.editing_state_utf16();
assert_eq!(st.selection_base, 3, "1 + 2 UTF-16 units");
assert_eq!(st.selection_extent, 3);
assert_eq!(st.composing_base, 1);
assert_eq!(st.composing_extent, 3);
}
#[test]
fn editing_state_utf16_reports_minus_one_when_not_composing() {
let (mut ed, mut cx) = editor();
ed.apply(EditOp::Insert("hi".into()), &mut cx);
let st = ed.editing_state_utf16();
assert_eq!(st.composing_base, -1);
assert_eq!(st.composing_extent, -1);
}
#[test]
fn cursor_and_selection_geometry_are_sane() {
let (mut ed, mut cx) = editor();
ed.apply(EditOp::Insert("hello".into()), &mut cx);
let caret = ed
.cursor_rect(2.0)
.expect("caret should exist after typing");
assert!(
caret.width() > 0.0,
"caret width should reflect the requested size"
);
assert!(caret.height() > 0.0, "caret should have positive height");
ed.apply(EditOp::SelectAll, &mut cx);
let rects = ed.selection_rects();
assert!(
!rects.is_empty(),
"a non-empty selection should yield rects"
);
for r in &rects {
assert!(
r.width() > 0.0 && r.height() > 0.0,
"selection rect must be non-degenerate: {r:?}"
);
assert!(r.x1 >= r.x0 && r.y1 >= r.y0, "rect must be ordered: {r:?}");
}
let ime = ed.ime_cursor_area();
assert!(
ime.x1 >= ime.x0 && ime.y1 >= ime.y0,
"ime area must be ordered"
);
}
#[test]
fn to_scene_runs_translates_by_origin_and_is_empty_when_fresh() {
let (mut ed, mut cx) = editor();
assert!(ed.to_scene_runs(Point::ORIGIN).is_empty());
ed.apply(EditOp::Insert("Ag".into()), &mut cx);
let runs = ed.to_scene_runs(Point::new(10.0, 20.0));
assert!(!runs.is_empty(), "typed text should produce glyph runs");
for run in &runs {
assert_eq!(run.transform, kurbo::Affine::translate((10.0, 20.0)));
}
}
#[test]
fn byte_to_utf16_matrix() {
assert_eq!(byte_to_utf16("abc", 0), 0);
assert_eq!(byte_to_utf16("abc", 2), 2);
assert_eq!(byte_to_utf16("é", 0), 0);
assert_eq!(byte_to_utf16("é", 2), 1);
assert_eq!(byte_to_utf16("é", 1), 0);
assert_eq!(byte_to_utf16(EMOJI, 4), 2);
assert_eq!(byte_to_utf16(EMOJI, 2), 0, "mid-surrogate byte snaps back");
assert_eq!(byte_to_utf16(FAMILY, FAMILY.len()), 11);
assert_eq!(byte_to_utf16("abc", 99), 3);
}
#[test]
fn utf16_to_byte_matrix() {
assert_eq!(utf16_to_byte("abc", 0), 0);
assert_eq!(utf16_to_byte("abc", 2), 2);
assert_eq!(utf16_to_byte("é", 1), 2);
assert_eq!(utf16_to_byte(EMOJI, 2), 4);
assert_eq!(
utf16_to_byte(EMOJI, 1),
0,
"mid-surrogate snaps to char start"
);
assert_eq!(utf16_to_byte(FAMILY, 11), FAMILY.len());
assert_eq!(utf16_to_byte("abc", 99), 3);
}
#[test]
fn conversion_helpers_round_trip_on_boundaries() {
for text in ["", "abc", "é", EMOJI, FAMILY, "aé😀b"] {
let mut byte = 0;
for ch in text.chars() {
let units = byte_to_utf16(text, byte);
assert_eq!(
utf16_to_byte(text, units),
byte,
"round-trip failed at byte {byte} in {text:?}"
);
byte += ch.len_utf8();
}
let units = byte_to_utf16(text, text.len());
assert_eq!(utf16_to_byte(text, units), text.len());
}
}
#[test]
fn editor_letter_spacing_widens_layout_like_context_layout() {
let narrow_style = TextStyle::new(24.0, Color::BLACK);
let (mut narrow_ed, mut narrow_cx) = (TextEditor::new(&narrow_style), TextContext::new());
narrow_ed.apply(EditOp::Insert("Hello".into()), &mut narrow_cx);
let narrow_width = narrow_ed.layout_size().width;
let mut wide_style = TextStyle::new(24.0, Color::BLACK);
wide_style.letter_spacing = 20.0;
let (mut wide_ed, mut wide_cx) = (TextEditor::new(&wide_style), TextContext::new());
wide_ed.apply(EditOp::Insert("Hello".into()), &mut wide_cx);
let wide_width = wide_ed.layout_size().width;
assert!(
wide_width > narrow_width,
"the editor's own layout should widen with extra letter-spacing \
the same way TextContext::layout's does: {narrow_width} vs {wide_width}"
);
}
#[test]
fn single_line_mode_keeps_one_line_and_grows_horizontally() {
let (mut ed, mut cx) = editor();
ed.apply(
EditOp::Insert("the quick brown fox jumps over the lazy dog".into()),
&mut cx,
);
assert_eq!(ed.line_count(), 1, "single-line mode never wraps");
let one_line_h = ed.layout_size().height;
assert!(one_line_h > 0.0);
assert!(
ed.layout_size().width > 100.0,
"single line grows horizontally"
);
}
#[test]
fn wrap_width_produces_multiple_lines_and_grows_vertically() {
let (mut ed, mut cx) = editor();
ed.apply(
EditOp::Insert("the quick brown fox jumps over the lazy dog".into()),
&mut cx,
);
let single_h = ed.layout_size().height;
ed.set_wrap_width(Some(60.0), &mut cx);
assert!(
ed.line_count() > 1,
"narrow wrap width splits the run into multiple lines, got {}",
ed.line_count()
);
assert!(
ed.layout_size().height > single_h,
"wrapped multi-line layout is taller than the single line: {single_h} -> {}",
ed.layout_size().height
);
ed.set_wrap_width(None, &mut cx);
assert_eq!(ed.line_count(), 1, "None restores single-line mode");
}
#[test]
fn insert_newline_splits_into_two_lines() {
let (mut ed, mut cx) = editor();
ed.set_wrap_width(Some(400.0), &mut cx);
ed.apply(EditOp::Insert("ab".into()), &mut cx);
ed.apply(EditOp::InsertNewline, &mut cx);
ed.apply(EditOp::Insert("cd".into()), &mut cx);
assert_eq!(ed.text(), "ab\ncd");
assert_eq!(ed.line_count(), 2, "the newline creates a second line");
assert_eq!(ed.editing_state_bytes().extent, 5);
}
#[test]
fn move_up_down_navigate_across_a_newline() {
let (mut ed, mut cx) = editor();
ed.set_wrap_width(Some(400.0), &mut cx);
ed.apply(EditOp::Insert("abc".into()), &mut cx);
ed.apply(EditOp::InsertNewline, &mut cx);
ed.apply(EditOp::Insert("xyz".into()), &mut cx);
let bottom = ed.editing_state_bytes().extent;
assert_eq!(bottom, 7);
ed.apply(EditOp::MoveUp { select: false }, &mut cx);
let up = ed.editing_state_bytes().extent;
assert!(up <= 3, "move-up crosses the newline onto line 1, got {up}");
ed.apply(EditOp::MoveDown { select: false }, &mut cx);
let down = ed.editing_state_bytes().extent;
assert!(
down >= 4,
"move-down crosses the newline back onto line 2, got {down}"
);
}
#[test]
fn utf16_selection_indices_are_correct_across_a_newline() {
let (mut ed, mut cx) = editor();
ed.set_wrap_width(Some(400.0), &mut cx);
ed.apply(
EditOp::ApplyEditingState(EditingStateBytes {
text: format!("a{EMOJI}\nb"),
base: 0,
extent: format!("a{EMOJI}\nb").len(),
composing: None,
}),
&mut cx,
);
assert_eq!(ed.line_count(), 2, "the newline still splits the layout");
let st = ed.editing_state_utf16();
assert_eq!(st.selection_base, 0);
assert_eq!(
st.selection_extent, 5,
"the newline counts as exactly one UTF-16 unit"
);
}
#[test]
fn editor_applies_weight_and_style_edit_defaults_without_panicking() {
let mut style = TextStyle::new(24.0, Color::BLACK);
style.weight = FontWeight::BOLD;
style.style = FontStyle::Italic;
let (mut ed, mut cx) = (TextEditor::new(&style), TextContext::new());
ed.apply(EditOp::Insert("Hi".into()), &mut cx);
assert!(
ed.layout_size().width > 0.0,
"bold+italic edit-style defaults should still shape text"
);
}
#[test]
fn selected_text_returns_none_when_collapsed() {
let (mut ed, mut cx) = editor();
ed.apply(EditOp::Insert("hello".into()), &mut cx);
ed.apply(EditOp::Home { select: false }, &mut cx);
assert_eq!(ed.selected_text(), None);
}
#[test]
fn selected_text_returns_slice_with_forward_selection() {
let (mut ed, mut cx) = editor();
ed.apply(EditOp::Insert("hello".into()), &mut cx);
ed.apply(EditOp::Home { select: false }, &mut cx);
ed.apply(EditOp::MoveRight { select: true }, &mut cx);
ed.apply(EditOp::MoveRight { select: true }, &mut cx);
assert_eq!(ed.selected_text(), Some("he"));
}
#[test]
fn selected_text_returns_same_slice_with_backward_selection() {
let (mut ed, mut cx) = editor();
ed.apply(EditOp::Insert("hello".into()), &mut cx);
ed.apply(EditOp::End { select: false }, &mut cx);
ed.apply(EditOp::MoveLeft { select: true }, &mut cx);
ed.apply(EditOp::MoveLeft { select: true }, &mut cx);
assert_eq!(ed.selected_text(), Some("lo"));
}
#[test]
fn selected_text_spans_multi_byte_emoji_correctly() {
let (mut ed, mut cx) = editor();
ed.apply(EditOp::Insert(format!("a{EMOJI}b")), &mut cx);
ed.apply(EditOp::SelectAll, &mut cx);
let selected = ed.selected_text().expect("full selection should exist");
assert_eq!(selected, format!("a{EMOJI}b"));
}
#[test]
fn selected_text_can_select_just_the_emoji() {
let (mut ed, mut cx) = editor();
ed.apply(EditOp::Insert(format!("a{EMOJI}b")), &mut cx);
ed.apply(EditOp::Home { select: false }, &mut cx);
ed.apply(EditOp::MoveRight { select: false }, &mut cx);
ed.apply(EditOp::MoveRight { select: true }, &mut cx);
assert_eq!(ed.selected_text(), Some(EMOJI));
}
#[test]
fn sanitize_paste_single_line_removes_crlf() {
let result = sanitize_paste("a\r\nb\n", true);
assert_eq!(result, Cow::Borrowed::<str>("ab") as Cow<str>);
assert_eq!(result.as_ref(), "ab");
}
#[test]
fn sanitize_paste_single_line_removes_lone_cr() {
let result = sanitize_paste("a\rb", true);
assert_eq!(result.as_ref(), "ab");
}
#[test]
fn sanitize_paste_single_line_removes_lone_lf() {
let result = sanitize_paste("a\nb", true);
assert_eq!(result.as_ref(), "ab");
}
#[test]
fn sanitize_paste_multi_line_normalizes_crlf_and_cr() {
let result = sanitize_paste("a\r\nb\rc", false);
assert_eq!(result.as_ref(), "a\nb\nc");
}
#[test]
fn sanitize_paste_multi_line_keeps_existing_lf() {
let result = sanitize_paste("a\nb\nc", false);
assert_eq!(result, Cow::Borrowed("a\nb\nc"));
}
#[test]
fn sanitize_paste_returns_borrowed_when_unchanged() {
let text = "hello world";
let result_single = sanitize_paste(text, true);
assert_eq!(result_single, Cow::Borrowed(text));
let result_multi = sanitize_paste(text, false);
assert_eq!(result_multi, Cow::Borrowed(text));
}
#[test]
fn sanitize_paste_single_line_multiple_newlines() {
let result = sanitize_paste("line1\nline2\nline3", true);
assert_eq!(result.as_ref(), "line1line2line3");
}
#[test]
fn sanitize_paste_multi_line_complex_mixed_endings() {
let result = sanitize_paste("a\r\nb\rc\nd", false);
assert_eq!(result.as_ref(), "a\nb\nc\nd");
}
#[test]
fn sanitize_paste_empty_string() {
let result_single = sanitize_paste("", true);
assert_eq!(result_single, Cow::Borrowed(""));
let result_multi = sanitize_paste("", false);
assert_eq!(result_multi, Cow::Borrowed(""));
}
#[test]
fn sanitize_paste_only_newlines_single_line() {
let result = sanitize_paste("\r\n\n\r", true);
assert_eq!(result.as_ref(), "");
}
#[test]
fn sanitize_paste_preserves_other_whitespace() {
let text = "a \t b";
let result = sanitize_paste(text, true);
assert_eq!(result.as_ref(), text);
}
}