From 18d3e16711a644e085fde476585cc8581f32b53f Mon Sep 17 00:00:00 2001 From: Marc Espin Date: Sat, 1 Jun 2024 15:54:24 +0200 Subject: [PATCH] feat: Improved special text editing support (#622) * feat: Improved text editing support * some improvements * Clean up and simplify * cleanup and fixes * cleanup * use chars len instead of bytes len in EditorHistory * fix: Move cursor accordingly * chore: Clean up * chore: Clean up * chore: Add test * chore: Update tests * chore: Update tests * chore: Update tests * chore: Update tests * chore: Update tests * chore: Update tests * chore: Update tests * chore: Update tests * chore: Update tests * chore: Update tests * chore: Update tests * chore: Update tests * chore: Update tests * chore: Update tests * chore: Update tests * chore: Clean up * chore: Clean up * chore: Clean up * chore: Clean up --- crates/components/src/input.rs | 2 +- crates/engine/src/skia.rs | 1 + crates/hooks/src/editor_history.rs | 10 +- crates/hooks/src/rope_editor.rs | 41 +++--- crates/hooks/src/text_editor.rs | 40 ++++-- crates/hooks/src/use_editable.rs | 30 ++++- crates/hooks/tests/use_editable.rs | 200 ++++++++++++++++++++++++++++- crates/testing/src/launch.rs | 4 +- examples/cloned_editor.rs | 4 +- examples/simple_editor.rs | 9 +- 10 files changed, 293 insertions(+), 48 deletions(-) diff --git a/crates/components/src/input.rs b/crates/components/src/input.rs index 66f7b10fd..b47916486 100644 --- a/crates/components/src/input.rs +++ b/crates/components/src/input.rs @@ -152,7 +152,7 @@ pub fn Input( let (background, cursor_char) = if focus.is_focused() { ( theme.hover_background, - editable.editor().read().cursor_pos().to_string(), + editable.editor().read().visible_cursor_pos().to_string(), ) } else { (theme.background, "none".to_string()) diff --git a/crates/engine/src/skia.rs b/crates/engine/src/skia.rs index 4653d7bb9..f7b3c92de 100644 --- a/crates/engine/src/skia.rs +++ b/crates/engine/src/skia.rs @@ -13,6 +13,7 @@ pub use skia_safe::{ path::ArcSize, rrect::Corner, runtime_effect::Uniform, + surfaces::raster_n32_premul, svg, textlayout::{ paragraph::GlyphClusterInfo, Decoration, FontCollection, FontFeature, LineMetrics, diff --git a/crates/hooks/src/editor_history.rs b/crates/hooks/src/editor_history.rs index 37277f1c5..3b07c1d33 100644 --- a/crates/hooks/src/editor_history.rs +++ b/crates/hooks/src/editor_history.rs @@ -57,10 +57,10 @@ impl EditorHistory { let idx_end = match last_change { HistoryChange::Remove { idx, text } => { rope.insert(*idx, text); - idx + text.len() + idx + text.chars().count() } - HistoryChange::InsertChar { idx, .. } => { - rope.remove(*idx..*idx + 1); + HistoryChange::InsertChar { idx, char: ch } => { + rope.remove(*idx..*idx + ch.len_utf8()); *idx } HistoryChange::InsertText { idx, text } => { @@ -85,7 +85,7 @@ impl EditorHistory { if let Some(next_change) = next_change { let idx_end = match next_change { HistoryChange::Remove { idx, text } => { - rope.remove(*idx..idx + text.len()); + rope.remove(*idx..idx + text.chars().count()); *idx } HistoryChange::InsertChar { idx, char: ch } => { @@ -94,7 +94,7 @@ impl EditorHistory { } HistoryChange::InsertText { idx, text, .. } => { rope.insert(*idx, text); - idx + text.len() + idx + text.chars().count() } }; self.current_change += 1; diff --git a/crates/hooks/src/rope_editor.rs b/crates/hooks/src/rope_editor.rs index 52d2694ff..03ce69ec5 100644 --- a/crates/hooks/src/rope_editor.rs +++ b/crates/hooks/src/rope_editor.rs @@ -85,6 +85,14 @@ impl TextEditor for RopeEditor { self.rope.line_to_char(line_idx) } + fn utf16_cu_to_char(&self, utf16_cu_idx: usize) -> usize { + self.rope.utf16_cu_to_char(utf16_cu_idx) + } + + fn char_to_utf16_cu(&self, idx: usize) -> usize { + self.rope.char_to_utf16_cu(idx) + } + fn line(&self, line_idx: usize) -> Option> { let line = self.rope.get_line(line_idx); @@ -145,16 +153,18 @@ impl TextEditor for RopeEditor { return Some((0, len)); } - match selected_from_row.cmp(&selected_to_row) { + let highlights = match selected_from_row.cmp(&selected_to_row) { // Selection direction is from bottom -> top Ordering::Greater => { if selected_from_row == editor_id { // Starting line - return Some((0, selected_from_col_idx)); + Some((0, selected_from_col_idx)) } else if selected_to_row == editor_id { // Ending line let len = self.line(selected_to_row).unwrap().len_chars(); - return Some((selected_to_col_idx, len)); + Some((selected_to_col_idx, len)) + } else { + None } } // Selection direction is from top -> bottom @@ -162,26 +172,27 @@ impl TextEditor for RopeEditor { if selected_from_row == editor_id { // Starting line let len = self.line(selected_from_row).unwrap().len_chars(); - return Some((selected_from_col_idx, len)); + Some((selected_from_col_idx, len)) } else if selected_to_row == editor_id { // Ending line - return Some((0, selected_to_col_idx)); + Some((0, selected_to_col_idx)) + } else { + None } } - Ordering::Equal => { + Ordering::Equal if selected_from_row == editor_id => { // Starting and endline line are the same - if selected_from_row == editor_id { - return Some(( - selected_from - editor_row_idx, - selected_to - editor_row_idx, - )); - } + Some((selected_from - editor_row_idx, selected_to - editor_row_idx)) } - } + _ => None, + }; - None + highlights.map(|(from, to)| (self.char_to_utf16_cu(from), self.char_to_utf16_cu(to))) } else { - Some((selected_from, selected_to)) + Some(( + self.char_to_utf16_cu(selected_from), + self.char_to_utf16_cu(selected_to), + )) } } diff --git a/crates/hooks/src/text_editor.rs b/crates/hooks/src/text_editor.rs index fd6dc7fd0..72349b416 100644 --- a/crates/hooks/src/text_editor.rs +++ b/crates/hooks/src/text_editor.rs @@ -59,6 +59,11 @@ impl Line<'_> { self.text.chars().filter(|c| c != &'\r').count() } + /// Get the length of the line + pub fn utf16_len_chars(&self) -> usize { + self.text.encode_utf16().count() + } + /// Get the text of the line fn as_str(&self) -> &str { &self.text @@ -110,6 +115,10 @@ pub trait TextEditor { /// Get the first char from the given line fn line_to_char(&self, line_idx: usize) -> usize; + fn utf16_cu_to_char(&self, utf16_cu_idx: usize) -> usize; + + fn char_to_utf16_cu(&self, idx: usize) -> usize; + /// Get a line from the text fn line(&self, line_idx: usize) -> Option>; @@ -132,6 +141,11 @@ pub trait TextEditor { self.cursor().col() } + /// Get the visible cursor position + fn visible_cursor_col(&self) -> usize { + self.char_to_utf16_cu(self.cursor_col()) + } + /// Move the cursor 1 line down fn cursor_down(&mut self) { let new_row = self.cursor_row() + 1; @@ -162,6 +176,12 @@ pub trait TextEditor { line_begining + self.cursor_col() } + /// Get the cursor position + fn visible_cursor_pos(&self) -> usize { + let line_begining = self.char_to_utf16_cu(self.line_to_char(self.cursor_row())); + line_begining + self.char_to_utf16_cu(self.cursor_col()) + } + /// Set the cursor position fn set_cursor_pos(&mut self, pos: usize) { let row = self.char_to_line(pos); @@ -450,21 +470,17 @@ pub trait TextEditor { _ => { if let Ok(ch) = character.parse::() { - // https://github.com/marc2332/freya/issues/461 - if !ch.is_ascii_control() && ch.len_utf8() <= 2 { - // Inserts a character - let char_idx = - self.line_to_char(self.cursor_row()) + self.cursor_col(); - self.insert(character, char_idx); - self.cursor_right(); - - event.insert(TextEvent::TEXT_CHANGED); - } - } else if character.is_ascii() { + // Inserts a character + let char_idx = self.line_to_char(self.cursor_row()) + self.cursor_col(); + self.insert_char(ch, char_idx); + self.cursor_right(); + + event.insert(TextEvent::TEXT_CHANGED); + } else { // Inserts a text let char_idx = self.line_to_char(self.cursor_row()) + self.cursor_col(); self.insert(character, char_idx); - self.set_cursor_pos(char_idx + character.len()); + self.set_cursor_pos(char_idx + character.chars().count()); event.insert(TextEvent::TEXT_CHANGED); } diff --git a/crates/hooks/src/use_editable.rs b/crates/hooks/src/use_editable.rs index a2e0fd0c5..e8d506530 100644 --- a/crates/hooks/src/use_editable.rs +++ b/crates/hooks/src/use_editable.rs @@ -191,23 +191,34 @@ pub fn use_editable(initializer: impl Fn() -> EditableConfig, mode: EditableMode let new_cursor_row = match mode { EditableMode::MultipleLinesSingleEditor => { - text_editor.char_to_line(position) + text_editor.char_to_line(text_editor.utf16_cu_to_char(position)) } EditableMode::SingleLineMultipleEditors => id, }; let new_cursor_col = match mode { - EditableMode::MultipleLinesSingleEditor => { - position - text_editor.line_to_char(new_cursor_row) + EditableMode::MultipleLinesSingleEditor => text_editor + .utf16_cu_to_char( + position + - text_editor.char_to_utf16_cu( + text_editor.line_to_char(new_cursor_row), + ), + ), + EditableMode::SingleLineMultipleEditors => { + text_editor.utf16_cu_to_char(position) } - EditableMode::SingleLineMultipleEditors => position, }; let new_current_line = text_editor.line(new_cursor_row).unwrap(); // Use the line length as new column if the clicked column surpases the length - let new_cursor = if new_cursor_col >= new_current_line.len_chars() { - (new_current_line.len_chars(), new_cursor_row) + let new_cursor = if new_cursor_col >= new_current_line.utf16_len_chars() + { + ( + text_editor + .utf16_cu_to_char(new_current_line.utf16_len_chars()), + new_cursor_row, + ) } else { (new_cursor_col, new_cursor_row) }; @@ -224,7 +235,12 @@ pub fn use_editable(initializer: impl Fn() -> EditableConfig, mode: EditableMode } // Update the text selections calculated by the layout CursorLayoutResponse::TextSelection { from, to, id } => { - editor.write().highlight_text(from, to, id); + let mut text_editor = editor.write(); + let (from, to) = ( + text_editor.utf16_cu_to_char(from), + text_editor.utf16_cu_to_char(to), + ); + text_editor.highlight_text(from, to, id); cursor_reference.set_cursor_selections(None); } } diff --git a/crates/hooks/tests/use_editable.rs b/crates/hooks/tests/use_editable.rs index dd9119937..e356c1540 100644 --- a/crates/hooks/tests/use_editable.rs +++ b/crates/hooks/tests/use_editable.rs @@ -12,7 +12,7 @@ pub async fn multiple_lines_single_editor() { let cursor_attr = editable.cursor_attr(); let editor = editable.editor().read(); let cursor = editor.cursor(); - let cursor_pos = editor.cursor_pos(); + let cursor_pos = editor.visible_cursor_pos(); let onmousedown = move |e: MouseEvent| { editable.process_event(&EditableEvent::MouseDown(e.data, 0)); @@ -317,7 +317,7 @@ pub async fn highlight_multiple_lines_single_editor() { ); let editor = editable.editor().read(); let cursor = editor.cursor(); - let cursor_pos = editor.cursor_pos(); + let cursor_pos = editor.visible_cursor_pos(); let cursor_reference = editable.cursor_attr(); let highlights = editable.highlights_attr(0); @@ -432,7 +432,7 @@ pub async fn highlights_single_line_mulitple_editors() { // Only show the cursor in the active line let character_index = if is_line_selected { - editable.editor().read().cursor_col().to_string() + editable.editor().read().visible_cursor_col().to_string() } else { "none".to_string() }; @@ -534,3 +534,197 @@ pub async fn highlights_single_line_mulitple_editors() { assert_eq!(highlights_2, Some(vec![(start, end)])); } + +#[tokio::test] +pub async fn special_text_editing() { + fn special_text_editing_app() -> Element { + let mut editable = use_editable( + || EditableConfig::new("δ½ ε₯½δΈ–η•Œ\nπŸ‘‹".to_string()), + EditableMode::MultipleLinesSingleEditor, + ); + let cursor_attr = editable.cursor_attr(); + let editor = editable.editor().read(); + let cursor = editor.cursor(); + let cursor_pos = editor.visible_cursor_pos(); + + let onmousedown = move |e: MouseEvent| { + editable.process_event(&EditableEvent::MouseDown(e.data, 0)); + }; + + let onkeydown = move |e: Event| { + editable.process_event(&EditableEvent::KeyDown(e.data)); + }; + + rsx!( + rect { + width: "100%", + height: "100%", + background: "white", + cursor_reference: cursor_attr, + onmousedown, + paragraph { + height: "50%", + width: "100%", + cursor_id: "0", + cursor_index: "{cursor_pos}", + cursor_color: "black", + cursor_mode: "editable", + onkeydown, + text { + color: "black", + "{editor}" + } + } + label { + color: "black", + height: "50%", + "{cursor.row()}:{cursor.col()}" + } + } + ) + } + + let mut utils = launch_test(special_text_editing_app); + + // Initial state + let root = utils.root().get(0); + let cursor = root.get(1).get(0); + let content = root.get(0).get(0).get(0); + assert_eq!(cursor.text(), Some("0:0")); + assert_eq!(content.text(), Some("δ½ ε₯½δΈ–η•Œ\nπŸ‘‹")); + + // Move cursor + utils.push_event(PlatformEvent::Mouse { + name: EventName::MouseDown, + cursor: (35.0, 3.0).into(), + button: Some(MouseButton::Left), + }); + + utils.wait_for_update().await; + utils.wait_for_update().await; + + // Cursor has been moved + let root = utils.root().get(0); + let cursor = root.get(1).get(0); + #[cfg(not(target_os = "linux"))] + assert_eq!(cursor.text(), Some("0:2")); + + #[cfg(target_os = "linux")] + assert_eq!(cursor.text(), Some("0:4")); + + // Insert text + utils.push_event(PlatformEvent::Keyboard { + name: EventName::KeyDown, + key: Key::Character("πŸ¦€".to_string()), + code: Code::Unidentified, + modifiers: Modifiers::empty(), + }); + + utils.wait_for_update().await; + + // Text and cursor have changed + let cursor = root.get(1).get(0); + let content = root.get(0).get(0).get(0); + #[cfg(not(target_os = "linux"))] + { + assert_eq!(content.text(), Some("δ½ ε₯½πŸ¦€δΈ–η•Œ\nπŸ‘‹")); + assert_eq!(cursor.text(), Some("0:3")); + } + + #[cfg(target_os = "linux")] + { + assert_eq!(content.text(), Some("δ½ ε₯½δΈ–η•ŒπŸ¦€\nπŸ‘‹")); + assert_eq!(cursor.text(), Some("0:5")); + } + + // Move cursor to the begining + utils.push_event(PlatformEvent::Mouse { + name: EventName::MouseDown, + cursor: (3.0, 3.0).into(), + button: Some(MouseButton::Left), + }); + utils.wait_for_update().await; + utils.wait_for_update().await; + let cursor = root.get(1).get(0); + assert_eq!(cursor.text(), Some("0:0")); + + // Move cursor with arrow down + utils.push_event(PlatformEvent::Keyboard { + name: EventName::KeyDown, + code: Code::ArrowDown, + key: Key::ArrowDown, + modifiers: Modifiers::default(), + }); + utils.wait_for_update().await; + let cursor = root.get(1).get(0); + assert_eq!(cursor.text(), Some("1:0")); + + // Move cursor with arrow right + utils.push_event(PlatformEvent::Keyboard { + name: EventName::KeyDown, + code: Code::ArrowRight, + key: Key::ArrowRight, + modifiers: Modifiers::default(), + }); + utils.wait_for_update().await; + let cursor = root.get(1).get(0); + assert_eq!(cursor.text(), Some("1:1")); + + // Move cursor with arrow up + utils.push_event(PlatformEvent::Keyboard { + name: EventName::KeyDown, + code: Code::ArrowUp, + key: Key::ArrowUp, + modifiers: Modifiers::default(), + }); + utils.wait_for_update().await; + let cursor = root.get(1).get(0); + assert_eq!(cursor.text(), Some("0:1")); + + // Move cursor with arrow left + utils.push_event(PlatformEvent::Keyboard { + name: EventName::KeyDown, + code: Code::ArrowLeft, + key: Key::ArrowLeft, + modifiers: Modifiers::default(), + }); + utils.wait_for_update().await; + let cursor = root.get(1).get(0); + assert_eq!(cursor.text(), Some("0:0")); + + // Move cursor with arrow down, twice + utils.push_event(PlatformEvent::Keyboard { + name: EventName::KeyDown, + code: Code::ArrowDown, + key: Key::ArrowDown, + modifiers: Modifiers::default(), + }); + utils.push_event(PlatformEvent::Keyboard { + name: EventName::KeyDown, + code: Code::ArrowDown, + key: Key::ArrowDown, + modifiers: Modifiers::default(), + }); + utils.wait_for_update().await; + let cursor = root.get(1).get(0); + // Because there is not a third line, the cursor will be moved to the max right + assert_eq!(cursor.text(), Some("1:1")); + + // Move cursor with arrow up, twice + utils.push_event(PlatformEvent::Keyboard { + name: EventName::KeyDown, + code: Code::ArrowUp, + key: Key::ArrowUp, + modifiers: Modifiers::default(), + }); + utils.push_event(PlatformEvent::Keyboard { + name: EventName::KeyDown, + code: Code::ArrowUp, + key: Key::ArrowUp, + modifiers: Modifiers::default(), + }); + utils.wait_for_update().await; + let cursor = root.get(1).get(0); + // Because there is not a line above the first one, the cursor will be moved to the begining + assert_eq!(cursor.text(), Some("0:0")); +} diff --git a/crates/testing/src/launch.rs b/crates/testing/src/launch.rs index 501262255..1c1a5b126 100644 --- a/crates/testing/src/launch.rs +++ b/crates/testing/src/launch.rs @@ -33,7 +33,9 @@ pub fn launch_test_with_config(root: AppComponent, config: TestingConfig) -> Tes let (platform_event_emitter, platform_event_receiver) = unbounded_channel::(); let (focus_sender, focus_receiver) = watch::channel(ACCESSIBILITY_ROOT_ID); let mut font_collection = FontCollection::new(); - font_collection.set_dynamic_font_manager(FontMgr::default()); + let font_mgr = FontMgr::default(); + font_collection.set_dynamic_font_manager(font_mgr.clone()); + font_collection.set_default_font_manager(font_mgr, "Fira Sans"); let mut handler = TestingHandler { vdom, diff --git a/examples/cloned_editor.rs b/examples/cloned_editor.rs index 270ed64ef..c2734d9a6 100644 --- a/examples/cloned_editor.rs +++ b/examples/cloned_editor.rs @@ -67,7 +67,7 @@ fn Body() -> Element { // Only show the cursor in the active line let character_index = if is_line_selected { - editor.cursor_col().to_string() + editor.visible_cursor_col().to_string() } else { "none".to_string() }; @@ -144,7 +144,7 @@ fn Body() -> Element { // Only show the cursor in the active line let character_index = if is_line_selected { - editor.cursor_col().to_string() + editor.visible_cursor_col().to_string() } else { "none".to_string() }; diff --git a/examples/simple_editor.rs b/examples/simple_editor.rs index 3b0693478..85f435629 100644 --- a/examples/simple_editor.rs +++ b/examples/simple_editor.rs @@ -12,7 +12,12 @@ fn main() { fn app() -> Element { let mut editable = use_editable( || { - EditableConfig::new("Hello Rustaceans Abcdefg12345 Hello Rustaceans Abcdefg12345 Hello Rustaceans Abcdefg12345\n".repeat(25).trim().to_string()) + EditableConfig::new( + "δ½ ε₯½δΈ–η•Œ πŸ‘‹| Hello World! πŸ™β€β™‚οΈ| Hola Mundo! πŸš€| Hola MΓ³n! πŸ¦€\n" + .repeat(15) + .trim() + .to_string(), + ) }, EditableMode::MultipleLinesSingleEditor, ); @@ -21,7 +26,7 @@ fn app() -> Element { let highlights = editable.highlights_attr(0); let editor = editable.editor().read(); let cursor = editor.cursor(); - let cursor_char = editor.cursor_pos(); + let cursor_char = editor.visible_cursor_pos(); let onmousedown = move |e: MouseEvent| { editable.process_event(&EditableEvent::MouseDown(e.data, 0));