From 812d5aa91afbe27cb0044fdf1ad41b6513d1f5d5 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Fri, 26 Jun 2026 10:48:31 -0400 Subject: [PATCH] add on_text_changed callback binding to allow the caller to be notified on every change to the text --- crates/gpui_elements/src/editable_text.rs | 32 +++++++++- .../src/editable_text/element.rs | 28 ++++++++- .../gpui_elements/src/editable_text/state.rs | 60 ++++++++++++++----- 3 files changed, 103 insertions(+), 17 deletions(-) diff --git a/crates/gpui_elements/src/editable_text.rs b/crates/gpui_elements/src/editable_text.rs index 8973bae6ba..c7524e4bd6 100644 --- a/crates/gpui_elements/src/editable_text.rs +++ b/crates/gpui_elements/src/editable_text.rs @@ -17,21 +17,26 @@ //! Standard library strings are not ideal though for large text documents. For such uses, //! it is encouraged that implementers consider rolling their own [`UnicodeTextStorage`] medium. //! -//! Some sample usages: +//! ### Usage Samples //! //! A single-line text input with a fixed width and text that does not wrap //! (overflow text is clipped and does not scroll). //! ``` +//! # use gpui::prelude::*; +//! # fn test() -> gpui_elements::editable_text::EditableTextElement { //! use gpui_elements::editable_text::text_input; //! text_input("my_input") //! .placeholder("empty text") //! .w_5() //! .min_h_auto() //! .whitespace_nowrap() +//! # } //! ``` //! //! A single-line text input with a flexible width and text that does not wrap, but will scroll if overflowing. //! ``` +//! # use gpui::{prelude::*, Hsla}; +//! # fn test() -> gpui_elements::editable_text::EditableTextElement { //! use gpui_elements::editable_text::text_input; //! text_input("my_input") //! .placeholder("empty text") @@ -41,10 +46,13 @@ //! .min_h_auto() //! .whitespace_nowrap() //! .overflow_x_scroll() +//! # } //! ``` //! //! A multi-line text area with flexible height, wrapping text, and scrolling overflow on both axes. //! ``` +//! # use gpui::{prelude::*, Hsla}; +//! # fn test() -> gpui_elements::editable_text::EditableTextElement { //! use gpui_elements::editable_text::text_area; //! text_area("message") //! .placeholder("empty text") @@ -54,12 +62,32 @@ //! .min_h_24().max_h_128() //! .whitespace_normal() // default //! .overflow_x_scroll().overflow_y_scroll() +//! # } +//! ``` +//! +//! The user-inputted text can be accessed via event callbacks on the element. +//! There is no callback representing the concept of "user is done editing". Its recommended that +//! users write a [debounce](https://developer.mozilla.org/en-US/docs/Glossary/Debounce) +//! or some way to detect "focus lost" to signify the user leaving the field. +//! ``` +//! # use gpui::{prelude::*, App, Entity}; +//! # fn test() -> gpui_elements::editable_text::EditableTextElement { +//! use gpui_elements::editable_text::{text_input, EditableTextState}; +//! text_input("my_input") +//! .whitespace_nowrap() +//! .overflow_x_scroll() +//! // this will trigger on every character input or other mutation to the underlying string +//! .on_text_changed(|state: &Entity, cx: &mut App| { +//! println!("text changed to: {:?}", state.read(cx).as_str()); +//! }) +//! # } //! ``` //! //! You can view more complex examples in the gpui crate examples. //! TODO: there is no example with editable text yet, and we should link it here when there is. //! -//! Backlog of not-yet implemented features: +//! ### Backlog of not-yet implemented features: +//! - detecting focus being lost on an EditableText field //! - text sanitation & validation (see no-op implementation of [`EditableTextState::validate_incoming_text`]) //! - nav & select via PageUp/PageDown //! - screen reader support via a11y diff --git a/crates/gpui_elements/src/editable_text/element.rs b/crates/gpui_elements/src/editable_text/element.rs index 1a34713531..bbd6a295f7 100644 --- a/crates/gpui_elements/src/editable_text/element.rs +++ b/crates/gpui_elements/src/editable_text/element.rs @@ -1,5 +1,5 @@ use crate::editable_text::{ - EditableTextState, InitStorage, + EditableTextState, FRcTextChanged, InitStorage, actions::{DEFAULT_INPUT_CONTEXT, EditableTextActionElement, EditableTextActionHandler}, layout::{TextInputLayoutData, TextLineSegment}, }; @@ -28,6 +28,7 @@ pub fn editable_text(id: impl Into) -> EditableTextElement { placeholder: None, accepts_input: true, colors: EditableTextColors::default(), + on_text_changed: None, }; this.interactivity.element_id = Some(id.into()); @@ -69,6 +70,7 @@ pub struct EditableTextElement { placeholder: Option, accepts_input: bool, colors: EditableTextColors, + on_text_changed: Option, } /// EditableText styling that goes beyond what Style/StyleRefinement supports @@ -171,6 +173,20 @@ impl EditableTextElement { self.colors.ime_underline = color; self } + + /// Assigns the callback to execute on after every change to the text. + /// + /// This is not suitable for input sanitation (which should occur before the mutation). + /// + /// Doesn't use the established emit/subscribe pattern normally found on entities. + /// Reasoning and blockers are described in the documentation of [`FRcTextChanged`]. + pub fn on_text_changed(mut self, f: F) -> Self + where + F: 'static + Fn(&Entity, &mut App), + { + self.on_text_changed = Some(Rc::new(f)); + self + } } impl InteractiveElement for EditableTextElement { @@ -244,6 +260,9 @@ impl Element for EditableTextElement { }); // store a reference to the entity owned by the element for access in action handlers *self.state_entity_rc().borrow_mut() = state.downgrade(); + state.update(cx, |state, _cx| { + state.on_text_changed = self.on_text_changed.clone(); + }); state } }; @@ -263,7 +282,14 @@ impl Element for EditableTextElement { } } + // NOTE: Unlike other elements, a FocusHandle is owned by the state. + // This means the user is currently unable to provide a focus handle. + // This was born out of Interactivity not having a way to read the focus handle. + // This might be able to be eliminated entirely if focus handle can be passed thru on_mouse_down. + // TODO: This required a gpui api change in order to sync the focus handle between Interactivity and TextInputStateBase + // maybe use `set_focus_handle` during prepaint? + //window.set_focus_handle(focus_handle, cx); self.interactivity.track_focus(focus_handle); let placeholder = self.placeholder.clone(); diff --git a/crates/gpui_elements/src/editable_text/state.rs b/crates/gpui_elements/src/editable_text/state.rs index eab73a0084..62a4c26802 100644 --- a/crates/gpui_elements/src/editable_text/state.rs +++ b/crates/gpui_elements/src/editable_text/state.rs @@ -9,12 +9,17 @@ use gpui::{ App, Bounds, ClipboardItem, Context, Entity, EntityInputHandler, EventEmitter, FocusHandle, Focusable, NavigationDirection, Pixels, Point, UTF16Selection, Window, point, }; -use std::{borrow::Cow, ops::Range}; +use std::{borrow::Cow, ops::Range, rc::Rc}; -/// Event emitted via EditableText elements when the internal storage contents have changed -pub struct TextChanged; +/// Heap allocated function which is triggered when the text is changed. +/// +/// This doesn't use the established [`Context::emit`] pattern because of limitations caused by +/// [`Window::use_keyed_state`], where the [`EditableTextState`] entity is unavailable until +/// [`Element::request_layout`] and therefore cannot be provided to the +/// element's caller/constructor for usage via [`Context::subscribe`]. +pub type FRcTextChanged = Rc, &mut App) + 'static>; -/// Internal state for EditableText elements. There is no way to access this externally with the current api. +/// Internal state for EditableText elements. pub struct EditableTextState { /// The storage medium backing this element-state. Hypothetically supports both /// std String and other crates (e.g. long document text). @@ -22,6 +27,10 @@ pub struct EditableTextState { /// The caret entity which has internal state for features like blinking caret: Entity, + /// Callback for consumers to receive notifications that the storage has changed. + /// See documentation of [`FRcTextChanged`] why this doesnt use the typical emit/subscribe approach. + pub(super) on_text_changed: Option, + /// The utf-8 character range that is currently selected by the user. /// Valid both when start < end and start > end (which dictates the direction of the selection). /// Empty when start==end. The start of this range is always the current position of the caret (input cursor). @@ -49,7 +58,6 @@ pub struct EditableTextState { pub(super) layout_data: TextInputLayoutData, } -impl EventEmitter for EditableTextState {} impl EventEmitter for EditableTextState {} impl Focusable for EditableTextState { @@ -58,6 +66,12 @@ impl Focusable for EditableTextState { } } +impl AsRef for EditableTextState { + fn as_ref(&self) -> &str { + self.as_str() + } +} + impl EditableTextState { pub fn new(storage: impl Into>, cx: &mut Context) -> Self { use gpui::AppContext; @@ -83,13 +97,19 @@ impl EditableTextState { focus_handle: cx.focus_handle(), // TODO: what is the best way to give users access to configure this via element history: Some(EditableTextHistory::default()), + on_text_changed: None, layout_data: TextInputLayoutData::default(), } } + /// Returns the current contents of [`storage`] as a string slice. + pub fn as_str(&self) -> &str { + self.storage().content_utf8() + } + /// Returns the storage medium created for this field. - pub fn storage(&self) -> &Box { + pub(super) fn storage(&self) -> &Box { &self.storage } @@ -109,12 +129,12 @@ impl EditableTextState { } /// Returns a reference to the entity owning the state of the [`Caret`] (e.g. its blinking state). - pub fn caret_entity(&self) -> &Entity { + pub(super) fn caret_entity(&self) -> &Entity { &self.caret } /// Returns the position of the caret in utf8 character space. - pub fn caret_pos(&self) -> usize { + pub(super) fn caret_pos(&self) -> usize { self.selected_range.start } @@ -123,7 +143,7 @@ impl EditableTextState { } /// Returns the IME marked range for character operations. - pub fn marked_range(&self) -> Option> { + pub(super) fn marked_range(&self) -> Option> { self.marked_range.clone() } } @@ -168,6 +188,18 @@ impl EditableTextState { self.selected_range = end_pos..end_pos; self.marked_range = None; } + + fn emit_text_changed(&self, cx: &mut Context) { + let Some(rc_callback) = self.on_text_changed.clone() else { + return; + }; + // Defer the emit until the end of the update cycle so that the state can be provided + // as the subject instead of direct access to storage. + // That way if the listener needs to mutate the stored contents, they can do so via + // apis on Self (which will help retain caret and selection coherence). + let entity = cx.entity(); + cx.defer(move |cx| (*rc_callback)(&entity, cx)); + } } // Screen space (text layout engine output) & String space transformers @@ -409,7 +441,7 @@ impl EditableTextState { self.replace_text(start..end, ""); - cx.emit(TextChanged); + self.emit_text_changed(cx); cx.notify(); } @@ -608,7 +640,7 @@ impl EntityInputHandler for EditableTextState { let text_to_insert = self.validate_incoming_text(&range_utf8, text_to_insert); self.replace_text(range_utf8, text_to_insert); cx.emit(CaretNotify::PauseBlinking); - cx.emit(TextChanged); + self.emit_text_changed(cx); cx.notify(); } @@ -625,7 +657,7 @@ impl EntityInputHandler for EditableTextState { self.replace_text(range.clone(), text_to_insert); self.ime_mark_text_in_range(&range, text_to_insert.len()); self.ime_mark_selected_range(&range, &new_selected_range_utf16, text_to_insert.len()); - cx.emit(TextChanged); + self.emit_text_changed(cx); cx.notify(); } @@ -937,7 +969,7 @@ impl<'app> EditableTextActionHandler> for EditableTextState self.replace_text(self.selected_range.clone(), ""); } - cx.emit(TextChanged); + self.emit_text_changed(cx); cx.notify(); } @@ -960,7 +992,7 @@ impl<'app> EditableTextActionHandler> for EditableTextState let range = self.ime_resolve_range(None); let text_to_insert = self.validate_incoming_text(&range, &text); self.replace_text(range, text_to_insert); - cx.emit(TextChanged); + self.emit_text_changed(cx); cx.notify(); }