rework element api to expose EditableTextState as a viable entity for users to create via use_keyed_state

This commit is contained in:
temportalflux
2026-07-11 09:31:07 -04:00
parent cc055b7f7d
commit 2cc4f237d6
4 changed files with 133 additions and 130 deletions
+39 -11
View File
@@ -65,7 +65,7 @@
//! .min_w_10().max_w_128()
//! .min_h_24().max_h_128()
//! .whitespace_normal() // default
//! .overflow_x_scroll().overflow_y_scroll()
//! .overflow_y_scroll()
//! # }
//! ```
//!
@@ -74,16 +74,44 @@
//! 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());
//! })
//! # use gpui::{prelude::*, App, Entity, Window, AppContext, ElementId};
//! # fn test(window: &mut Window, cx: &mut App) -> gpui_elements::editable_text::EditableTextElement {
//! use gpui_elements::editable_text::{text_input, EditableTextState, TextChanged};
//!
//! // A unique id to the editable text element within the outer scope.
//! let id = ElementId::from("my_input");
//!
//! // Find or lazily create the state entity backing the element.
//! // Then attach the entity to the element, thereby keeping it alive across consecutive frames.
//! let state = EditableTextState::use_keyed(id.clone(), window, cx);
//!
//! // This will trigger on every character input or other mutation to the underlying string
//! cx.subscribe(&state, |state, _: &TextChanged, cx| {
//! println!("{:?}", state.read(cx).as_str());
//! }).detach();
//!
//! // Using state explicitly attaches the state we already have attached to the ElementId.
//! text_input(id).state(state.downgrade())
//! # }
//! ```
//!
//! You can configure the default value of the editable text by using [`use_keyed_init`]:
//! ```
//! # use gpui::{prelude::*, App, Entity, Window, AppContext, ElementId};
//! # use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage};
//! # fn test(window: &mut Window, cx: &mut App) -> gpui_elements::editable_text::EditableTextElement {
//! let id = ElementId::from("my_input");
//!
//! // The function parameter will only be called when the state is created/initialized.
//! // All successive renders across consecutive frames will re-use the existing state.
//! let _state = EditableTextState::use_keyed_init(id.clone(), window, cx,
//! |_window, _cx| StringStorage::from("this is some default text content"));
//!
//! // Its also plausible to omit the state function call. The element will try to find the state
//! // according to its id (which we are trusting here was garunteed to be at that id above).
//! // Despite this functionality, its recommended that callers which construct a state explicitly
//! // provide it to the element, at least for clarity and debugging.
//! text_input(id)
//! # }
//! ```
//!
@@ -1,5 +1,5 @@
use crate::editable_text::{
EditableTextState, FRcTextChanged, InitStorage,
EditableTextState,
actions::{DEFAULT_INPUT_CONTEXT, EditableTextActionElement, EditableTextActionHandler},
layout::{TextInputLayoutData, TextLineSegment},
};
@@ -24,11 +24,9 @@ pub fn editable_text(id: impl Into<ElementId>) -> EditableTextElement {
interactivity: Interactivity::default(),
state_entity: Rc::new(RefCell::new(WeakEntity::new_invalid())),
supports_multiline: true,
init_storage: InitStorage::default(),
placeholder: None,
accepts_input: true,
colors: EditableTextColors::default(),
on_text_changed: None,
};
this.interactivity.element_id = Some(id.into());
@@ -53,24 +51,16 @@ pub fn text_area(id: impl Into<ElementId>) -> EditableTextElement {
}
/// An input field which users can type text into.
///
/// EditableText elements require a storage medium to be specified
/// (defaulting to [`StringStorage`](super::StringStorage)).
/// Use [`with_storage`](Self::with_storage) to configure the storage medium,
/// or [`default_value`](Self::default_value) to specify the content of the default storage medium.
///
pub struct EditableTextElement {
interactivity: Interactivity,
// Populated on first render with an entity stored/attached to the view.
// This reference is shared with the action handlers, which are processed between renders
// and therefore cannot otherwise access state attached to the view.
state_entity: Rc<RefCell<WeakEntity<EditableTextState>>>,
init_storage: InitStorage,
supports_multiline: bool,
placeholder: Option<SharedString>,
accepts_input: bool,
colors: EditableTextColors,
on_text_changed: Option<FRcTextChanged>,
}
/// EditableText styling that goes beyond what Style/StyleRefinement supports
@@ -103,6 +93,15 @@ impl Default for EditableTextColors {
}
impl EditableTextElement {
/// Assigns the underlying state of this element, which should persist across multiple frames.
/// The user should either create the entity once or utilize `Window::use_keyed_state`
/// to create an entity intrinsicly linked to the element.
/// If no state is configured, one will be linked to this element on first render via `Window::use_keyed_state`.
pub fn state(self, state: WeakEntity<EditableTextState>) -> Self {
*self.state_entity.borrow_mut() = state;
self
}
/// Configures whether the field supports multiple lines of text.
/// Disabling this prevents actions like `enter` and navigating up and down.
///
@@ -119,22 +118,6 @@ impl EditableTextElement {
self
}
/// Swaps the default storage container (standard String) with a custom initializer of [`UnicodeTextStorage`].
pub fn with_storage(mut self, fn_init: impl Into<InitStorage>) -> Self {
self.init_storage = fn_init.into();
self
}
/// Swaps the default storage container. The new initializer is a standard String using the provided value.
///
/// Incompatible with [`with_storage`] (they establish the same internal value).
/// If you initialize custom storage, you should be able to initialize its default value.
pub fn default_value(mut self, value: impl Into<String>) -> Self {
let storage = super::StringStorage::from(value.into());
self.init_storage = InitStorage::new_typed(move |_cx| storage.clone());
self
}
/// Configures whether the element can accept input (effectively means "is the element currently enabled").
pub fn accepts_input(mut self, enabled: bool) -> Self {
self.accepts_input = enabled;
@@ -173,20 +156,6 @@ 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 {
@@ -252,19 +221,18 @@ impl Element for EditableTextElement {
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
// Fetches or initializes the internal state of the field
let state = match &self.interactivity().element_id {
None => unimplemented!("all input elements must be assigned an id"),
Some(element_id) => {
let state = window.use_keyed_state(element_id.clone(), cx, |_window, cx| {
EditableTextState::new(self.init_storage.exec(cx), cx)
});
// 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
}
let state = self.state_entity.borrow().upgrade();
let state = match state {
Some(entity) => entity,
None => match &self.interactivity.element_id {
None => unimplemented!("all input elements must be assigned an id"),
Some(element_id) => {
let state = EditableTextState::use_keyed(element_id.clone(), window, cx);
// store a reference to the entity owned by the element for access in action handlers
*self.state_entity_rc().borrow_mut() = state.downgrade();
state
}
},
};
let show_placeholder;
+70 -30
View File
@@ -1,23 +1,15 @@
use crate::editable_text::{
TextBoundary, UnicodeTextStorage,
StringStorage, TextBoundary, UnicodeTextStorage,
actions::EditableTextActionHandler,
caret::{Caret, CaretNotify},
history::EditableTextHistory,
layout::TextInputLayoutData,
};
use gpui::{
App, Bounds, ClipboardItem, Context, Entity, EntityInputHandler, EventEmitter, FocusHandle,
Focusable, NavigationDirection, Pixels, Point, UTF16Selection, Window, point,
App, Bounds, ClipboardItem, Context, ElementId, Entity, EntityInputHandler, EventEmitter,
FocusHandle, Focusable, NavigationDirection, Pixels, Point, UTF16Selection, Window, point,
};
use std::{borrow::Cow, ops::Range, rc::Rc};
/// 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>;
use std::{borrow::Cow, ops::Range};
/// Internal state for EditableText elements.
pub struct EditableTextState {
@@ -27,10 +19,6 @@ 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).
@@ -60,6 +48,12 @@ pub struct EditableTextState {
impl EventEmitter<CaretNotify> for EditableTextState {}
/// Event emitted when an `EditableTextState` is changed.
///
/// This is not suitable for input sanitation (which should occur before the mutation).
pub struct TextChanged;
impl EventEmitter<TextChanged> for EditableTextState {}
impl Focusable for EditableTextState {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
@@ -73,7 +67,55 @@ impl AsRef<str> for EditableTextState {
}
impl EditableTextState {
pub fn new(storage: impl Into<Box<dyn UnicodeTextStorage>>, cx: &mut Context<Self>) -> Self {
/// Uses a pre-existing state attached to the element at `key`, as long as the element has existed over consecutive frames.
/// If the state does not yet exist, a new one is created using the default [`UnicodeTextStorage`] medium.
pub fn use_keyed(key: impl Into<ElementId>, window: &mut Window, cx: &mut App) -> Entity<Self> {
Self::use_keyed_init(key, window, cx, |_, _| StringStorage::default())
}
/// Uses a pre-existing state attached to the element at `key`, as long as the element has existed over consecutive frames.
/// If the state does not yet exist, a new one is created calling `init` to create a [`UnicodeTextStorage`] medium.
///
/// ```
/// # use gpui::{RenderOnce, Window, App, IntoElement, ElementId};
/// # use gpui_elements::editable_text::{EditableTextState, StringStorage, editable_text};
/// pub struct Form;
/// impl RenderOnce for Form {
/// fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
/// let field_a_id = ElementId::from("field_a");
/// let field_a = EditableTextState::use_keyed_init(field_a_id.clone(), window, cx,
/// |_window, _cx| StringStorage::from("this is some default editable text content"));
/// editable_text(field_a_id).state(field_a.downgrade())
/// }
/// }
/// ```
pub fn use_keyed_init<F, StorageType>(
key: impl Into<ElementId>,
window: &mut Window,
cx: &mut App,
init: F,
) -> Entity<Self>
where
F: 'static + Fn(&mut Window, &mut Context<'_, EditableTextState>) -> StorageType,
StorageType: 'static + UnicodeTextStorage,
{
window.use_keyed_state(key, cx, |window, cx| Self::new(init(window, cx), cx))
}
/// Creates a new EditableText state with a given storage medium.
///
/// Does not intrinsicly handle the state being attached to an element
/// over multiple frames (e.g. via [`RenderOnce`]). Use [`use_keyed`] or [`use_keyed_init`] for that.
///
/// Expected to be called via [`AppContext::new`] such as:
/// ```
/// # use gpui::{AppContext, Window, App, Entity};
/// # use gpui_elements::editable_text::{StringStorage, EditableTextState};
/// # fn new(_window: &mut Window, cx: &mut App) -> Entity<EditableTextState> {
/// cx.new(|cx| EditableTextState::new(StringStorage::default(), cx))
/// # }
/// ```
pub fn new(storage: impl UnicodeTextStorage + 'static, cx: &mut Context<Self>) -> Self {
use gpui::AppContext;
let caret = cx.new({
let state_entity = cx.entity();
@@ -84,7 +126,7 @@ impl EditableTextState {
}
});
Self {
storage: storage.into(),
storage: Box::new(storage),
caret,
selected_range: 0..0,
@@ -97,7 +139,6 @@ 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(),
}
@@ -108,6 +149,14 @@ impl EditableTextState {
self.storage().content_utf8()
}
/// Replaces the contents of the stored text with the provided string slice.
pub fn emplace(&mut self, content: &str, cx: &mut Context<Self>) {
let len = self.storage.content_utf8().len();
self.replace_text(0..len, content);
self.emit_text_changed(cx);
cx.notify();
}
/// Returns the storage medium created for this field.
pub(super) fn storage(&self) -> &Box<dyn UnicodeTextStorage> {
&self.storage
@@ -195,15 +244,7 @@ impl EditableTextState {
}
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));
cx.emit(TextChanged);
}
}
@@ -1110,8 +1151,7 @@ mod tests {
}
fn default_state(content: &str, cx: &mut Context<EditableTextState>) -> EditableTextState {
let storage = Box::new(StringStorage::from(content)) as Box<dyn UnicodeTextStorage>;
EditableTextState::new(storage, cx)
EditableTextState::new(StringStorage::from(content), cx)
}
fn create_test_input(
@@ -1,5 +1,5 @@
use gpui::{App, NavigationDirection};
use std::{ops::Range, rc::Rc};
use gpui::NavigationDirection;
use std::ops::Range;
use unicode_segmentation::UnicodeSegmentation;
/// Describes a boundary within a chunk of text.
@@ -14,39 +14,6 @@ pub enum TextBoundary {
Document,
}
/// Allocated Fn initializer for EditableText storage medium [`UnicodeTextStorage`].
/// Defaults to empty/none, resulting in [`StringStorage`] when executed.
#[derive(Clone, Default)]
pub struct InitStorage(Option<Rc<dyn Fn(&mut App) -> Box<dyn UnicodeTextStorage>>>);
impl InitStorage {
/// Basic constructor which requires that the output of the func is a `dyn UnicodeTextStorage`.
pub fn new<F>(f: F) -> Self
where
F: 'static + Fn(&mut App) -> Box<dyn UnicodeTextStorage>,
{
Self(Some(Rc::new(f)))
}
/// Wrapper around [`new`] which automatically casts the output of the provided func to [`UnicodeTextStorage`].
pub fn new_typed<F, R>(f: F) -> Self
where
F: 'static + Fn(&mut App) -> R,
R: 'static + UnicodeTextStorage,
{
Self(Some(Rc::new(move |cx| {
Box::new(f(cx)) as Box<dyn UnicodeTextStorage>
})))
}
pub(super) fn exec(&self, cx: &mut App) -> Box<dyn UnicodeTextStorage> {
match &self.0 {
None => Box::new(StringStorage::default()),
Some(init) => (*init)(cx),
}
}
}
/// Implement this trait to create a storage medium that can be used as the content of EditableText elements.
/// Default implementation is [`StringStorage`].
pub trait UnicodeTextStorage {