reorganize editable text implementation for maintainability

This commit is contained in:
temportalflux
2026-07-11 09:31:07 -04:00
parent 5e6d3028e0
commit 717f465001
5 changed files with 305 additions and 273 deletions
+1 -1
View File
@@ -2,6 +2,7 @@ pub mod actions;
mod caret;
mod element;
mod history;
mod layout;
mod state;
mod storage;
@@ -10,7 +11,6 @@ pub use state::*;
pub use storage::*;
/* TODO list
- remove gpuikit based input
- text sanitation
- add page up/down actions to nav by an entire page or expand selection by an entire page
- test IME (char palette only available on macos)
@@ -1,6 +1,7 @@
use crate::editable_text::{
EditableTextState, InitStorage, TextInputLayoutData, TextLineSegment,
EditableTextState, InitStorage,
actions::{DEFAULT_INPUT_CONTEXT, EditableTextActionElement, EditableTextActionHandler},
layout::{TextInputLayoutData, TextLineSegment},
};
use gpui::{
App, Bounds, CursorStyle, DispatchPhase, Display, Element, ElementId, ElementInputHandler,
@@ -0,0 +1,72 @@
use gpui::{Bounds, Pixels, Point, Size, WrappedLine};
use std::{ops::Range, sync::Arc};
/// Generated / internal state tracked during and across renders of the element.
#[derive(Default)]
pub(super) struct TextInputLayoutData {
/// Whether the element supports multiple lines of text
pub supports_multiline: bool,
/// Whether the element is currently accepting inputs
pub accepts_input: bool,
/// The last seen scroll position and size of the element
pub scroll_bounds: Bounds<Pixels>,
/// The last known width at which the lines were wrapped.
pub wrap_width: Option<Pixels>,
/// The last known size of the text, as generated during layout.
pub size: Option<Size<Pixels>>,
/// The last seen version of `storage` (for tracking when lines need to be reprocessed during layout)
pub last_seen_storage_version: u16,
/// The `ShapedLine` produced by the painter's `prepaint`.
/// Cached so IME `bounds_for_range` / `character_index_for_point` can evaluate without re-shaping.
pub lines: Vec<TextLineSegment>,
pub line_height: Pixels,
/// The next position the scroll view should move to.
/// Set by the state in response to user actions.
pub next_scroll_offset: Option<Point<Pixels>>,
}
/// A segment of text that is a single logical/document line but can take up multiple rows due to wrapping.
pub(super) struct TextLineSegment {
/// The utf8 byte range in the content string that this line covers.
pub text_range: Range<usize>,
/// The shaped and wrapped text for this line, if available.
pub wrapped_line: Option<Arc<WrappedLine>>,
/// The y-coordinate of this segment which can be multiplied by the line_height
/// to get its pixel location relative to the bounds of the text area.
pub pos_y: usize,
}
impl TextLineSegment {
/// The number of visual lines this segment encapsulates,
/// since it can occupy multiple rows due to wrapping.
pub fn row_count(&self) -> usize {
let count = self
.wrapped_line
.as_ref()
.map(|line| line.wrap_boundaries().len());
count.unwrap_or_default() + 1
}
/// Returns true if the line contains a given position (e.g. for finding the line containing the caret).
/// If `includes_end` is true, the end of the line is treated as inclusive instead of exclusive.
pub fn contains_position(&self, pos: usize, include_end: bool) -> bool {
if self.text_range.is_empty() {
return pos == self.text_range.start;
}
// pos must be >= range-start
if pos < self.text_range.start {
return false;
}
// pos must be <= range-end
if pos > self.text_range.end {
return false;
}
// pos must be < range-end
// or == is permitted if explicitly allowed (varies according to usage needs)
pos < self.text_range.end || include_end
}
}
+221 -271
View File
@@ -3,6 +3,7 @@ use crate::editable_text::{
actions::EditableTextActionHandler,
caret::{Caret, CaretNotify},
history::EditableTextHistory,
layout::TextInputLayoutData,
};
use gpui::{
App, Bounds, ClipboardItem, Context, Entity, EntityInputHandler, EventEmitter, FocusHandle,
@@ -11,10 +12,15 @@ use gpui::{
};
use std::{borrow::Cow, ops::Range, sync::Arc};
/// Event emitted via EditableText elements when the internal storage contents have changed
pub struct TextChanged;
/// 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).
storage: Box<dyn UnicodeTextStorage>,
/// The caret entity which has internal state for features like blinking
caret: Entity<Caret>,
/// The utf-8 character range that is currently selected by the user.
@@ -44,71 +50,6 @@ pub struct EditableTextState {
pub(super) layout_data: TextInputLayoutData,
}
#[derive(Default)]
pub(super) struct TextInputLayoutData {
/// Whether the element supports multiple lines of text
pub supports_multiline: bool,
/// Whether the element is currently accepting inputs
pub accepts_input: bool,
/// The last seen scroll position and size of the element
pub scroll_bounds: Bounds<Pixels>,
/// The last known width at which the lines were wrapped.
pub wrap_width: Option<Pixels>,
/// The last known size of the text, as generated during layout.
pub size: Option<Size<Pixels>>,
/// The last seen version of `storage` (for tracking when lines need to be reprocessed during layout)
pub last_seen_storage_version: u16,
/// The `ShapedLine` produced by the painter's `prepaint`.
/// Cached so IME `bounds_for_range` / `character_index_for_point` can evaluate without re-shaping.
pub lines: Vec<TextLineSegment>,
pub line_height: Pixels,
/// The next position the scroll view should move to.
/// Set by the state in response to user actions.
pub next_scroll_offset: Option<Point<Pixels>>,
}
/// A segment of text that is a single logical/document line but can take up multiple rows due to wrapping.
pub(super) struct TextLineSegment {
/// The utf8 byte range in the content string that this line covers.
pub text_range: Range<usize>,
/// The shaped and wrapped text for this line, if available.
pub wrapped_line: Option<Arc<WrappedLine>>,
/// The y-coordinate of this segment which can be multiplied by the line_height
/// to get its pixel location relative to the bounds of the text area.
pub pos_y: usize,
}
impl TextLineSegment {
/// The number of visual lines this segment encapsulates,
/// since it can occupy multiple rows due to wrapping.
pub fn row_count(&self) -> usize {
let count = self
.wrapped_line
.as_ref()
.map(|line| line.wrap_boundaries().len());
count.unwrap_or_default() + 1
}
pub fn contains_position(&self, pos: usize, include_end: bool) -> bool {
if self.text_range.is_empty() {
return pos == self.text_range.start;
}
// pos must be >= range-start
if pos < self.text_range.start {
return false;
}
// pos must be <= range-end
if pos > self.text_range.end {
return false;
}
// pos must be < range-end
// or == is permitted if explicitly allowed (varies according to usage needs)
pos < self.text_range.end || include_end
}
}
impl EventEmitter<TextChanged> for EditableTextState {}
impl EventEmitter<CaretNotify> for EditableTextState {}
@@ -184,6 +125,42 @@ impl EditableTextState {
}
}
impl EditableTextState {
/// Validates/santizes incoming text according to the rules of the field.
fn validate_incoming_text<'text>(
&self,
range: &Range<usize>,
mut text_to_insert: &'text str,
) -> &'text str {
// TODO: Apply text sanitization
// single-line fields should prune \n and \r
// fields should be able to provide a max_length or other validations on text-input
let max_length = None::<usize>;
// Decide the effective new text up front (honouring `max_length`).
// This avoids the "apply, then truncate" path which would leave the caret past the end.
if let Some(cap) = max_length {
let existing_len = self.storage().content_utf8().len() - (range.end - range.start);
let room = cap.saturating_sub(existing_len);
text_to_insert = &text_to_insert[..text_to_insert.len().min(room)];
}
text_to_insert
}
/// Internal method to record historical changes and perform text replacement in storage.
/// Selection is moved to the end of the inserted text and ime marked range is cleared.
fn replace_text(&mut self, range: Range<usize>, text_to_insert: &str) {
let end_pos = range.start + text_to_insert.len();
self.record_history(range.clone(), text_to_insert.len());
self.storage.replace_range(range, text_to_insert);
self.selected_range = end_pos..end_pos;
self.marked_range = None;
}
}
// Screen space (text layout engine output) & String space transformers
impl EditableTextState {
/// Returns the utf-8 character position of the start of the line that contains the provided pixel-point.
fn index_for_pixel_point(&self, point: Point<Pixels>, line_height: Pixels) -> usize {
@@ -219,207 +196,6 @@ impl EditableTextState {
storage_len_utf8
}
fn ime_resolve_range(&self, range_utf16: Option<Range<usize>>) -> Range<usize> {
// Use a series of fallbacks to pick the range to operate on.
// Fallback order: IME provided range, active IME marked range, selection
let range = range_utf16.map(|range_utf16| self.storage.utf_range_16to8(&range_utf16));
let range = range.or_else(|| self.marked_range.clone());
let range = range.unwrap_or_else(|| self.selected_range());
let storage_len_utf8 = self.storage().content_utf8().len();
range.start.min(storage_len_utf8)..range.end.min(storage_len_utf8)
}
pub fn replace_text(&mut self, range: &Range<usize>, new_text: &str) {
let storage_len_utf8 = self.storage.content_utf8().len();
let start = range.start.min(storage_len_utf8);
let end = range.end.max(start).min(storage_len_utf8);
self.storage.replace_range(start..end, new_text);
let new_caret = start + new_text.len();
self.selected_range = new_caret..new_caret;
}
pub fn replace_text_in_range_bytes(
&mut self,
range: Range<usize>,
mut text_to_insert: &str,
_cx: &mut Context<Self>,
) {
// TODO: Apply text sanitization
// single-line fields should prune \n and \r
// fields should be able to provide a max_length or other validations on text-input
let max_length = None::<usize>;
// Decide the effective new text up front (honouring `max_length`).
// This avoids the "apply, then truncate" path which would leave the caret past the end.
if let Some(cap) = max_length {
let existing_len = self.storage().content_utf8().len() - (range.end - range.start);
let room = cap.saturating_sub(existing_len);
text_to_insert = &text_to_insert[..text_to_insert.len().min(room)];
}
let end_pos = range.start + text_to_insert.len();
self.record_history(range.clone(), text_to_insert.len());
self.storage.replace_range(range, text_to_insert);
self.selected_range = end_pos..end_pos;
self.marked_range = None;
}
fn ime_mark_text_in_range(&mut self, range: &Range<usize>, text_len: usize) {
self.marked_range = match text_len {
0 => None,
_ => Some(range.start..range.start + text_len),
};
}
fn ime_mark_selected_range(
&mut self,
range_overwritten: &Range<usize>,
new_selected_range_utf16: &Option<Range<usize>>,
text_len: usize,
) {
// NOTE: Differs from yororen-ui
// https://github.com/MeowLynxSea/yororen-ui/blob/346502ac654b77fdaff3be2d7444fca8783acfc9/crates/yororen-ui-core/src/headless/text_input_core.rs#L359-L371
self.selected_range = {
let new_range = new_selected_range_utf16.as_ref();
let new_range = new_range.map(|range_utf16| self.storage.utf_range_16to8(range_utf16));
let new_range = new_range.map(|new_range| {
new_range.start + range_overwritten.start..new_range.end + range_overwritten.start
});
new_range.unwrap_or_else(|| {
range_overwritten.start + text_len..range_overwritten.start + text_len
})
};
}
}
impl EditableTextState {
fn scroll_to_caret(&mut self) {
if self.layout_data.scroll_bounds.is_empty() {
return;
}
let Some(content_size) = self.layout_data.size else {
return;
};
// point will be relative to content_size, and may or may not be within the current scroll_bounds
let point = self.find_point_for_character_position(self.caret_pos());
// this scroll_offset diverges from the rest of gpui, as it is stored in the
// positive real number space (interactivity stores it in the negatives)
let mut scroll_offset = Cow::Borrowed(&self.layout_data.scroll_bounds.origin);
if self.layout_data.scroll_bounds.contains(&point) {
return;
}
// No existing "shift bounds origin so <point> is contained", but that is effectively what this does
if point.x < self.layout_data.scroll_bounds.left() {
scroll_offset.to_mut().x = point.x;
}
if point.y < self.layout_data.scroll_bounds.top() {
scroll_offset.to_mut().y = point.y;
}
let right = self.layout_data.scroll_bounds.right();
if point.x > right {
scroll_offset.to_mut().x += point.x - right;
}
let bottom = self.layout_data.scroll_bounds.bottom();
let point_bottom = point.y + self.layout_data.line_height;
if point_bottom > bottom {
let delta = point_bottom - bottom;
scroll_offset.to_mut().y += delta;
}
if let Cow::Owned(mut offset) = scroll_offset {
offset.x = offset.x.clamp(Pixels::ZERO, content_size.width);
offset.y = offset.y.clamp(Pixels::ZERO, content_size.height);
println!("shift to {offset:?}");
self.layout_data.next_scroll_offset = Some(offset);
}
}
pub fn move_to(&mut self, caret_pos: usize, cx: &mut Context<Self>) {
cx.emit(CaretNotify::PauseBlinking);
let caret_pos = caret_pos.min(self.storage.content_utf8().len());
self.selected_range = caret_pos..caret_pos;
self.scroll_to_caret();
cx.notify();
}
pub fn select_to(&mut self, caret_pos: usize, cx: &mut Context<Self>) {
cx.emit(CaretNotify::PauseBlinking);
let caret_pos = caret_pos.min(self.storage().content_utf8().len());
self.selected_range.start = caret_pos;
self.scroll_to_caret();
cx.notify();
}
pub fn delete_linear(
&mut self,
direction: NavigationDirection,
boundary: TextBoundary,
cx: &mut Context<Self>,
) {
if !self.layout_data.accepts_input {
return;
}
let range = self.selected_range();
let range = match range.is_empty() {
false => range,
true => self
.storage
.range_from_caret(self.caret_pos(), direction, boundary),
};
self.record_history(range.clone(), 0);
self.replace_text(&range, "");
self.marked_range = None;
cx.emit(TextChanged);
cx.notify();
}
pub fn nav_linear(
&mut self,
direction: NavigationDirection,
boundary: TextBoundary,
cx: &mut Context<Self>,
) {
let caret_pos = match self.selected_range.is_empty() {
false => match direction {
NavigationDirection::Back => self.selected_range.start,
NavigationDirection::Forward => self.selected_range.end,
},
true => self
.storage
.offset_from_caret(self.caret_pos(), direction, boundary),
};
self.move_to(caret_pos, cx);
}
pub fn select_document(&mut self, cx: &mut Context<Self>) {
self.selected_range = 0..self.storage.content_utf8().len();
cx.notify();
}
pub fn select_linear(
&mut self,
direction: NavigationDirection,
boundary: TextBoundary,
cx: &mut Context<Self>,
) {
let caret_pos = self
.storage
.offset_from_caret(self.caret_pos(), direction, boundary);
self.select_to(caret_pos, cx);
}
fn line_index_and_point_at_caret(&self, line_height: Pixels) -> (usize, Point<Pixels>) {
if self.layout_data.lines.is_empty() {
return (0, Point::default());
@@ -517,6 +293,133 @@ impl EditableTextState {
}
}
// Internal user action / logical processors
impl EditableTextState {
fn scroll_to_caret(&mut self) {
if self.layout_data.scroll_bounds.is_empty() {
return;
}
let Some(content_size) = self.layout_data.size else {
return;
};
// point will be relative to content_size, and may or may not be within the current scroll_bounds
let point = self.find_point_for_character_position(self.caret_pos());
// this scroll_offset diverges from the rest of gpui, as it is stored in the
// positive real number space (interactivity stores it in the negatives)
let mut scroll_offset = Cow::Borrowed(&self.layout_data.scroll_bounds.origin);
if self.layout_data.scroll_bounds.contains(&point) {
return;
}
// No existing "shift bounds origin so <point> is contained", but that is effectively what this does
if point.x < self.layout_data.scroll_bounds.left() {
scroll_offset.to_mut().x = point.x;
}
if point.y < self.layout_data.scroll_bounds.top() {
scroll_offset.to_mut().y = point.y;
}
let right = self.layout_data.scroll_bounds.right();
if point.x > right {
scroll_offset.to_mut().x += point.x - right;
}
let bottom = self.layout_data.scroll_bounds.bottom();
let point_bottom = point.y + self.layout_data.line_height;
if point_bottom > bottom {
let delta = point_bottom - bottom;
scroll_offset.to_mut().y += delta;
}
if let Cow::Owned(mut offset) = scroll_offset {
offset.x = offset.x.clamp(Pixels::ZERO, content_size.width);
offset.y = offset.y.clamp(Pixels::ZERO, content_size.height);
println!("shift to {offset:?}");
self.layout_data.next_scroll_offset = Some(offset);
}
}
pub fn move_to(&mut self, caret_pos: usize, cx: &mut Context<Self>) {
cx.emit(CaretNotify::PauseBlinking);
let caret_pos = caret_pos.min(self.storage.content_utf8().len());
self.selected_range = caret_pos..caret_pos;
self.scroll_to_caret();
cx.notify();
}
pub fn select_to(&mut self, caret_pos: usize, cx: &mut Context<Self>) {
cx.emit(CaretNotify::PauseBlinking);
let caret_pos = caret_pos.min(self.storage().content_utf8().len());
self.selected_range.start = caret_pos;
self.scroll_to_caret();
cx.notify();
}
pub fn delete_linear(
&mut self,
direction: NavigationDirection,
boundary: TextBoundary,
cx: &mut Context<Self>,
) {
if !self.layout_data.accepts_input {
return;
}
let range = self.selected_range();
let range = match range.is_empty() {
false => range,
true => self
.storage
.range_from_caret(self.caret_pos(), direction, boundary),
};
let storage_len_utf8 = self.storage.content_utf8().len();
let start = range.start.min(storage_len_utf8);
let end = range.end.max(start).min(storage_len_utf8);
self.replace_text(start..end, "");
cx.emit(TextChanged);
cx.notify();
}
pub fn nav_linear(
&mut self,
direction: NavigationDirection,
boundary: TextBoundary,
cx: &mut Context<Self>,
) {
let caret_pos = match self.selected_range.is_empty() {
false => match direction {
NavigationDirection::Back => self.selected_range.start,
NavigationDirection::Forward => self.selected_range.end,
},
true => self
.storage
.offset_from_caret(self.caret_pos(), direction, boundary),
};
self.move_to(caret_pos, cx);
}
pub fn select_document(&mut self, cx: &mut Context<Self>) {
self.selected_range = 0..self.storage.content_utf8().len();
cx.notify();
}
pub fn select_linear(
&mut self,
direction: NavigationDirection,
boundary: TextBoundary,
cx: &mut Context<Self>,
) {
let caret_pos = self
.storage
.offset_from_caret(self.caret_pos(), direction, boundary);
self.select_to(caret_pos, cx);
}
}
// History management
impl EditableTextState {
pub fn history(&self) -> Option<&EditableTextHistory> {
self.history.as_ref()
@@ -561,6 +464,47 @@ impl EditableTextState {
}
}
impl EditableTextState {
fn ime_resolve_range(&self, range_utf16: Option<Range<usize>>) -> Range<usize> {
// Use a series of fallbacks to pick the range to operate on.
// Fallback order: IME provided range, active IME marked range, selection
let range = range_utf16.map(|range_utf16| self.storage.utf_range_16to8(&range_utf16));
let range = range.or_else(|| self.marked_range.clone());
let range = range.unwrap_or_else(|| self.selected_range());
let storage_len_utf8 = self.storage().content_utf8().len();
range.start.min(storage_len_utf8)..range.end.min(storage_len_utf8)
}
fn ime_mark_text_in_range(&mut self, range: &Range<usize>, text_len: usize) {
self.marked_range = match text_len {
0 => None,
_ => Some(range.start..range.start + text_len),
};
}
fn ime_mark_selected_range(
&mut self,
range_overwritten: &Range<usize>,
new_selected_range_utf16: &Option<Range<usize>>,
text_len: usize,
) {
// NOTE: Differs from yororen-ui
// https://github.com/MeowLynxSea/yororen-ui/blob/346502ac654b77fdaff3be2d7444fca8783acfc9/crates/yororen-ui-core/src/headless/text_input_core.rs#L359-L371
self.selected_range = {
let new_range = new_selected_range_utf16.as_ref();
let new_range = new_range.map(|range_utf16| self.storage.utf_range_16to8(range_utf16));
let new_range = new_range.map(|new_range| {
new_range.start + range_overwritten.start..new_range.end + range_overwritten.start
});
new_range.unwrap_or_else(|| {
range_overwritten.start + text_len..range_overwritten.start + text_len
})
};
}
}
// IME handler
impl EntityInputHandler for EditableTextState {
fn text_for_range(
&mut self,
@@ -612,7 +556,8 @@ impl EntityInputHandler for EditableTextState {
cx: &mut Context<Self>,
) {
let range_utf8 = self.ime_resolve_range(range_utf16);
self.replace_text_in_range_bytes(range_utf8, text_to_insert, cx);
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);
cx.notify();
@@ -627,7 +572,8 @@ impl EntityInputHandler for EditableTextState {
cx: &mut Context<Self>,
) {
let range = self.ime_resolve_range(range_utf16);
self.replace_text_in_range_bytes(range.clone(), text_to_insert, cx);
let text_to_insert = self.validate_incoming_text(&range, text_to_insert);
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);
@@ -700,6 +646,7 @@ impl EntityInputHandler for EditableTextState {
}
}
// Input Action handler
use super::{actions::*, history::HistoryKind};
impl<'app> EditableTextActionHandler<Context<'app, Self>> for EditableTextState {
fn escape(&mut self, _: &Escape, window: &mut Window, cx: &mut Context<'app, Self>) {
@@ -906,7 +853,7 @@ impl<'app> EditableTextActionHandler<Context<'app, Self>> for EditableTextState
// Cut selected text
let slice = &self.storage.content_utf8()[self.selected_range.clone()];
cx.write_to_clipboard(ClipboardItem::new_string(slice.to_string()));
self.replace_text_in_range_bytes(self.selected_range.clone(), "", cx);
self.replace_text(self.selected_range.clone(), "");
} else {
// No selection: cut the entire current line (including newline)
let caret = self.caret_pos();
@@ -936,7 +883,7 @@ impl<'app> EditableTextActionHandler<Context<'app, Self>> for EditableTextState
let slice = &self.storage.content_utf8()[self.selected_range.clone()];
cx.write_to_clipboard(ClipboardItem::new_string(slice.to_string()));
self.replace_text_in_range_bytes(self.selected_range.clone(), "", cx);
self.replace_text(self.selected_range.clone(), "");
}
cx.emit(TextChanged);
cx.notify();
@@ -957,7 +904,10 @@ impl<'app> EditableTextActionHandler<Context<'app, Self>> for EditableTextState
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
return;
};
self.replace_text_in_range_bytes(self.ime_resolve_range(None), &text, cx);
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);
cx.notify();
}
@@ -13,6 +13,8 @@ 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>>>);
@@ -42,7 +44,10 @@ impl InitStorage {
}
}
/// Abstraction around any text medium that can be used as the storage for EditableText elements.
pub trait UnicodeTextStorage {
/// Returns the version/generation of the content, which should be incremented ever time the
/// content is changed so that rendering elements can reprocess the contents via the text layout engine.
fn version(&self) -> u16;
/// Returns a reference to the utf8 string.
@@ -51,6 +56,7 @@ pub trait UnicodeTextStorage {
/// Returns the UTF-16 length of the content.
fn len_utf16(&self) -> usize;
/// Replace contents within the provided range with the given str slice.
fn replace_range(&mut self, range: Range<usize>, text: &str);
fn utf_offset_8to16(&self, pos_uft8: usize) -> usize {
@@ -235,6 +241,9 @@ pub trait UnicodeTextStorage {
}
}
/// [`UnicodeTextStorage`] implementation for std String.
/// Not going to be the most performant, especially for large document text.
/// Its a decent default for editable text fields though.
#[derive(Clone, Default)]
pub struct StringStorage {
value: String,