organize input element types and implementation into dedicated files

This commit is contained in:
temportalflux
2026-07-11 09:31:07 -04:00
parent eb8f90a310
commit 13676cf649
10 changed files with 2907 additions and 2859 deletions
+14 -4
View File
@@ -1,5 +1,15 @@
mod element;
pub use element::*;
pub mod actions;
mod colors;
mod cursor;
pub use cursor::*;
mod element;
mod history;
mod paint;
mod state;
mod state_input_handler;
pub(self) mod unicode;
pub use colors::*;
pub(self) use cursor::*;
pub use element::*;
pub(self) use history::*;
pub use state::*;
+157
View File
@@ -0,0 +1,157 @@
/// The key context used for input element keybindings.
pub const DEFAULT_INPUT_CONTEXT: &str = "Input";
gpui::actions!(
actions,
[
/// Delete the character before the cursor.
Backspace,
/// Delete the character after the cursor.
Delete,
/// Blur focus from the input.
Escape,
/// Delete the word before the cursor.
DeleteWordLeft,
/// Delete the word after the cursor.
DeleteWordRight,
/// Delete from the cursor to the beginning of the line.
DeleteToBeginningOfLine,
/// Delete from the cursor to the end of the line.
DeleteToEndOfLine,
/// Insert a tab character at the cursor position.
Tab,
/// Move the cursor one character to the left.
Left,
/// Move the cursor one character to the right.
Right,
/// Move the cursor up one visual line.
Up,
/// Move the cursor down one visual line.
Down,
/// Extend selection one character to the left.
SelectLeft,
/// Extend selection one character to the right.
SelectRight,
/// Extend selection up one visual line.
SelectUp,
/// Extend selection down one visual line.
SelectDown,
/// Select all text content.
SelectAll,
/// Move cursor to the start of the current line.
Home,
/// Move cursor to the end of the current line.
End,
/// Extend selection to the beginning of the content.
SelectToBeginning,
/// Extend selection to the end of the content.
SelectToEnd,
/// Move cursor to the beginning of the content.
MoveToBeginning,
/// Move cursor to the end of the content.
MoveToEnd,
/// Paste from clipboard at the cursor position.
Paste,
/// Cut selected text to clipboard.
Cut,
/// Copy selected text to clipboard.
Copy,
/// Insert a newline at the cursor position.
Enter,
/// Move cursor one word to the left.
WordLeft,
/// Move cursor one word to the right.
WordRight,
/// Extend selection one word to the left.
SelectWordLeft,
/// Extend selection one word to the right.
SelectWordRight,
/// Undo the last edit.
Undo,
/// Redo the last undone edit.
Redo,
]
);
pub fn default_bindings() -> gpui::ActionBindingCollection {
let mut bindings = gpui::ActionBindingCollection::default();
#[cfg(target_os = "macos")]
{
bindings = bindings
.with::<Backspace>("backspace")
.with::<Delete>("delete")
.with::<DeleteWordLeft>("alt-backspace")
.with::<DeleteWordRight>("alt-delete")
.with::<DeleteToBeginningOfLine>("cmd-backspace")
.with::<DeleteToEndOfLine>("ctrl-k")
.with::<Tab>("tab")
.with::<Enter>("enter")
.with::<Left>("left")
.with::<Right>("right")
.with::<Up>("up")
.with::<Down>("down")
.with::<SelectLeft>("shift-left")
.with::<SelectRight>("shift-right")
.with::<SelectUp>("shift-up")
.with::<SelectDown>("shift-down")
.with::<SelectAll>("cmd-a")
// Mac keyboards don't have Home/End keys, so cmd-left/right are standard
.with::<Home>("cmd-left")
.with::<End>("cmd-right")
.with::<MoveToBeginning>("cmd-up")
.with::<MoveToEnd>("cmd-down")
.with::<SelectToBeginning>("cmd-shift-up")
.with::<SelectToEnd>("cmd-shift-down")
.with::<WordLeft>("alt-left")
.with::<WordRight>("alt-right")
.with::<SelectWordLeft>("alt-shift-left")
.with::<SelectWordRight>("alt-shift-right")
.with::<Copy>("cmd-c")
.with::<Cut>("cmd-x")
.with::<Paste>("cmd-v")
.with::<Undo>("cmd-z")
.with::<Redo>("cmd-shift-z")
.with::<Escape>("escape");
}
#[cfg(not(target_os = "macos"))]
{
bindings = bindings
.with::<Backspace>("backspace")
.with::<Delete>("delete")
.with::<DeleteWordLeft>("ctrl-backspace")
.with::<DeleteWordRight>("ctrl-delete")
.with::<DeleteToBeginningOfLine>("ctrl-shift-backspace")
.with::<DeleteToEndOfLine>("ctrl-shift-delete")
.with::<Tab>("tab")
.with::<Enter>("enter")
.with::<Left>("left")
.with::<Right>("right")
.with::<Up>("up")
.with::<Down>("down")
.with::<SelectLeft>("shift-left")
.with::<SelectRight>("shift-right")
.with::<SelectUp>("shift-up")
.with::<SelectDown>("shift-down")
.with::<SelectAll>("ctrl-a")
.with::<Home>("home")
.with::<End>("end")
.with::<MoveToBeginning>("ctrl-home")
.with::<MoveToEnd>("ctrl-end")
.with::<SelectToBeginning>("ctrl-shift-home")
.with::<SelectToEnd>("ctrl-shift-end")
.with::<WordLeft>("ctrl-left")
.with::<WordRight>("ctrl-right")
.with::<SelectWordLeft>("ctrl-shift-left")
.with::<SelectWordRight>("ctrl-shift-right")
.with::<Copy>("ctrl-c")
.with::<Cut>("ctrl-x")
.with::<Paste>("ctrl-v")
.with::<Undo>("ctrl-z")
.with::<Redo>("ctrl-shift-z")
.with::<Escape>("escape");
}
bindings
}
+18
View File
@@ -0,0 +1,18 @@
use gpui::Hsla;
#[derive(Clone, Copy, Debug)]
pub struct PaintColors {
pub selection: Hsla,
pub cursor: Hsla,
pub placeholder: Hsla,
}
impl Default for PaintColors {
fn default() -> Self {
Self {
selection: Hsla::blue().opacity(0.2),
cursor: Hsla::white().opacity(0.8),
placeholder: gpui::hsla(0.6, 0.6, 0.6, 1.0),
}
}
}
+3 -5
View File
@@ -1,6 +1,9 @@
use gpui::Context;
use std::time::Duration;
/// Default interval for cursor blinking.
pub const DEFAULT_BLINK_INTERVAL: Duration = Duration::from_millis(500);
/// Manages the blinking state of a text cursor.
///
/// The cursor blinks at a configurable interval when enabled. Blinking can be
@@ -32,11 +35,6 @@ impl CursorBlink {
self.visible
}
/// Returns whether blinking is currently active.
pub fn is_active(&self) -> bool {
self.active
}
/// Activates cursor blinking.
///
/// When activated, the cursor will alternate between visible and hidden
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
use std::{
ops::Range,
time::{Duration, Instant},
};
/// Maximum number of history entries to keep.
pub const MAX_HISTORY_LEN: usize = 1000;
/// Default interval for grouping consecutive edits into a single undo entry.
pub const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300);
/// A patch-based history entry for memory-efficient undo/redo operations.
/// Instead of storing the full content, we store only the change needed to reverse the edit.
#[derive(Clone, Debug)]
pub struct HistoryEntry {
/// The byte range that was modified (after the edit, for undo; before the edit, for redo).
pub range: Range<usize>,
/// The text that was replaced (to restore on undo).
pub old_text: String,
/// The length of the new text that replaced old_text (to know how much to remove on undo).
pub new_text_len: usize,
/// The selection range before the edit.
pub selected_range: Range<usize>,
/// Whether the selection was reversed before the edit.
pub selection_reversed: bool,
/// Timestamp for grouping consecutive edits.
pub timestamp: Instant,
}
impl HistoryEntry {
/// Apply this patch to undo an edit, returning the reverse patch for redo.
pub fn apply_undo(&self, content: &mut String) -> HistoryEntry {
let undo_start = self.range.start;
let undo_end = (self.range.start + self.new_text_len).min(content.len());
// Capture what we're about to remove (the "new" text that was inserted)
let removed_text = content[undo_start..undo_end].to_string();
// Replace with the old text
content.replace_range(undo_start..undo_end, &self.old_text);
// Return reverse patch for redo
HistoryEntry {
range: undo_start..undo_start + self.old_text.len(),
old_text: removed_text,
new_text_len: self.old_text.len(),
selected_range: self.selected_range.clone(),
selection_reversed: self.selection_reversed,
timestamp: self.timestamp,
}
}
/// Apply this patch to redo an edit, returning the reverse patch for undo.
pub fn apply_redo(&self, content: &mut String) -> HistoryEntry {
// Redo is the same operation as undo - we're reversing the undo
self.apply_undo(content)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,229 @@
use super::unicode::UnicodeString;
use crate::input::InputStateEvent;
use gpui::{Bounds, Context, EntityInputHandler, Pixels, Point, UTF16Selection, Window, point, px};
use std::ops::Range;
impl EntityInputHandler for super::InputState {
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> {
let range = self.utf_range_16to8(&range_utf16);
let clamped_range =
range.start.min(self.content().len())..range.end.min(self.content().len());
adjusted_range.replace(self.utf_range_8to16(&clamped_range));
Some(self.content()[clamped_range].to_string())
}
fn selected_text_range(
&mut self,
_ignore_disabled_input: bool,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<UTF16Selection> {
Some(UTF16Selection {
range: self.utf_range_8to16(&self.selected_range),
reversed: self.selection_reversed,
})
}
fn marked_text_range(
&self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Range<usize>> {
self.marked_range
.as_ref()
.map(|range| self.utf_range_8to16(range))
}
fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
self.marked_range = None;
}
fn replace_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let range = range_utf16
.as_ref()
.map(|range_utf16| self.utf_range_16to8(range_utf16))
.or(self.marked_range.clone())
.unwrap_or(self.selected_range.clone());
let range = range.start.min(self.content().len())..range.end.min(self.content().len());
// Strip newlines for single-line input
let sanitized_text;
let text_to_insert = if self.multiline {
new_text
} else {
sanitized_text = new_text.replace('\n', " ").replace('\r', "");
&sanitized_text
};
// Record patch for undo before modifying content
self.push_undo_patch(range.clone(), text_to_insert.len());
// Update cached UTF-16 length incrementally if available
if let Some(cached_len) = self.cached_utf16_len {
let removed_utf16_len: usize = self.content()[range.clone()]
.chars()
.map(|c| c.len_utf16())
.sum();
let added_utf16_len: usize = text_to_insert.chars().map(|c| c.len_utf16()).sum();
self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len);
}
self.content_mut()
.replace_range(range.clone(), text_to_insert);
self.selected_range =
range.start + text_to_insert.len()..range.start + text_to_insert.len();
self.marked_range.take();
self.needs_layout = true;
self.pause_cursor_blink(cx);
cx.emit(InputStateEvent::TextChanged);
cx.notify();
}
fn replace_and_mark_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
new_selected_range_utf16: Option<Range<usize>>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let range = range_utf16
.as_ref()
.map(|range_utf16| self.utf_range_16to8(range_utf16))
.or(self.marked_range.clone())
.unwrap_or(self.selected_range.clone());
let range = range.start.min(self.content().len())..range.end.min(self.content().len());
// Strip newlines for single-line input
let sanitized_text;
let text_to_insert = if self.multiline {
new_text
} else {
sanitized_text = new_text.replace('\n', " ").replace('\r', "");
&sanitized_text
};
// Update cached UTF-16 length incrementally if available
if let Some(cached_len) = self.cached_utf16_len {
let removed_utf16_len: usize = self.content()[range.clone()]
.chars()
.map(|c| c.len_utf16())
.sum();
let added_utf16_len: usize = text_to_insert.chars().map(|c| c.len_utf16()).sum();
self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len);
}
self.content_mut()
.replace_range(range.clone(), text_to_insert);
if !text_to_insert.is_empty() {
self.marked_range = Some(range.start..range.start + text_to_insert.len());
} else {
self.marked_range = None;
}
self.selected_range = new_selected_range_utf16
.as_ref()
.map(|range_utf16| self.utf_range_16to8(range_utf16))
.map(|new_range| new_range.start + range.start..new_range.end + range.start)
.unwrap_or_else(|| {
range.start + text_to_insert.len()..range.start + text_to_insert.len()
});
self.needs_layout = true;
cx.emit(InputStateEvent::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>> {
let range = self.utf_range_16to8(&range_utf16);
for line in &self.line_layouts {
if line.text_range.is_empty() {
if range.start == line.text_range.start {
return Some(Bounds::from_corners(
point(bounds.left(), bounds.top() + line.y_offset),
point(
bounds.left() + px(4.),
bounds.top() + line.y_offset + self.line_height,
),
));
}
} else if line.text_range.contains(&range.start) {
if let Some(wrapped) = &line.wrapped_line {
let local_start = range.start - line.text_range.start;
let local_end = (range.end - line.text_range.start).min(wrapped.text.len());
let start_pos = wrapped
.position_for_index(local_start, self.line_height)
.unwrap_or(point(px(0.), px(0.)));
let end_pos = wrapped
.position_for_index(local_end, self.line_height)
.unwrap_or_else(|| {
let last_line_y =
self.line_height * (line.visual_line_count - 1) as f32;
point(wrapped.width(), last_line_y)
});
let start_visual_line = (start_pos.y / self.line_height).floor() as usize;
let end_visual_line = (end_pos.y / self.line_height).floor() as usize;
if start_visual_line == end_visual_line {
return Some(Bounds::from_corners(
point(
bounds.left() + start_pos.x,
bounds.top() + line.y_offset + start_pos.y,
),
point(
bounds.left() + end_pos.x,
bounds.top() + line.y_offset + start_pos.y + self.line_height,
),
));
} else {
return Some(Bounds::from_corners(
point(
bounds.left() + start_pos.x,
bounds.top() + line.y_offset + start_pos.y,
),
point(
bounds.left() + wrapped.width(),
bounds.top() + line.y_offset + start_pos.y + self.line_height,
),
));
}
}
}
}
None
}
fn character_index_for_point(
&mut self,
point: Point<Pixels>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<usize> {
let index = self.index_for_position(point);
Some(self.utf_offset_8to16(index))
}
}
+85
View File
@@ -0,0 +1,85 @@
use std::ops::Range;
pub trait UnicodeString {
fn len_utf16_cached(&self) -> Option<usize>;
/// Returns a reference to the utf8 string.
fn content_utf8(&self) -> &str;
/// Returns the UTF-16 length of the content.
fn len_utf16(&self) -> usize;
fn utf_offset_8to16(&self, pos_uft8: usize) -> usize {
// Fast path: if offset is 0, return 0
if pos_uft8 == 0 {
return 0;
}
// Fast path: if offset is at or past end, return cached length
if pos_uft8 >= self.content_utf8().len() {
return self.len_utf16();
}
let mut pos_utf16 = 0;
let mut counter_utf8 = 0;
for character in self.content_utf8().chars() {
if counter_utf8 >= pos_uft8 {
break;
}
counter_utf8 += character.len_utf8();
pos_utf16 += character.len_utf16();
}
pos_utf16
}
fn utf_offset_16to8(&self, pos_utf16: usize) -> usize {
// Fast path: if offset is 0, return 0
if pos_utf16 == 0 {
return 0;
}
// Fast path: if we have cached length and offset is at or past end
if let Some(utf16_len) = self.len_utf16_cached() {
if pos_utf16 >= utf16_len {
return self.content_utf8().len();
}
}
let mut pos_utf8 = 0;
let mut counter_utf16 = 0;
for character in self.content_utf8().chars() {
if counter_utf16 >= pos_utf16 {
break;
}
counter_utf16 += character.len_utf16();
pos_utf8 += character.len_utf8();
}
pos_utf8.min(self.content_utf8().len())
}
fn utf_range_8to16(&self, range_utf8: &Range<usize>) -> Range<usize> {
self.utf_offset_8to16(range_utf8.start)..self.utf_offset_8to16(range_utf8.end)
}
fn utf_range_16to8(&self, range_utf16: &Range<usize>) -> Range<usize> {
self.utf_offset_16to8(range_utf16.start)..self.utf_offset_16to8(range_utf16.end)
}
}
impl UnicodeString for super::InputState {
fn len_utf16_cached(&self) -> Option<usize> {
self.cached_utf16_len
}
fn content_utf8(&self) -> &str {
self.content()
}
fn len_utf16(&self) -> usize {
if let Some(len) = self.cached_utf16_len {
return len;
}
self.content_utf8().chars().map(|c| c.len_utf16()).sum()
}
}