diff --git a/examples/editor/src/main.rs b/examples/editor/src/main.rs index 0599e80a7..0b296cf7b 100644 --- a/examples/editor/src/main.rs +++ b/examples/editor/src/main.rs @@ -1,10 +1,7 @@ use floem::{ prelude::*, views::editor::{ - command::{Command, CommandExecuted}, - core::{ - command::EditCommand, cursor::CursorAffinity, editor::EditType, selection::Selection, - }, + core::{cursor::CursorAffinity, editor::EditType, selection::Selection}, text::{default_dark_color, SimpleStyling}, }, }; @@ -23,36 +20,37 @@ fn app_view() -> impl IntoView { .style(|s| s.size_full()) .editor_style(default_dark_color) .editor_style(move |s| s.hide_gutter(hide_gutter_a.get())); + let focus_editor_a = editor_a.editor().clone(); let editor_b = editor_a .shared_editor() .editor_style(default_dark_color) .editor_style(move |s| s.hide_gutter(hide_gutter_b.get())) .style(|s| s.size_full()) - .pre_command(|ev| { - if matches!(ev.cmd, Command::Edit(EditCommand::Undo)) { - println!("Undo command executed on editor B, ignoring!"); - return CommandExecuted::Yes; - } - CommandExecuted::No - }) .update(|_| { // This hooks up to both editors! println!("Editor changed"); }) .placeholder("Some placeholder text"); let doc = editor_a.doc(); + let clear_editor_a = editor_a.editor().clone(); Stack::new(( editor_a, editor_b, Stack::new(( Button::new("Clear").action(move || { - doc.edit_single( + doc.edit_single_from( + &clear_editor_a, Selection::region(0, doc.text().len(), CursorAffinity::Backward), "", EditType::DeleteSelection, ); }), + Button::new("Focus A").action(move || { + if let Some(id) = focus_editor_a.editor_view_id.get_untracked() { + id.request_focus(); + } + }), Button::new("Flip Gutter").action(move || { hide_gutter_a.update(|hide| *hide = !*hide); hide_gutter_b.update(|hide| *hide = !*hide); diff --git a/examples/syntax-editor/src/main.rs b/examples/syntax-editor/src/main.rs index fd4bc5ffb..d6198d1a7 100644 --- a/examples/syntax-editor/src/main.rs +++ b/examples/syntax-editor/src/main.rs @@ -215,12 +215,14 @@ mod tests { .style(|s| s.size_full()); let doc = editor.doc(); + let clear_editor = editor.editor().clone(); Stack::new(( editor, Stack::new(( Button::new("Clear").action(move || { - doc.edit_single( + doc.edit_single_from( + &clear_editor, Selection::region(0, doc.text().len(), CursorAffinity::Backward), "", EditType::DeleteSelection, diff --git a/examples/widget-gallery/src/texteditor.rs b/examples/widget-gallery/src/texteditor.rs index b5e220ce4..3931b944a 100644 --- a/examples/widget-gallery/src/texteditor.rs +++ b/examples/widget-gallery/src/texteditor.rs @@ -2,10 +2,7 @@ use floem::{ action::inspect, prelude::*, views::editor::{ - command::{Command, CommandExecuted}, - core::{ - command::EditCommand, cursor::CursorAffinity, editor::EditType, selection::Selection, - }, + core::{cursor::CursorAffinity, editor::EditType, selection::Selection}, text::{SimpleStyling, default_dark_color}, }, }; @@ -28,26 +25,21 @@ pub fn editor_view() -> impl IntoView { .editor_style(default_dark_color) .editor_style(move |s| s.hide_gutter(!hide_gutter_a.get())) .style(|s| s.size_full()) - .pre_command(|ev| { - if matches!(ev.cmd, Command::Edit(EditCommand::Undo)) { - println!("Undo command executed on editor B, ignoring!"); - return CommandExecuted::Yes; - } - CommandExecuted::No - }) .update(|_| { // This hooks up to both editors! println!("Editor changed"); }) .placeholder("Some placeholder text"); let doc = editor_a.doc(); + let clear_editor_a = editor_a.editor().clone(); Stack::new(( editor_a, editor_b, Stack::new(( Button::new("Clear").action(move || { - doc.edit_single( + doc.edit_single_from( + &clear_editor_a, Selection::region(0, doc.text().len(), CursorAffinity::Backward), "", EditType::DeleteSelection, diff --git a/src/views/editor/mod.rs b/src/views/editor/mod.rs index 9786750df..d1bcdb41e 100644 --- a/src/views/editor/mod.rs +++ b/src/views/editor/mod.rs @@ -58,6 +58,7 @@ use self::{ layout::TextLayoutLine, phantom_text::PhantomTextLine, text::{Document, Preedit, PreeditData, RenderWhitespace, Styling, WrapMethod}, + text_document::TextDocument, view::{LineInfo, ScreenLines, ScreenLinesBase}, visual_line::{ ConfigId, FontSizeCacheId, LayoutEvent, LineFontSizeProvider, Lines, RVLine, ResolvedWrap, @@ -309,6 +310,7 @@ impl Editor { floem_style_id: cx.create_rw_signal(0), }; + ed.register_doc_cursor_sync(); create_view_effects(ed.effects_cx.get(), &ed); ed @@ -371,6 +373,7 @@ impl Editor { }); self.lines.clear(0, None); self.doc.set(doc); + self.register_doc_cursor_sync(); if let Some(styling) = styling { self.style.set(styling); } @@ -384,6 +387,51 @@ impl Editor { }); } + /// Registers cursor synchronization for `TextDocument` updates. + /// + /// Each editor owns its own cursor state, so shared or programmatic document edits must remap + /// that cursor through incoming deltas to keep it valid for the current buffer revision. + fn register_doc_cursor_sync(&self) { + let Some(doc) = self.try_text_doc_untracked() else { + return; + }; + + let editor_id = self.id; + let doc_signal = self.doc; + let cursor = self.cursor; + let synced_doc = doc.clone(); + doc.add_on_update(move |update| { + if update.editor.is_some_and(|editor| editor.id() == editor_id) { + return; + } + + let Some(current_doc) = doc_signal + .try_get_untracked() + .and_then(downcast_text_document) + else { + return; + }; + + // `add_on_update` is append-only, so old listeners can remain after `update_doc`. + // Ignore updates from documents this editor no longer points at. + if !Rc::ptr_eq(¤t_doc, &synced_doc) { + return; + } + + cursor.try_update(|cursor| { + for delta in update.deltas() { + cursor.apply_delta(delta); + } + }); + }); + } + + fn try_text_doc_untracked(&self) -> Option> { + self.doc + .try_get_untracked() + .and_then(downcast_text_document) + } + pub fn update_styling(&self, styling: Rc) { Effect::batch(|| { // Get rid of all the effects @@ -1746,3 +1794,146 @@ impl CursorInfo { self.blink(); } } + +fn downcast_text_document(doc: Rc) -> Option> { + (doc as Rc).downcast().ok() +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use floem_editor_core::{ + command::{EditCommand, MultiSelectionCommand}, + cursor::CursorAffinity, + editor::EditType, + selection::Selection, + }; + use floem_reactive::{Scope, SignalGet, SignalUpdate}; + + use crate::{ + headless::TestRoot, + views::editor::{ + command::Command, + text::{Document, SimpleStyling}, + text_document::TextDocument, + }, + }; + + use super::Editor; + + fn make_shared_editors(text: &str) -> (Rc, Editor, Editor) { + let _root = TestRoot::new(); + let cx = Scope::new(); + let doc = Rc::new(TextDocument::new(cx, text)); + let style = Rc::new(SimpleStyling::new()); + let primary = Editor::new(cx, doc.clone(), style.clone(), false); + let secondary = Editor::new(cx, doc.clone(), style, false); + (doc, primary, secondary) + } + + #[test] + fn shared_editor_cursor_tracks_full_delete() { + let (doc, primary, secondary) = make_shared_editors("Hello world"); + + primary.cursor.update(|cursor| { + cursor.set_offset(11, CursorAffinity::Backward, false, false); + }); + + doc.run_command( + &secondary, + &Command::MultiSelection(MultiSelectionCommand::SelectAll), + None, + Default::default(), + ); + doc.run_command( + &secondary, + &Command::Edit(EditCommand::DeleteForward), + None, + Default::default(), + ); + + assert_eq!(primary.cursor.get_untracked().offset(), 0); + assert_eq!(secondary.cursor.get_untracked().offset(), 0); + + primary.receive_char("x"); + assert_eq!(doc.text().to_string(), "x"); + } + + #[test] + fn shared_editor_cursor_tracks_multibyte_insert_without_double_transforming_origin() { + let (doc, primary, secondary) = make_shared_editors("a"); + + primary.cursor.update(|cursor| { + cursor.set_offset(1, CursorAffinity::Backward, false, false); + }); + secondary.cursor.update(|cursor| { + cursor.set_offset(0, CursorAffinity::Backward, false, false); + }); + + secondary.receive_char("あ"); + + assert_eq!(secondary.cursor.get_untracked().offset(), "あ".len()); + assert_eq!(primary.cursor.get_untracked().offset(), "あa".len()); + + primary.receive_char(" "); + assert_eq!(doc.text().to_string(), "あa "); + } + + #[test] + fn external_edit_updates_existing_editor_cursor() { + let _root = TestRoot::new(); + let cx = Scope::new(); + let doc = Rc::new(TextDocument::new(cx, "Hello world")); + let style = Rc::new(SimpleStyling::new()); + let editor = Editor::new(cx, doc.clone(), style, false); + + editor.cursor.update(|cursor| { + cursor.set_offset(11, CursorAffinity::Backward, false, false); + }); + + doc.edit_single( + Selection::region(0, doc.text().len(), CursorAffinity::Backward), + "", + EditType::Delete, + ); + + assert_eq!(editor.cursor.get_untracked().offset(), 0); + + editor.receive_char("x"); + assert_eq!(doc.text().to_string(), "x"); + } + + #[test] + fn edit_single_from_restores_cursor_on_undo() { + let _root = TestRoot::new(); + let cx = Scope::new(); + let doc = Rc::new(TextDocument::new(cx, "Hello world")); + let style = Rc::new(SimpleStyling::new()); + let editor = Editor::new(cx, doc.clone(), style, false); + + editor.cursor.update(|cursor| { + cursor.set_offset(11, CursorAffinity::Backward, false, false); + }); + + doc.edit_single_from( + &editor, + Selection::region(0, doc.text().len(), CursorAffinity::Backward), + "", + EditType::Delete, + ); + + assert_eq!(doc.text().to_string(), ""); + assert_eq!(editor.cursor.get_untracked().offset(), 0); + + doc.run_command( + &editor, + &Command::Edit(EditCommand::Undo), + None, + Default::default(), + ); + + assert_eq!(doc.text().to_string(), "Hello world"); + assert_eq!(editor.cursor.get_untracked().offset(), 11); + } +} diff --git a/src/views/editor/text.rs b/src/views/editor/text.rs index ea2d772d5..d3a45c2c5 100644 --- a/src/views/editor/text.rs +++ b/src/views/editor/text.rs @@ -188,24 +188,44 @@ pub trait Document: DocumentPhantom + ::std::any::Any { self.edit(&mut iter, edit_type); } - /// Perform the edit(s) on this document. + /// Perform a single edit while preserving editor-specific semantics for the initiating editor. /// - /// This intentionally does not require an `Editor` as this is primarily intended for use by - /// code that wants to modify the document from 'outside' the usual keybinding/command logic. + /// Use this when a UI action edits the document on behalf of a live editor and should record + /// that editor's cursor state for undo. + fn edit_single_from( + &self, + editor: &Editor, + selection: Selection, + content: &str, + edit_type: EditType, + ) { + let mut iter = std::iter::once((selection, content)); + self.edit_from(editor, &mut iter, edit_type); + } + + /// Perform the edit(s) on this document. /// - /// ```rust,ignore - /// let editor: TextEditor = text_editor(); - /// let doc: Rc = editor.doc(); + /// This intentionally does not require an `Editor`. It is the raw document mutation path for + /// code that wants to modify text without an initiating editor context. /// - /// stack(( - /// editor, - /// button(|| "Append 'Hello'").on_click_stop(move |_| { - /// let text = doc.text(); - /// doc.edit_single(Selection::caret(text.len()), "Hello", EditType::InsertChars); - /// }) - /// )) - /// ``` + /// Because it has no initiating editor context, it can't record that editor's cursor history + /// for undo. If a UI action is editing on behalf of a live editor, prefer + /// [`Document::edit_from`] or [`Document::edit_single_from`] instead. fn edit(&self, iter: &mut dyn Iterator, edit_type: EditType); + + /// Perform the edit(s) on this document using the provided editor as the initiating context. + /// + /// The default implementation falls back to [`Document::edit`], which keeps this additive for + /// custom `Document` implementations that do not yet preserve initiating-editor cursor history + /// for undo. + fn edit_from( + &self, + _editor: &Editor, + iter: &mut dyn Iterator, + edit_type: EditType, + ) { + self.edit(iter, edit_type); + } } pub trait DocumentPhantom { @@ -516,9 +536,29 @@ where self.doc.edit_single(selection, content, edit_type) } + fn edit_single_from( + &self, + editor: &Editor, + selection: Selection, + content: &str, + edit_type: EditType, + ) { + self.doc + .edit_single_from(editor, selection, content, edit_type) + } + fn edit(&self, iter: &mut dyn Iterator, edit_type: EditType) { self.doc.edit(iter, edit_type) } + + fn edit_from( + &self, + editor: &Editor, + iter: &mut dyn Iterator, + edit_type: EditType, + ) { + self.doc.edit_from(editor, iter, edit_type) + } } impl DocumentPhantom for ExtCmdDocument where diff --git a/src/views/editor/text_document.rs b/src/views/editor/text_document.rs index 1a7c0e5f0..ff7f55f8c 100644 --- a/src/views/editor/text_document.rs +++ b/src/views/editor/text_document.rs @@ -148,6 +148,46 @@ impl TextDocument { }); } + fn apply_programmatic_edit( + &self, + editor: Option<&Editor>, + iter: &mut dyn Iterator, + edit_type: EditType, + ) { + let mut cursor = editor.map(|editor| editor.cursor.get_untracked()); + let cursor_before = cursor.as_ref().map(|cursor| cursor.mode.clone()); + + let deltas = self + .buffer + .try_update(|buffer| buffer.edit(iter, edit_type)); + let deltas = deltas.map(|x| [x]); + let deltas = deltas.as_ref().map(|x| x as &[_]).unwrap_or(&[]); + + if deltas.is_empty() { + return; + } + + if let Some(cursor) = cursor.as_mut() { + for delta in deltas.iter().map(|(_, delta, _)| delta) { + cursor.apply_delta(delta); + } + } + + if let (Some(cursor_before), Some(cursor)) = (cursor_before, cursor.as_ref()) { + self.buffer.update(|buffer| { + buffer.set_cursor_before(cursor_before); + buffer.set_cursor_after(cursor.mode.clone()); + }); + } + + self.update_cache_rev(); + self.on_update(editor, deltas); + + if let (Some(editor), Some(cursor)) = (editor, cursor) { + editor.cursor.set(cursor); + } + } + fn placeholder(&self, editor_id: EditorId) -> Option { self.placeholders .with_untracked(|placeholders| placeholders.get(&editor_id).cloned()) @@ -231,14 +271,16 @@ impl Document for TextDocument { } fn edit(&self, iter: &mut dyn Iterator, edit_type: EditType) { - let deltas = self - .buffer - .try_update(|buffer| buffer.edit(iter, edit_type)); - let deltas = deltas.map(|x| [x]); - let deltas = deltas.as_ref().map(|x| x as &[_]).unwrap_or(&[]); + self.apply_programmatic_edit(None, iter, edit_type); + } - self.update_cache_rev(); - self.on_update(None, deltas); + fn edit_from( + &self, + editor: &Editor, + iter: &mut dyn Iterator, + edit_type: EditType, + ) { + self.apply_programmatic_edit(Some(editor), iter, edit_type); } } impl DocumentPhantom for TextDocument {