#![allow(dead_code)] use macros::ToJs; use crate::shapes::{ Fill, FontStyle, TextAlign, TextContent, TextDecoration, TextDirection, TextPositionWithAffinity, TextTransform, VerticalAlign, }; use crate::uuid::Uuid; use crate::wasm::text::helpers::{self as text_helpers, find_text_span_at_offset}; use crate::wasm::text_editor::CursorDirection; use skia_safe::Color; #[derive(Debug, Clone, Copy, Default)] pub struct TextSelection { pub anchor: TextPositionWithAffinity, pub focus: TextPositionWithAffinity, } impl TextSelection { pub fn new() -> Self { Self::default() } pub fn from_position_with_affinity(position: TextPositionWithAffinity) -> Self { Self { anchor: position, focus: position, } } pub fn is_collapsed(&self) -> bool { self.anchor == self.focus } pub fn is_selection(&self) -> bool { !self.is_collapsed() } pub fn reset(&mut self) { self.anchor.reset(); self.focus.reset(); } pub fn set_caret(&mut self, cursor: TextPositionWithAffinity) { self.anchor = cursor; self.focus = cursor; } pub fn extend_to(&mut self, cursor: TextPositionWithAffinity) { self.focus = cursor; } pub fn collapse_to_focus(&mut self) { self.anchor = self.focus; } pub fn collapse_to_anchor(&mut self) { self.focus = self.anchor; } pub fn start(&self) -> TextPositionWithAffinity { if self.anchor.paragraph < self.focus.paragraph { self.anchor } else if self.anchor.paragraph > self.focus.paragraph { self.focus } else if self.anchor.offset <= self.focus.offset { self.anchor } else { self.focus } } pub fn end(&self) -> TextPositionWithAffinity { if self.anchor.paragraph > self.focus.paragraph { self.anchor } else if self.anchor.paragraph < self.focus.paragraph { self.focus } else if self.anchor.offset >= self.focus.offset { self.anchor } else { self.focus } } } /// Events that the text editor can emit for frontend synchronization /// FIXME: the serialization should be in the wasm module #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq, ToJs)] pub enum TextEditorEvent { None = 0, ContentChanged = 1, SelectionChanged = 2, StylesChanged = 3, NeedsLayout = 4, } /// FIXME: It should be better to get these constants from the frontend through the API. const SELECTION_COLOR: Color = Color::from_argb(127, 0, 209, 184); const CURSOR_COLOR: Color = Color::WHITE; const CURSOR_WIDTH: f32 = 1.0; const CURSOR_BLINK_INTERVAL_MS: f32 = 530.0; #[derive(Debug)] pub struct TextEditorStyles { pub vertical_align: VerticalAlign, pub text_align: Multiple, // Multiple pub text_direction: Multiple, // Multiple pub text_decoration: Multiple, pub text_transform: Multiple, // The font family is decomposed into its independent parts so the family // dropdown shows a single family even when the selection mixes variants of // the same family (e.g. regular + bold italic). The id identifies the // family; weight is tracked by `font_weight`; only the style is left here. pub font_family_id: Multiple, pub font_style: Multiple, pub font_size: Multiple, pub font_weight: Multiple, pub font_variant_id: Multiple, pub line_height: Multiple, pub letter_spacing: Multiple, pub fills_are_multiple: bool, pub fills: Vec, } // FIXME: the serialization should be in the wasm module #[derive(Debug, Clone, Copy, PartialEq, ToJs)] #[repr(u8)] pub enum MultipleState { Undefined = 0, Single = 1, Multiple = 2, } #[derive(Debug)] pub struct Multiple { state: MultipleState, value: Option, } impl Multiple { pub fn empty() -> Self { Self { state: MultipleState::Undefined, value: None, } } pub fn new(state: MultipleState, value: Option) -> Self { Self { state, value } } pub fn state(&self) -> &MultipleState { &self.state } pub fn value(&self) -> &Option { &self.value } pub fn is_undefined(&self) -> bool { self.state == MultipleState::Undefined } pub fn is_multiple(&self) -> bool { self.state == MultipleState::Multiple } pub fn is_single(&self) -> bool { self.state == MultipleState::Single } pub fn is_single_some(&self) -> bool { !self.is_undefined() && self.value.is_some() } pub fn is_single_none(&self) -> bool { !self.is_undefined() && self.value.is_none() } pub fn reset(&mut self) { self.state = MultipleState::Undefined; self.value = None; } pub fn set(&mut self, value: Option) { if self.state == MultipleState::Undefined || self.state == MultipleState::Multiple { self.state = MultipleState::Single; } self.value = value; } pub fn set_single(&mut self, value: Option) { self.state = MultipleState::Single; self.value = value; } pub fn set_multiple(&mut self) { self.state = MultipleState::Multiple; self.value = None; } pub fn merge(&mut self, value: Option) -> bool where T: PartialEq, { if self.state == MultipleState::Multiple { return false; } if self.state == MultipleState::Undefined { self.set_single(value); return true; } if self.value.as_ref() != value.as_ref() { self.set_multiple(); return false; } self.value = value; true } } impl TextEditorStyles { pub fn new() -> Self { Self { vertical_align: VerticalAlign::Top, text_align: Multiple::empty(), text_direction: Multiple::empty(), text_decoration: Multiple::empty(), text_transform: Multiple::empty(), font_family_id: Multiple::empty(), font_style: Multiple::empty(), font_size: Multiple::empty(), font_weight: Multiple::empty(), font_variant_id: Multiple::empty(), line_height: Multiple::empty(), letter_spacing: Multiple::empty(), fills_are_multiple: false, fills: Vec::new(), } } pub fn reset(&mut self) { self.text_align.reset(); self.text_direction.reset(); self.text_decoration.reset(); self.text_transform.reset(); self.font_family_id.reset(); self.font_style.reset(); self.font_size.reset(); self.font_weight.reset(); self.font_variant_id.reset(); self.line_height.reset(); self.letter_spacing.reset(); self.fills_are_multiple = false; self.fills.clear(); } } impl Default for TextEditorStyles { fn default() -> Self { Self::new() } } pub struct TextEditorTheme { pub selection_color: Color, pub cursor_color: Color, pub cursor_width: f32, /// When true the caret is painted with a Difference blend mode, so it shows /// as the inverted color of whatever is behind it. Used as the default when /// the text has no solid fill whose color the caret can match. pub cursor_invert: bool, } pub struct TextComposition { pub previous: String, pub current: String, pub is_composing: bool, /// Preview start: each update replaces `[start, start + previous]`. pub start: Option, } impl TextComposition { pub fn new() -> Self { Self { previous: String::new(), current: String::new(), is_composing: false, start: None, } } pub fn start(&mut self) -> bool { if self.is_composing { return false; } self.is_composing = true; self.previous = String::new(); self.current = String::new(); self.start = None; true } pub fn update(&mut self, text: &str) -> bool { if !self.is_composing { self.is_composing = true; } self.previous = self.current.clone(); self.current = text.to_owned(); true } pub fn end(&mut self) -> bool { if !self.is_composing { return false; } self.is_composing = false; self.start = None; true } /// Range the next preview replaces: the previous preview, or `selection` /// (the pre-composition selection) when there is none yet. pub fn get_selection(&self, selection: &TextSelection) -> TextSelection { let Some(start) = self.start.filter(|_| !self.previous.is_empty()) else { return *selection; }; let anchor = TextPositionWithAffinity::new_downstream_affinity( start.paragraph, start.offset + self.previous.chars().count(), ); TextSelection { anchor, focus: start, } } } impl Default for TextComposition { fn default() -> Self { Self::new() } } pub struct TextEditorState { pub theme: TextEditorTheme, pub selection: TextSelection, pub composition: TextComposition, pub has_focus: bool, // This property indicates that we've started // selecting something with the pointer. pub is_pointer_selection_active: bool, pub is_click_event_skipped: bool, pub is_overtype_mode: bool, pub active_shape_id: Option, pub cursor_visible: bool, pub last_blink_time_ms: f32, pub current_styles: TextEditorStyles, pending_events: Vec, } impl TextEditorState { pub fn new() -> Self { Self { theme: TextEditorTheme { selection_color: SELECTION_COLOR, cursor_color: CURSOR_COLOR, cursor_width: CURSOR_WIDTH, cursor_invert: true, }, selection: TextSelection::new(), composition: TextComposition::new(), has_focus: false, is_pointer_selection_active: false, is_click_event_skipped: false, is_overtype_mode: false, active_shape_id: None, cursor_visible: true, last_blink_time_ms: 0.0, pending_events: Vec::new(), current_styles: TextEditorStyles::new(), } } pub fn focus(&mut self, shape_id: Uuid) { let same_shape = self.active_shape_id == Some(shape_id); self.has_focus = true; self.active_shape_id = Some(shape_id); self.cursor_visible = true; self.last_blink_time_ms = 0.0; if !same_shape { self.selection.reset(); } self.is_pointer_selection_active = false; self.is_overtype_mode = false; self.pending_events.clear(); } pub fn blur(&mut self) { self.has_focus = false; // self.active_shape_id = None; self.cursor_visible = false; self.last_blink_time_ms = 0.0; // self.selection.reset(); self.is_pointer_selection_active = false; self.is_overtype_mode = false; self.pending_events.clear(); } pub fn dispose(&mut self) { self.has_focus = false; self.active_shape_id = None; self.cursor_visible = false; self.last_blink_time_ms = 0.0; self.selection.reset(); self.is_pointer_selection_active = false; self.is_overtype_mode = false; self.pending_events.clear(); } pub fn start_pointer_selection(&mut self) -> bool { if self.is_pointer_selection_active { return false; } self.is_pointer_selection_active = true; true } pub fn stop_pointer_selection(&mut self) -> bool { if !self.is_pointer_selection_active { return false; } self.is_pointer_selection_active = false; true } fn select_range( &mut self, text_content: &TextContent, start: &TextPositionWithAffinity, end: &TextPositionWithAffinity, ) { self.is_pointer_selection_active = false; self.is_click_event_skipped = false; self.set_caret_from_position(start); self.extend_selection_from_position(end); self.update_styles(text_content); self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); } pub fn select_all(&mut self, text_content: &TextContent) -> bool { let num_paragraphs = text_content.paragraphs().len().saturating_sub(1); let Some(last_paragraph) = text_content.paragraphs().last() else { return false; }; let Some(_last_text_span) = last_paragraph.children().last() else { return false; }; // Offsets are counted in characters, not bytes. let offset = text_helpers::paragraph_char_count(last_paragraph); self.select_range( text_content, &TextPositionWithAffinity::empty(), &TextPositionWithAffinity::new_upstream_affinity(num_paragraphs, offset), ); true } pub fn select_word_boundary( &mut self, text_content: &TextContent, position: &TextPositionWithAffinity, ) { let paragraphs = text_content.paragraphs(); if paragraphs.is_empty() || position.paragraph >= paragraphs.len() { return; } let paragraph = ¶graphs[position.paragraph]; let paragraph_text: String = paragraph .children() .iter() .map(|span| span.text.as_str()) .collect(); let chars: Vec = paragraph_text.chars().collect(); if chars.is_empty() { self.set_caret_from_position(&TextPositionWithAffinity::new_downstream_affinity( position.paragraph, 0, )); self.update_styles(text_content); self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); return; } let mut offset = position.offset.min(chars.len()); if offset == chars.len() { offset = offset.saturating_sub(1); } else if !text_helpers::is_word_char(chars[offset]) && offset > 0 && text_helpers::is_word_char(chars[offset - 1]) { offset -= 1; } if !text_helpers::is_word_char(chars[offset]) { self.set_caret_from_position(&TextPositionWithAffinity::new_downstream_affinity( position.paragraph, position.offset.min(chars.len()), )); self.update_styles(text_content); self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); return; } let mut start = offset; while start > 0 && text_helpers::is_word_char(chars[start - 1]) { start -= 1; } let mut end = offset + 1; while end < chars.len() && text_helpers::is_word_char(chars[end]) { end += 1; } self.select_range( text_content, &TextPositionWithAffinity::new_downstream_affinity(position.paragraph, start), &TextPositionWithAffinity::new_downstream_affinity(position.paragraph, end), ); } pub fn select_paragraph( &mut self, text_content: &TextContent, position: &TextPositionWithAffinity, ) { let paragraphs = text_content.paragraphs(); if paragraphs.is_empty() || position.paragraph >= paragraphs.len() { return; } // Offsets are counted in characters, not bytes. let offset = text_helpers::paragraph_char_count(¶graphs[position.paragraph]); self.select_range( text_content, &TextPositionWithAffinity::new_downstream_affinity(position.paragraph, 0), &TextPositionWithAffinity::new_upstream_affinity(position.paragraph, offset), ); } pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) { self.selection.set_caret(*position); // Restart the blink so the caret is solid right after it is placed, // instead of keeping whatever phase it had (which can toggle off at the // moment of the click and read as a flash). Mirrors the keyboard paths // (`move_cursor`, `select_all`) which already reset the blink. self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); } /// Places the caret and refreshes `current_styles` (emits `StylesChanged`). pub fn place_caret_from_position( &mut self, text_content: &TextContent, position: &TextPositionWithAffinity, ) { self.set_caret_from_position(position); self.update_styles(text_content); } pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) { self.selection.extend_to(*position); self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); } pub fn set_overtype_mode(&mut self, overtype_mode: bool) { self.is_overtype_mode = overtype_mode; } pub fn toggle_overtype_mode(&mut self) { self.set_overtype_mode(!self.is_overtype_mode); } fn update_styles_from_selection(&mut self, text_content: &TextContent) -> bool { let paragraphs = text_content.paragraphs(); if paragraphs.is_empty() { return false; } let start = self.selection.start(); if start.paragraph >= paragraphs.len() { return false; } let end = self.selection.end(); let end_paragraph = end.paragraph.min(paragraphs.len() - 1); self.current_styles.reset(); let mut has_selected_content = false; for (para_idx, paragraph) in paragraphs .iter() .enumerate() .take(end_paragraph + 1) .skip(start.paragraph) { let paragraph_char_count: usize = paragraph .children() .iter() .map(|span| span.text.chars().count()) .sum(); let range_start = if para_idx == start.paragraph { start.offset.min(paragraph_char_count) } else { 0 }; let range_end = if para_idx == end.paragraph { end.offset.min(paragraph_char_count) } else { paragraph_char_count }; if range_start >= range_end { continue; } has_selected_content = true; self.current_styles .text_align .merge(Some(paragraph.text_align())); let mut char_pos = 0; for span in paragraph.children() { let span_len = span.text.chars().count(); let span_start = char_pos; let span_end = char_pos + span_len; char_pos += span_len; let selected_start = range_start.max(span_start); let selected_end = range_end.min(span_end); if selected_start >= selected_end { continue; } self.current_styles .text_direction .merge(Some(span.text_direction)); self.current_styles .text_decoration .merge(span.text_decoration); self.current_styles .text_transform .merge(span.text_transform); self.current_styles .font_family_id .merge(Some(span.font_family.id())); self.current_styles .font_style .merge(Some(span.font_family.style())); self.current_styles.font_size.merge(Some(span.font_size)); self.current_styles .font_weight .merge(Some(span.font_weight)); self.current_styles .font_variant_id .merge(Some(span.font_variant_id)); self.current_styles .line_height .merge(Some(span.line_height)); self.current_styles .letter_spacing .merge(Some(span.letter_spacing)); if self.current_styles.fills.is_empty() { self.current_styles.fills.append(&mut span.fills.clone()); } else if self.current_styles.fills != span.fills { self.current_styles.fills_are_multiple = true; self.current_styles.fills.append(&mut span.fills.clone()); } } } has_selected_content } fn update_styles_from_caret(&mut self, text_content: &TextContent) -> bool { let focus = self.selection.focus; let paragraphs = text_content.paragraphs(); let Some(current_paragraph) = paragraphs.get(focus.paragraph) else { return false; }; let current_offset = focus.offset; let current_text_span = find_text_span_at_offset(current_paragraph, current_offset); self.current_styles.reset(); self.current_styles .text_align .set_single(Some(current_paragraph.text_align())); if let Some((text_span_index, _)) = current_text_span { if let Some(text_span) = current_paragraph.children().get(text_span_index) { self.current_styles .text_direction .set_single(Some(text_span.text_direction)); self.current_styles .text_decoration .set_single(text_span.text_decoration); self.current_styles .text_transform .set_single(text_span.text_transform); self.current_styles .font_family_id .set_single(Some(text_span.font_family.id())); self.current_styles .font_style .set_single(Some(text_span.font_family.style())); self.current_styles .font_size .set_single(Some(text_span.font_size)); self.current_styles .font_weight .set_single(Some(text_span.font_weight)); self.current_styles .font_variant_id .set_single(Some(text_span.font_variant_id)); self.current_styles .line_height .set_single(Some(text_span.line_height)); self.current_styles .letter_spacing .set_single(Some(text_span.letter_spacing)); self.current_styles.fills = text_span.fills.clone(); } } else { self.current_styles .line_height .set_single(Some(current_paragraph.line_height())); self.current_styles .letter_spacing .set_single(Some(current_paragraph.letter_spacing())); } true } pub fn update_styles(&mut self, text_content: &TextContent) -> bool { if self.selection.is_selection() { let styles_were_updated = self.update_styles_from_selection(text_content); if styles_were_updated { self.push_event(TextEditorEvent::StylesChanged); } return styles_were_updated; } // It is a caret. let styles_were_updated = self.update_styles_from_caret(text_content); if styles_were_updated { self.push_event(TextEditorEvent::StylesChanged); } styles_were_updated } pub fn update_blink(&mut self, timestamp_ms: f32) { if !self.has_focus { return; } if self.last_blink_time_ms == 0.0 { self.last_blink_time_ms = timestamp_ms; self.cursor_visible = true; return; } let elapsed = timestamp_ms - self.last_blink_time_ms; if elapsed >= CURSOR_BLINK_INTERVAL_MS { self.cursor_visible = !self.cursor_visible; self.last_blink_time_ms = timestamp_ms; } } pub fn reset_blink(&mut self) { self.cursor_visible = true; self.last_blink_time_ms = 0.0; } pub fn push_event(&mut self, event: TextEditorEvent) { if self.pending_events.last() != Some(&event) { self.pending_events.push(event); } } pub fn poll_event(&mut self) -> TextEditorEvent { self.pending_events.pop().unwrap_or(TextEditorEvent::None) } pub fn has_pending_events(&self) -> bool { !self.pending_events.is_empty() } pub fn delete_backward(&mut self, text_content: &mut TextContent, word_boundary: bool) { if self.selection.is_selection() { text_helpers::delete_selection_range(text_content, &self.selection); let start = self.selection.start(); let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs()); self.selection.set_caret(clamped); } else if word_boundary { let cursor = self.selection.focus; if let Some(new_cursor) = text_helpers::delete_word_before(text_content, &cursor) { self.selection.set_caret(new_cursor); } } else { let cursor = self.selection.focus; if let Some(new_cursor) = text_helpers::delete_char_before(text_content, &cursor) { self.selection.set_caret(new_cursor); } } text_content.layout.clear(); self.reset_blink(); self.push_event(TextEditorEvent::ContentChanged); self.push_event(TextEditorEvent::NeedsLayout); } pub fn delete_forward(&mut self, text_content: &mut TextContent, word_boundary: bool) { if self.selection.is_selection() { text_helpers::delete_selection_range(text_content, &self.selection); let start = self.selection.start(); let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs()); self.selection.set_caret(clamped); } else if word_boundary { let cursor = self.selection.focus; text_helpers::delete_word_after(text_content, &cursor); let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs()); self.selection.set_caret(clamped); } else { let cursor = self.selection.focus; text_helpers::delete_char_after(text_content, &cursor); let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs()); self.selection.set_caret(clamped); } text_content.layout.clear(); self.reset_blink(); self.push_event(TextEditorEvent::ContentChanged); self.push_event(TextEditorEvent::NeedsLayout); } pub fn insert_paragraph(&mut self, text_content: &mut TextContent) { if self.selection.is_selection() { text_helpers::delete_selection_range(text_content, &self.selection); let start = self.selection.start(); self.selection.set_caret(start); } let cursor = self.selection.focus; if text_helpers::split_paragraph_at_cursor(text_content, &cursor) { let new_cursor = TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph + 1, 0); self.selection.set_caret(new_cursor); } text_content.layout.clear(); self.reset_blink(); self.push_event(TextEditorEvent::ContentChanged); self.push_event(TextEditorEvent::NeedsLayout); } pub fn move_cursor( &mut self, text_content: &TextContent, direction: CursorDirection, word_boundary: bool, extend_selection: bool, ) -> bool { let paragraphs = text_content.paragraphs(); if paragraphs.is_empty() { return false; } // With a range selected, Left/Right collapse to that edge and stop. // Extending (Shift) keeps the normal move-from-focus path below. if self.selection.is_selection() && !extend_selection { let edge = match direction { CursorDirection::Backward => Some(self.selection.start()), CursorDirection::Forward => Some(self.selection.end()), _ => None, }; if let Some(caret) = edge { self.selection.set_caret(caret); self.update_styles(text_content); self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); return true; } } let focus = self.selection.focus; // Get the text direction of the span at the current cursor position let text_span_text_direction = if focus.paragraph < paragraphs.len() { text_helpers::get_text_span_text_direction_at_offset( ¶graphs[focus.paragraph], focus.offset, ) } else { TextDirection::LTR }; // In vertical-rl, Up/Down move by character along the column and // Left/Right cross columns (columns advance right-to-left). let is_vertical = text_content.is_vertical(); let direction = if is_vertical { match direction { CursorDirection::Backward => CursorDirection::LineAfter, // Left -> next column CursorDirection::Forward => CursorDirection::LineBefore, // Right -> prev column CursorDirection::LineBefore => CursorDirection::Backward, // Up -> prev char CursorDirection::LineAfter => CursorDirection::Forward, // Down -> next char other => other, } } else { direction }; // For horizontal navigation, swap Backward/Forward when in RTL text let adjusted_direction = if !is_vertical && text_span_text_direction == TextDirection::RTL { match direction { CursorDirection::Backward => CursorDirection::Forward, CursorDirection::Forward => CursorDirection::Backward, other => other, } } else { direction }; let new_cursor = match adjusted_direction { CursorDirection::Backward => { text_helpers::move_cursor_backward(&focus, paragraphs, word_boundary) } CursorDirection::Forward => { text_helpers::move_cursor_forward(&focus, paragraphs, word_boundary) } CursorDirection::LineBefore => { text_helpers::move_cursor_up(&focus, paragraphs, text_content) } CursorDirection::LineAfter => { text_helpers::move_cursor_down(&focus, paragraphs, text_content) } CursorDirection::LineStart if word_boundary => TextPositionWithAffinity::empty(), CursorDirection::LineEnd if word_boundary => text_helpers::move_cursor_line_end( &TextPositionWithAffinity::new_downstream_affinity(paragraphs.len() - 1, 0), paragraphs, ), CursorDirection::LineStart => text_helpers::move_cursor_line_start(&focus, paragraphs), CursorDirection::LineEnd => text_helpers::move_cursor_line_end(&focus, paragraphs), }; if extend_selection { self.selection.extend_to(new_cursor); } else { self.selection.set_caret(new_cursor); } self.update_styles(text_content); self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); true } } impl Default for TextEditorState { fn default() -> Self { Self::new() } } fn is_word_char(c: char) -> bool { c.is_alphanumeric() || c == '_' } #[cfg(test)] mod tests { use super::*; use crate::math::Rect; use crate::shapes::{ Fill, FontFamily, FontStyle, GrowType, Paragraph, SolidColor, TextAlign, TextSpan, }; use skia_safe::Color; fn span(text: &str, font_size: f32, fill: Fill) -> TextSpan { TextSpan::new( text.to_string(), FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), font_size, 1.2, 0.0, None, None, TextDirection::LTR, 400, Uuid::nil(), vec![fill], ) } fn hello_world_content() -> TextContent { let black = Fill::Solid(SolidColor(Color::BLACK)); let red = Fill::Solid(SolidColor(Color::RED)); let mut content = TextContent::new(Rect::from_xywh(0.0, 0.0, 200.0, 100.0), GrowType::Fixed); content.add_paragraph(Paragraph::new( TextAlign::Left, TextDirection::LTR, None, None, 1.2, 0.0, vec![span("Hello ", 24.0, black), span("world", 40.0, red)], )); content } fn content(text: &str) -> TextContent { let mut content = TextContent::new(Rect::from_xywh(0.0, 0.0, 200.0, 100.0), GrowType::Fixed); content.add_paragraph(Paragraph::new( TextAlign::Left, TextDirection::LTR, None, None, 1.2, 0.0, vec![span(text, 14.0, Fill::Solid(SolidColor(Color::BLACK)))], )); content } fn drain_events(editor: &mut TextEditorState) -> Vec { let mut events = Vec::new(); loop { let ev = editor.poll_event(); if ev == TextEditorEvent::None { break; } events.push(ev); } events } fn pos(offset: usize) -> TextPositionWithAffinity { TextPositionWithAffinity::new_downstream_affinity(0, offset) } #[test] fn set_caret_from_position_alone_does_not_emit_styles_changed() { let content = hello_world_content(); let mut editor = TextEditorState::new(); editor.place_caret_from_position( &content, &TextPositionWithAffinity::new_downstream_affinity(0, 1), ); drain_events(&mut editor); editor.set_caret_from_position(&TextPositionWithAffinity::new_downstream_affinity(0, 8)); let events = drain_events(&mut editor); assert!(!events.contains(&TextEditorEvent::StylesChanged)); assert_eq!(editor.current_styles.font_size.value(), &Some(24.0)); } #[test] fn place_caret_from_position_syncs_styles_and_emits_styles_changed() { let content = hello_world_content(); let mut editor = TextEditorState::new(); editor.place_caret_from_position( &content, &TextPositionWithAffinity::new_downstream_affinity(0, 1), ); drain_events(&mut editor); assert_eq!(editor.current_styles.font_size.value(), &Some(24.0)); // Offset 8 is inside the second span ("world"). editor.place_caret_from_position( &content, &TextPositionWithAffinity::new_downstream_affinity(0, 8), ); let events = drain_events(&mut editor); assert!(events.contains(&TextEditorEvent::StylesChanged)); assert_eq!(editor.current_styles.font_size.value(), &Some(40.0)); assert_eq!( editor.current_styles.fills, vec![Fill::Solid(SolidColor(Color::RED))] ); } #[test] fn place_caret_from_position_clears_mixed_selection_styles() { let content = hello_world_content(); let mut editor = TextEditorState::new(); editor.select_all(&content); assert!(editor.current_styles.font_size.is_multiple()); assert!(editor.current_styles.fills_are_multiple); drain_events(&mut editor); editor.place_caret_from_position( &content, &TextPositionWithAffinity::new_downstream_affinity(0, 8), ); let events = drain_events(&mut editor); assert!(events.contains(&TextEditorEvent::StylesChanged)); assert_eq!(editor.current_styles.font_size.value(), &Some(40.0)); assert!(!editor.current_styles.fills_are_multiple); assert_eq!( editor.current_styles.fills, vec![Fill::Solid(SolidColor(Color::RED))] ); } #[test] fn move_backward_with_selection_collapses_to_start() { // "Third paragraph" — select "aph" at the end, then Left. let content = content("Third paragraph"); let mut editor = TextEditorState::new(); editor.selection.set_caret(pos(12)); editor.selection.extend_to(pos(15)); editor.move_cursor(&content, CursorDirection::Backward, false, false); assert!(editor.selection.is_collapsed()); assert_eq!(editor.selection.focus.offset, 12); } #[test] fn move_forward_with_selection_collapses_to_end() { // Caret between "parag" and "raph", Shift+Left twice selects "ag", then Right. let content = content("Third paragraph"); let mut editor = TextEditorState::new(); editor.selection.set_caret(pos(11)); editor.selection.extend_to(pos(9)); editor.move_cursor(&content, CursorDirection::Forward, false, false); assert!(editor.selection.is_collapsed()); assert_eq!(editor.selection.focus.offset, 11); } #[test] fn move_backward_with_full_selection_collapses_to_document_start() { let content = content("Hello"); let mut editor = TextEditorState::new(); editor.select_all(&content); editor.move_cursor(&content, CursorDirection::Backward, false, false); assert!(editor.selection.is_collapsed()); assert_eq!(editor.selection.focus.offset, 0); } #[test] fn move_forward_with_full_selection_collapses_to_document_end() { let content = content("Hello"); let mut editor = TextEditorState::new(); editor.select_all(&content); editor.move_cursor(&content, CursorDirection::Forward, false, false); assert!(editor.selection.is_collapsed()); assert_eq!(editor.selection.focus.offset, 5); } #[test] fn move_backward_while_extending_still_moves_from_focus() { let content = content("Third paragraph"); let mut editor = TextEditorState::new(); editor.selection.set_caret(pos(15)); editor.selection.extend_to(pos(12)); editor.move_cursor(&content, CursorDirection::Backward, false, true); assert!(editor.selection.is_selection()); assert_eq!(editor.selection.anchor.offset, 15); assert_eq!(editor.selection.focus.offset, 11); } #[test] fn move_backward_with_collapsed_caret_still_moves() { let content = content("Hello"); let mut editor = TextEditorState::new(); editor.selection.set_caret(pos(3)); editor.move_cursor(&content, CursorDirection::Backward, false, false); assert!(editor.selection.is_collapsed()); assert_eq!(editor.selection.focus.offset, 2); } fn caret(paragraph: usize, offset: usize) -> TextSelection { TextSelection::from_position_with_affinity( TextPositionWithAffinity::new_downstream_affinity(paragraph, offset), ) } #[test] fn composition_replaces_the_previous_preview_wherever_the_caret_is() { let mut composition = TextComposition::new(); composition.start(); // First update: the pre-composition caret is the replaced range. composition.update("に"); let replaced = composition.get_selection(&caret(0, 2)); assert_eq!((replaced.start().offset, replaced.end().offset), (2, 2)); composition.start = Some(replaced.start()); // The caret sits after the preview; the next update still replaces // the preview itself. composition.update("にほ"); let replaced = composition.get_selection(&caret(0, 3)); assert_eq!((replaced.start().offset, replaced.end().offset), (2, 3)); composition.end(); assert!(composition.start.is_none()); } }