add on_text_changed callback binding to allow the caller to be notified on every change to the text

This commit is contained in:
temportalflux
2026-07-11 09:31:07 -04:00
parent 6beeecb5a8
commit 812d5aa91a
3 changed files with 103 additions and 17 deletions
+30 -2
View File
@@ -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<EditableTextState>, 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
@@ -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<ElementId>) -> 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<SharedString>,
accepts_input: bool,
colors: EditableTextColors,
on_text_changed: Option<FRcTextChanged>,
}
/// 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<F>(mut self, f: F) -> Self
where
F: 'static + Fn(&Entity<EditableTextState>, &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();
+46 -14
View File
@@ -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<dyn Fn(&Entity<EditableTextState>, &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<Caret>,
/// 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<FRcTextChanged>,
/// 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<TextChanged> for EditableTextState {}
impl EventEmitter<CaretNotify> for EditableTextState {}
impl Focusable for EditableTextState {
@@ -58,6 +66,12 @@ impl Focusable for EditableTextState {
}
}
impl AsRef<str> for EditableTextState {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl EditableTextState {
pub fn new(storage: impl Into<Box<dyn UnicodeTextStorage>>, cx: &mut Context<Self>) -> 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<dyn UnicodeTextStorage> {
pub(super) fn storage(&self) -> &Box<dyn UnicodeTextStorage> {
&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<Caret> {
pub(super) fn caret_entity(&self) -> &Entity<Caret> {
&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<Range<usize>> {
pub(super) fn marked_range(&self) -> Option<Range<usize>> {
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<Self>) {
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<Context<'app, Self>> 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<Context<'app, Self>> 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();
}