outline reimplemented ime operations

This commit is contained in:
temportalflux
2026-07-11 09:31:07 -04:00
parent bde62d95ea
commit dd9d4585d6
8 changed files with 353 additions and 11 deletions
+11 -2
View File
@@ -1,7 +1,16 @@
mod actions;
mod state_field;
mod input_element;
mod input_state;
pub mod notify;
mod shared_state;
mod storage;
mod text_area_element;
mod text_area_state;
pub use actions::*;
pub use state_field::*;
pub use input_element::*;
pub use input_state::*;
pub use shared_state::*;
pub use storage::*;
pub use text_area_element::*;
pub use text_area_state::*;
@@ -0,0 +1,3 @@
use gpui::ElementId;
pub fn input(id: impl Into<ElementId>) {}
@@ -0,0 +1,103 @@
use crate::editable_text::{TextInputStateBase, notify::TextChanged};
use gpui::{
Bounds, Context, EntityInputHandler, EventEmitter, Pixels, Point, UTF16Selection, Window,
};
use std::ops::Range;
pub struct TextInputState {
internal: TextInputStateBase,
}
impl EventEmitter<TextChanged> for TextInputState {}
impl EntityInputHandler for TextInputState {
fn text_for_range(
&mut self,
range_utf16: Range<usize>,
adjusted_range: &mut Option<Range<usize>>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<String> {
self.internal
.ime_text_for_range(range_utf16, adjusted_range)
}
fn selected_text_range(
&mut self,
ignore_disabled_input: bool,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<UTF16Selection> {
self.internal.ime_selected_text_range(ignore_disabled_input)
}
fn marked_text_range(
&self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Range<usize>> {
self.internal.ime_marked_text_range()
}
fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
self.internal.ime_unmark_text();
}
fn replace_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
text_to_insert: &str,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let range_utf8 = self.internal.ime_resolve_range(range_utf16);
self.internal
.replace_text_in_range_bytes(range_utf8, text_to_insert);
//self.mark_layout_dirty();
//cx.emit(CursorTrigger::PauseBlinkingForUserAction);
cx.emit(TextChanged);
cx.notify();
}
fn replace_and_mark_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
text_to_insert: &str,
new_selected_range_utf16: Option<Range<usize>>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let range = self.internal.ime_resolve_range(range_utf16);
self.internal
.replace_text_in_range_bytes(range.clone(), text_to_insert);
self.internal
.ime_mark_text_in_range(&range, text_to_insert.len());
self.internal.ime_mark_selected_range(
&range,
&new_selected_range_utf16,
text_to_insert.len(),
);
//self.mark_layout_dirty();
cx.emit(TextChanged);
cx.notify();
}
fn bounds_for_range(
&mut self,
range_utf16: Range<usize>,
bounds: Bounds<Pixels>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
unimplemented!()
}
fn character_index_for_point(
&mut self,
point: Point<Pixels>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<usize> {
unimplemented!()
}
}
@@ -0,0 +1,49 @@
use std::{ops::Range, time::Instant};
use crate::editable_text::UnicodeTextStorage;
pub struct TextChanged;
pub struct TextHistoryPushed {
pub timestamp: Instant,
pub modified_range: Range<usize>,
pub text_payload: String,
pub new_length: usize,
pub selected_range: Range<usize>,
}
impl TextHistoryPushed {
pub fn new(
range: Range<usize>,
new_length: usize,
storage: impl UnicodeTextStorage,
selected_range: Range<usize>,
) -> Self {
let timestamp = Instant::now();
let modified_range = range.start..range.start + new_length;
// NOTE: not performant to allocate a new text payload if the event doesnt
// need to be logged (based on timestamp). Should consider a more robust way to access
// the storage only if it absolutely needs to be cloned from.
let text_payload = storage.content_utf8()[range].to_string();
Self {
timestamp,
modified_range,
text_payload,
new_length,
selected_range,
}
}
pub fn convert_to_redo(self, content: &str) -> Self {
let undo_start = self.modified_range.start;
let undo_end = (self.modified_range.start + self.new_length).min(content.len());
let text_payload = content[undo_start..undo_end].to_string();
let new_length = self.text_payload.len();
Self {
timestamp: self.timestamp,
modified_range: undo_start..undo_start + self.text_payload.len(),
text_payload,
new_length,
selected_range: self.selected_range,
}
}
}
@@ -0,0 +1,180 @@
use crate::editable_text::UnicodeTextStorage;
use gpui::{App, FocusHandle, Focusable, NavigationDirection, Pixels, Point, UTF16Selection};
use std::ops::Range;
pub struct TextInputStateBase {
storage: Box<dyn UnicodeTextStorage>,
/// 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).
///
/// NOTE: because each input has its own selection state, its trivial for users to have multiple selections active across multiple inputs at the same time.
/// This could be considered undesirable behavior, and could prompt the question of whether there should be a mechanism to clear selection when focus is lost.
selected_range: Range<usize>,
/// The utf-8 character range of `storage` which is being composed by IME
marked_range: Option<Range<usize>>,
/// True while the user is in the act of highlighting a section of the text (e.g. during mouse pressed & dragging).
is_selecting: bool,
/// The last ui location relative to the element that the user clicked. Used to filter when a user clicks multiple times in the same area.
last_click_position: Option<Point<Pixels>>,
/// The number of times the user has clicked `last_click_position`. Used to determine which click behavior to trigger, depending on single, double, or triple clicks.
click_count: usize,
focus_handle: FocusHandle,
}
impl Focusable for TextInputStateBase {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl TextInputStateBase {
/// Creates a new `Input` with the specified multiline setting.
/// Cursor blinking is enabled by default.
pub fn new(storage: impl Into<Box<dyn UnicodeTextStorage>>, cx: &mut App) -> Self {
Self {
storage: storage.into(),
selected_range: 0..0,
marked_range: None,
is_selecting: false,
last_click_position: None,
click_count: 0,
focus_handle: cx.focus_handle(),
}
}
pub fn storage(&self) -> &Box<dyn UnicodeTextStorage> {
&self.storage
}
/// Returns the utf-8 character range that is currently selected within the current state of the text.
/// Internally converts the stored direction-aware range into a canonical range.
pub fn selected_range(&self) -> Range<usize> {
self.selected_range.start.min(self.selected_range.end)
..self.selected_range.start.max(self.selected_range.end)
}
pub fn selection_direction(&self) -> Option<NavigationDirection> {
match self.selected_range.start.cmp(&self.selected_range.end) {
std::cmp::Ordering::Less => Some(NavigationDirection::Forward),
std::cmp::Ordering::Equal => None,
std::cmp::Ordering::Greater => Some(NavigationDirection::Back),
}
}
pub fn caret_pos(&self) -> usize {
self.selected_range.start
}
}
impl TextInputStateBase {
pub fn ime_text_for_range(
&self,
range_utf16: Range<usize>,
adjusted_range: &mut Option<Range<usize>>,
) -> Option<String> {
let range = self.storage.utf_range_16to8(&range_utf16);
let storage_len_utf8 = self.storage.content_utf8().len();
let clamped_range = range.start.min(storage_len_utf8)..range.end.min(storage_len_utf8);
adjusted_range.replace(self.storage.utf_range_8to16(&clamped_range));
Some(self.storage.content_utf8()[clamped_range].to_string())
}
pub fn ime_selected_text_range(&self, _ignore_disabled_input: bool) -> Option<UTF16Selection> {
let selection_range = self.selected_range();
let direction = self.selection_direction();
Some(UTF16Selection {
range: self.storage.utf_range_8to16(&selection_range),
reversed: direction == Some(NavigationDirection::Back),
})
}
pub fn ime_marked_text_range(&self) -> Option<Range<usize>> {
self.marked_range
.as_ref()
.map(|range| self.storage.utf_range_8to16(range))
}
pub fn ime_unmark_text(&mut self) {
self.marked_range = None;
}
pub 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, start: usize, end: usize, new_text: &str) {
let storage_len_utf8 = self.storage.content_utf8().len();
let start = start.min(storage_len_utf8);
let end = 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) {
// 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)];
}
// TODO: Push history diff
// self.push_undo_patch(range.clone(), text_to_insert.len());
self.storage.replace_range(range, text_to_insert);
self.marked_range = None;
// TODO: caller emits events
}
pub 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),
};
}
pub 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
})
};
}
}
@@ -1,4 +1,3 @@
use gpui::SharedString;
use std::ops::Range;
use unicode_segmentation::UnicodeSegmentation;
@@ -9,6 +8,8 @@ pub trait UnicodeTextStorage {
/// Returns the UTF-16 length of the content.
fn len_utf16(&self) -> usize;
fn replace_range(&mut self, range: Range<usize>, text: &str);
fn utf_offset_8to16(&self, pos_uft8: usize) -> usize {
// Fast path: if offset is 0, return 0
if pos_uft8 == 0 {
@@ -148,14 +149,8 @@ impl UnicodeTextStorage for String {
fn len_utf16(&self) -> usize {
self.len()
}
}
impl UnicodeTextStorage for SharedString {
fn content_utf8(&self) -> &str {
self.as_str()
}
fn len_utf16(&self) -> usize {
self.len()
fn replace_range(&mut self, range: Range<usize>, text: &str) {
self.replace_range(range, &text);
}
}
@@ -0,0 +1,3 @@
use gpui::ElementId;
pub fn text_area(id: impl Into<ElementId>) {}