From 59e716c6eeccac68c15e9dfa9ff532a9aefb6ca6 Mon Sep 17 00:00:00 2001 From: Third-Thing <219055174+Third-Thing@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:08:54 -0700 Subject: [PATCH 1/3] crash fix: sync stale editor cursors through document updates This is the smallest change that fixes the crash using existing document-delta plumbing, without taking on the larger API remodel. --- src/views/editor/mod.rs | 152 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/src/views/editor/mod.rs b/src/views/editor/mod.rs index 9786750df..f2f81f190 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,45 @@ impl Editor { }); } + 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; + }; + + 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 +1788,113 @@ 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"); + } +} From 609eaf770b706b511e0fada06b5e6bde29490a51 Mon Sep 17 00:00:00 2001 From: Third-Thing <219055174+Third-Thing@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:29:54 -0700 Subject: [PATCH 2/3] add comments for register_doc_cursor_sync --- src/views/editor/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/views/editor/mod.rs b/src/views/editor/mod.rs index f2f81f190..9b39be4ea 100644 --- a/src/views/editor/mod.rs +++ b/src/views/editor/mod.rs @@ -387,6 +387,10 @@ 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; @@ -408,6 +412,8 @@ impl Editor { 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; } From b1222c25c214068039ed7fc1b06e897183e31206 Mon Sep 17 00:00:00 2001 From: Third-Thing <219055174+Third-Thing@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:54:35 -0700 Subject: [PATCH 3/3] 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. --- examples/editor/src/main.rs | 22 ++++---- examples/syntax-editor/src/main.rs | 4 +- examples/widget-gallery/src/texteditor.rs | 16 ++---- src/views/editor/mod.rs | 33 +++++++++++ src/views/editor/text.rs | 68 ++++++++++++++++++----- src/views/editor/text_document.rs | 56 ++++++++++++++++--- 6 files changed, 153 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/mod.rs b/src/views/editor/mod.rs index 9b39be4ea..d1bcdb41e 100644 --- a/src/views/editor/mod.rs +++ b/src/views/editor/mod.rs @@ -1903,4 +1903,37 @@ mod tests { 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 {