From 28e6a4f8f7276c12e9b837fd95b910b0c1924440 Mon Sep 17 00:00:00 2001 From: Third-Thing <219055174+Third-Thing@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:21:42 -0700 Subject: [PATCH] enable undo cursor restoration for programmatic edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Programmatic document edits currently bypass the editor context that undo cursor restoration depends on. `Document::edit` and `edit_single` mutate the buffer and emit deltas, but they do not record `cursor_before` / `cursor_after`, so undo lacks the initiating editor state needed to restore the cursor correctly. That left floem’s examples using raw document edits from button callbacks and compensating with workarounds like disabling undo in one shared editor. Solution: Add additive editor-aware document edit APIs: `edit_from` and `edit_single_from`. `TextDocument` uses the initiating editor to capture cursor-before state, apply the edit, remap that editor’s cursor through the produced deltas, and record cursor-after state on the revision. Raw `edit` / `edit_single` remain unchanged as low-level document mutation APIs, while UI-driven edits can now preserve expected cursor and undo behavior without changing the broader document-centered reactive model. Reasoning: This keeps the architecture aligned with floem’s existing update flow. Live cursor consistency across editors still comes from document deltas and update listeners; the new APIs only add what undo needs. The examples are moved to the editor-aware path, which removes the need to suppress undo in the secondary editor and makes the intended API split explicit. Associated with issue https://github.com/lapce/floem/issues/1058 --- examples/editor/src/main.rs | 22 ++++---- examples/syntax-editor/src/main.rs | 4 +- examples/widget-gallery/src/texteditor.rs | 16 ++---- src/views/editor/text.rs | 68 ++++++++++++++++++----- src/views/editor/text_document.rs | 56 ++++++++++++++++--- 5 files changed, 120 insertions(+), 46 deletions(-) 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/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 {