diff --git a/Cargo.lock b/Cargo.lock index 6864164..a9a7358 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1171,6 +1171,7 @@ dependencies = [ "flume 0.11.1", "gpui", "gpui_tokio", + "input", "itertools 0.13.0", "linkify", "log", @@ -3675,6 +3676,22 @@ dependencies = [ "generic-array", ] +[[package]] +name = "input" +version = "1.0.0-beta4" +dependencies = [ + "anyhow", + "common", + "gpui", + "serde", + "serde_json", + "smol", + "theme", + "ui", + "unicode-bidi", + "unicode-segmentation", +] + [[package]] name = "instant" version = "0.1.13" diff --git a/crates/chat_ui/Cargo.toml b/crates/chat_ui/Cargo.toml index 658b111..2a06c80 100644 --- a/crates/chat_ui/Cargo.toml +++ b/crates/chat_ui/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true [dependencies] state = { path = "../state" } ui = { path = "../ui" } +input = { path = "../input" } theme = { path = "../theme" } common = { path = "../common" } person = { path = "../person" } diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index f309f86..314794d 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -13,6 +13,7 @@ use gpui::{ StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, WeakEntity, Window, deferred, div, img, list, px, red, relative, svg, white, }; +use input::{Input, InputState, InputStateEvent, input}; use itertools::Itertools; use nostr_sdk::prelude::*; use person::{Person, PersonRegistry}; @@ -24,7 +25,6 @@ use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; -use ui::input::{InputEvent, InputState, TextInput}; use ui::menu::DropdownMenu; use ui::notification::Notification; use ui::scroll::Scrollbar; @@ -116,15 +116,17 @@ impl ChatPanel { // Define input state let input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder(format!("Message {}", name)) - .auto_grow(1, 20) - .prevent_new_line_on_enter() - .clean_on_escape() + let mut this = InputState::new_multiline(cx); + this.set_placeholder(format!("Message {}", name), cx); + this }); // Define subject input state - let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject...")); + let subject_input = cx.new(|cx| { + let mut this = InputState::new(cx); + this.set_placeholder("New subject...", cx); + this + }); let subject_bar = cx.new(|_cx| false); // Define subscriptions @@ -133,7 +135,7 @@ impl ChatPanel { subscriptions.push( // Subscribe the chat input event cx.subscribe_in(&input, window, move |this, _input, event, window, cx| { - if let InputEvent::PressEnter { .. } = event { + if let InputStateEvent::Enter = event { this.send_text_message(window, cx); }; }), @@ -145,7 +147,7 @@ impl ChatPanel { &subject_input, window, move |this, _input, event, window, cx| { - if let InputEvent::PressEnter { .. } = event { + if let InputStateEvent::Enter = event { this.change_subject(window, cx); }; }, @@ -294,7 +296,7 @@ impl ChatPanel { /// Get user input content and merged all attachments if available fn get_input_value(&self, cx: &Context) -> String { // Get input's value - let mut content = self.input.read(cx).value().trim().to_string(); + let mut content = self.input.read(cx).content().trim().to_string(); // Get all attaches and merge its with message let attachments = self.attachments.read(cx); @@ -317,7 +319,7 @@ impl ChatPanel { } fn change_subject(&mut self, _window: &mut Window, cx: &mut Context) { - let subject = self.subject_input.read(cx).value(); + let subject = self.subject_input.read(cx).content().to_string(); self.room .update(cx, |this, cx| { @@ -414,9 +416,9 @@ impl ChatPanel { /// Clear the input field, attachments, and replies /// /// Only run after sending a message - fn clear(&mut self, window: &mut Window, cx: &mut Context) { + fn clear(&mut self, _window: &mut Window, cx: &mut Context) { self.input.update(cx, |this, cx| { - this.set_value("", window, cx); + this.set_content("", cx); }); self.attachments.update(cx, |this, cx| { this.clear(); @@ -1497,12 +1499,7 @@ impl Render for ChatPanel { .gap_2() .border_b_1() .border_color(cx.theme().border) - .child( - TextInput::new(&self.subject_input) - .text_sm() - .small() - .bordered(false), - ) + .child(input(&self.subject_input, cx).text_sm()) .child( Button::new("change") .icon(IconName::CheckCircle) @@ -1553,12 +1550,7 @@ impl Render for ChatPanel { this.upload(window, cx); })), ) - .child( - TextInput::new(&self.input) - .appearance(false) - .text_sm() - .flex_1(), - ) + .child(input(&self.input, cx).text_sm().flex_1()) .child( h_flex() .pl_1() diff --git a/crates/input/Cargo.toml b/crates/input/Cargo.toml new file mode 100644 index 0000000..47a4990 --- /dev/null +++ b/crates/input/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "input" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +common = { path = "../common" } +theme = { path = "../theme" } +ui = { path = "../ui" } + +gpui.workspace = true +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +smol.workspace = true + +unicode-segmentation = "1.12.0" +unicode-bidi = "0.3" diff --git a/crates/input/src/bidi.rs b/crates/input/src/bidi.rs new file mode 100644 index 0000000..14bd24e --- /dev/null +++ b/crates/input/src/bidi.rs @@ -0,0 +1,166 @@ +use unicode_bidi::{BidiClass, bidi_class}; + +/// Text direction for bidirectional text support. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TextDirection { + /// Left-to-right text direction (default for Latin, Greek, Cyrillic, etc.) + #[default] + Ltr, + /// Right-to-left text direction (for Arabic, Hebrew, etc.) + Rtl, +} + +impl TextDirection { + /// Returns true if this is left-to-right direction. + pub fn is_ltr(self) -> bool { + matches!(self, TextDirection::Ltr) + } + + /// Returns true if this is right-to-left direction. + pub fn is_rtl(self) -> bool { + matches!(self, TextDirection::Rtl) + } +} + +/// Detects the base direction of text using the first strong directional character. +/// +/// This follows the Unicode Bidirectional Algorithm (UBA) rule P2/P3: +/// - Find the first character with a strong directional type (L, R, or AL) +/// - If it's L, the paragraph direction is LTR +/// - If it's R or AL, the paragraph direction is RTL +/// - If no strong character is found, defaults to LTR +/// +/// # Examples +/// +/// ```ignore +/// use gpui::input::bidi::detect_base_direction; +/// +/// // English text is LTR +/// assert!(detect_base_direction("Hello world").is_ltr()); +/// +/// // Arabic text is RTL +/// assert!(detect_base_direction("مرحبا").is_rtl()); +/// +/// // Hebrew text is RTL +/// assert!(detect_base_direction("שלום").is_rtl()); +/// +/// // Mixed text uses first strong character +/// assert!(detect_base_direction("Hello مرحبا").is_ltr()); +/// assert!(detect_base_direction("مرحبا Hello").is_rtl()); +/// +/// // Empty or neutral-only text defaults to LTR +/// assert!(detect_base_direction("").is_ltr()); +/// assert!(detect_base_direction("123").is_ltr()); +/// ``` +pub fn detect_base_direction(text: &str) -> TextDirection { + for c in text.chars() { + match bidi_class(c) { + BidiClass::L => return TextDirection::Ltr, + BidiClass::R | BidiClass::AL => return TextDirection::Rtl, + _ => continue, + } + } + TextDirection::Ltr +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_string() { + assert_eq!(detect_base_direction(""), TextDirection::Ltr); + } + + #[test] + fn test_whitespace_only() { + assert_eq!(detect_base_direction(" "), TextDirection::Ltr); + } + + #[test] + fn test_numbers_only() { + assert_eq!(detect_base_direction("12345"), TextDirection::Ltr); + } + + #[test] + fn test_punctuation_only() { + assert_eq!(detect_base_direction("!@#$%"), TextDirection::Ltr); + } + + #[test] + fn test_latin_text() { + assert_eq!(detect_base_direction("Hello world"), TextDirection::Ltr); + } + + #[test] + fn test_arabic_text() { + assert_eq!(detect_base_direction("مرحبا بالعالم"), TextDirection::Rtl); + } + + #[test] + fn test_hebrew_text() { + assert_eq!(detect_base_direction("שלום עולם"), TextDirection::Rtl); + } + + #[test] + fn test_mixed_ltr_first() { + assert_eq!(detect_base_direction("Hello مرحبا"), TextDirection::Ltr); + } + + #[test] + fn test_mixed_rtl_first() { + assert_eq!(detect_base_direction("مرحبا Hello"), TextDirection::Rtl); + } + + #[test] + fn test_numbers_before_arabic() { + assert_eq!(detect_base_direction("123 مرحبا"), TextDirection::Rtl); + } + + #[test] + fn test_numbers_before_latin() { + assert_eq!(detect_base_direction("123 Hello"), TextDirection::Ltr); + } + + #[test] + fn test_punctuation_before_hebrew() { + assert_eq!(detect_base_direction("... שלום"), TextDirection::Rtl); + } + + #[test] + fn test_greek_text() { + assert_eq!(detect_base_direction("Γειά σου κόσμε"), TextDirection::Ltr); + } + + #[test] + fn test_cyrillic_text() { + assert_eq!(detect_base_direction("Привет мир"), TextDirection::Ltr); + } + + #[test] + fn test_chinese_text() { + assert_eq!(detect_base_direction("你好世界"), TextDirection::Ltr); + } + + #[test] + fn test_japanese_text() { + assert_eq!(detect_base_direction("こんにちは"), TextDirection::Ltr); + } + + #[test] + fn test_direction_is_ltr() { + assert!(TextDirection::Ltr.is_ltr()); + assert!(!TextDirection::Rtl.is_ltr()); + } + + #[test] + fn test_direction_is_rtl() { + assert!(TextDirection::Rtl.is_rtl()); + assert!(!TextDirection::Ltr.is_rtl()); + } + + #[test] + fn test_direction_default() { + assert_eq!(TextDirection::default(), TextDirection::Ltr); + } +} diff --git a/crates/input/src/bindings.rs b/crates/input/src/bindings.rs new file mode 100644 index 0000000..bc6bd97 --- /dev/null +++ b/crates/input/src/bindings.rs @@ -0,0 +1,512 @@ +use gpui::{App, KeyBinding, actions}; + +actions!( + input, + [ + /// 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, + ] +); + +/// The key context used for input element keybindings. +pub const INPUT_CONTEXT: &str = "Input"; + +/// Keybindings configuration for input elements. +/// +/// Each field is an `Option` to allow: +/// - Using defaults (via `Default::default()`) +/// - Overriding with custom bindings +/// - Unbinding keys by setting fields to `None` +/// +/// The `Default` implementation returns platform-specific keybindings. +#[derive(Clone)] +pub struct InputBindings { + /// Binding for deleting the character before the cursor. + /// Default: `backspace` + pub backspace: Option, + + /// Binding for deleting the character after the cursor. + /// Default: `delete` + pub delete: Option, + + /// Binding for deleting the word before the cursor. + /// Default: `alt-backspace` (macOS) / `ctrl-backspace` (other platforms) + pub delete_word_left: Option, + + /// Binding for deleting the word after the cursor. + /// Default: `alt-delete` (macOS) / `ctrl-delete` (other platforms) + pub delete_word_right: Option, + + /// Binding for deleting from cursor to beginning of line. + /// Default: `cmd-backspace` (macOS) / `ctrl-shift-backspace` (other platforms) + pub delete_to_beginning_of_line: Option, + + /// Binding for deleting from cursor to end of line. + /// Default: `ctrl-k` (macOS) / `ctrl-shift-delete` (other platforms) + pub delete_to_end_of_line: Option, + + /// Binding for inserting a tab character. + /// Default: `tab` + pub tab: Option, + + /// Binding for inserting a newline (multi-line) or confirming input (single-line). + /// Default: `enter` + pub enter: Option, + + /// Binding for moving the cursor one character to the left. + /// Default: `left` + pub left: Option, + + /// Binding for moving the cursor one character to the right. + /// Default: `right` + pub right: Option, + + /// Binding for moving the cursor up one line (multi-line) or to the start of the line (single-line). + /// Default: `up` + pub up: Option, + + /// Binding for moving the cursor down one line (multi-line) or to the end of the line (single-line). + /// Default: `down` + pub down: Option, + + /// Binding for extending selection one character to the left. + /// Default: `shift-left` + pub select_left: Option, + + /// Binding for extending selection one character to the right. + /// Default: `shift-right` + pub select_right: Option, + + /// Binding for extending selection up one line (multi-line) or to the start (single-line). + /// Default: `shift-up` + pub select_up: Option, + + /// Binding for extending selection down one line (multi-line) or to the end (single-line). + /// Default: `shift-down` + pub select_down: Option, + + /// Binding for selecting all text content. + /// Default: `cmd-a` (macOS) / `ctrl-a` (other platforms) + pub select_all: Option, + + /// Binding for moving cursor to the start of the current line. + /// Default: `home` + pub home: Option, + + /// Binding for moving cursor to the end of the current line. + /// Default: `end` + pub end: Option, + + /// Binding for moving cursor to the beginning of all content. + /// Default: `cmd-up` (macOS) / `ctrl-home` (other platforms) + pub move_to_beginning: Option, + + /// Binding for moving cursor to the end of all content. + /// Default: `cmd-down` (macOS) / `ctrl-end` (other platforms) + pub move_to_end: Option, + + /// Binding for extending selection to the beginning of all content. + /// Default: `cmd-shift-up` (macOS) / `ctrl-shift-home` (other platforms) + pub select_to_beginning: Option, + + /// Binding for extending selection to the end of all content. + /// Default: `cmd-shift-down` (macOS) / `ctrl-shift-end` (other platforms) + pub select_to_end: Option, + + /// Binding for moving cursor one word to the left. + /// Default: `alt-left` (macOS) / `ctrl-left` (other platforms) + pub word_left: Option, + + /// Binding for moving cursor one word to the right. + /// Default: `alt-right` (macOS) / `ctrl-right` (other platforms) + pub word_right: Option, + + /// Binding for extending selection one word to the left. + /// Default: `alt-shift-left` (macOS) / `ctrl-shift-left` (other platforms) + pub select_word_left: Option, + + /// Binding for extending selection one word to the right. + /// Default: `alt-shift-right` (macOS) / `ctrl-shift-right` (other platforms) + pub select_word_right: Option, + + /// Binding for copying selected text to clipboard. + /// Default: `cmd-c` (macOS) / `ctrl-c` (other platforms) + pub copy: Option, + + /// Binding for cutting selected text to clipboard. + /// Default: `cmd-x` (macOS) / `ctrl-x` (other platforms) + pub cut: Option, + + /// Binding for pasting from clipboard. + /// Default: `cmd-v` (macOS) / `ctrl-v` (other platforms) + pub paste: Option, + + /// Binding for undoing the last edit. + /// Default: `cmd-z` (macOS) / `ctrl-z` (other platforms) + pub undo: Option, + + /// Binding for redoing the last undone edit. + /// Default: `cmd-shift-z` (macOS) / `ctrl-shift-z` (other platforms) + pub redo: Option, + + /// Binding for blurring focus from the input. + /// Default: `escape` + pub escape: Option, +} + +impl Default for InputBindings { + /// Returns platform-specific default keybindings for input elements. + fn default() -> Self { + let context = Some(INPUT_CONTEXT); + + #[cfg(target_os = "macos")] + { + Self { + backspace: Some(KeyBinding::new("backspace", Backspace, context)), + delete: Some(KeyBinding::new("delete", Delete, context)), + delete_word_left: Some(KeyBinding::new("alt-backspace", DeleteWordLeft, context)), + delete_word_right: Some(KeyBinding::new("alt-delete", DeleteWordRight, context)), + delete_to_beginning_of_line: Some(KeyBinding::new( + "cmd-backspace", + DeleteToBeginningOfLine, + context, + )), + delete_to_end_of_line: Some(KeyBinding::new("ctrl-k", DeleteToEndOfLine, context)), + tab: Some(KeyBinding::new("tab", Tab, context)), + enter: Some(KeyBinding::new("enter", Enter, context)), + left: Some(KeyBinding::new("left", Left, context)), + right: Some(KeyBinding::new("right", Right, context)), + up: Some(KeyBinding::new("up", Up, context)), + down: Some(KeyBinding::new("down", Down, context)), + select_left: Some(KeyBinding::new("shift-left", SelectLeft, context)), + select_right: Some(KeyBinding::new("shift-right", SelectRight, context)), + select_up: Some(KeyBinding::new("shift-up", SelectUp, context)), + select_down: Some(KeyBinding::new("shift-down", SelectDown, context)), + select_all: Some(KeyBinding::new("cmd-a", SelectAll, context)), + home: Some(KeyBinding::new("home", Home, context)), + end: Some(KeyBinding::new("end", End, context)), + move_to_beginning: Some(KeyBinding::new("cmd-up", MoveToBeginning, context)), + move_to_end: Some(KeyBinding::new("cmd-down", MoveToEnd, context)), + select_to_beginning: Some(KeyBinding::new( + "cmd-shift-up", + SelectToBeginning, + context, + )), + select_to_end: Some(KeyBinding::new("cmd-shift-down", SelectToEnd, context)), + word_left: Some(KeyBinding::new("alt-left", WordLeft, context)), + word_right: Some(KeyBinding::new("alt-right", WordRight, context)), + select_word_left: Some(KeyBinding::new("alt-shift-left", SelectWordLeft, context)), + select_word_right: Some(KeyBinding::new( + "alt-shift-right", + SelectWordRight, + context, + )), + copy: Some(KeyBinding::new("cmd-c", Copy, context)), + cut: Some(KeyBinding::new("cmd-x", Cut, context)), + paste: Some(KeyBinding::new("cmd-v", Paste, context)), + undo: Some(KeyBinding::new("cmd-z", Undo, context)), + redo: Some(KeyBinding::new("cmd-shift-z", Redo, context)), + escape: Some(KeyBinding::new("escape", Escape, context)), + } + } + + #[cfg(not(target_os = "macos"))] + { + Self { + backspace: Some(KeyBinding::new("backspace", Backspace, context)), + delete: Some(KeyBinding::new("delete", Delete, context)), + delete_word_left: Some(KeyBinding::new("ctrl-backspace", DeleteWordLeft, context)), + delete_word_right: Some(KeyBinding::new("ctrl-delete", DeleteWordRight, context)), + delete_to_beginning_of_line: Some(KeyBinding::new( + "ctrl-shift-backspace", + DeleteToBeginningOfLine, + context, + )), + delete_to_end_of_line: Some(KeyBinding::new( + "ctrl-shift-delete", + DeleteToEndOfLine, + context, + )), + tab: Some(KeyBinding::new("tab", Tab, context)), + enter: Some(KeyBinding::new("enter", Enter, context)), + left: Some(KeyBinding::new("left", Left, context)), + right: Some(KeyBinding::new("right", Right, context)), + up: Some(KeyBinding::new("up", Up, context)), + down: Some(KeyBinding::new("down", Down, context)), + select_left: Some(KeyBinding::new("shift-left", SelectLeft, context)), + select_right: Some(KeyBinding::new("shift-right", SelectRight, context)), + select_up: Some(KeyBinding::new("shift-up", SelectUp, context)), + select_down: Some(KeyBinding::new("shift-down", SelectDown, context)), + select_all: Some(KeyBinding::new("ctrl-a", SelectAll, context)), + home: Some(KeyBinding::new("home", Home, context)), + end: Some(KeyBinding::new("end", End, context)), + move_to_beginning: Some(KeyBinding::new("ctrl-home", MoveToBeginning, context)), + move_to_end: Some(KeyBinding::new("ctrl-end", MoveToEnd, context)), + select_to_beginning: Some(KeyBinding::new( + "ctrl-shift-home", + SelectToBeginning, + context, + )), + select_to_end: Some(KeyBinding::new("ctrl-shift-end", SelectToEnd, context)), + word_left: Some(KeyBinding::new("ctrl-left", WordLeft, context)), + word_right: Some(KeyBinding::new("ctrl-right", WordRight, context)), + select_word_left: Some(KeyBinding::new("ctrl-shift-left", SelectWordLeft, context)), + select_word_right: Some(KeyBinding::new( + "ctrl-shift-right", + SelectWordRight, + context, + )), + copy: Some(KeyBinding::new("ctrl-c", Copy, context)), + cut: Some(KeyBinding::new("ctrl-x", Cut, context)), + paste: Some(KeyBinding::new("ctrl-v", Paste, context)), + undo: Some(KeyBinding::new("ctrl-z", Undo, context)), + redo: Some(KeyBinding::new("ctrl-shift-z", Redo, context)), + escape: Some(KeyBinding::new("escape", Escape, context)), + } + } + } +} + +impl InputBindings { + /// Creates an empty `InputBindings` with all fields set to `None`. + /// + /// Use this as a starting point when you want to override only specific bindings: + /// + /// ```ignore + /// let bindings = InputBindings::empty(); + /// bindings.select_all = Some(KeyBinding::new("ctrl-shift-a", SelectAll, Some(INPUT_CONTEXT))); + /// ``` + pub fn empty() -> Self { + Self { + backspace: None, + delete: None, + delete_word_left: None, + delete_word_right: None, + delete_to_beginning_of_line: None, + delete_to_end_of_line: None, + tab: None, + enter: None, + left: None, + right: None, + up: None, + down: None, + select_left: None, + select_right: None, + select_up: None, + select_down: None, + select_all: None, + home: None, + end: None, + move_to_beginning: None, + move_to_end: None, + select_to_beginning: None, + select_to_end: None, + word_left: None, + word_right: None, + select_word_left: None, + select_word_right: None, + copy: None, + cut: None, + paste: None, + undo: None, + redo: None, + escape: None, + } + } + + /// Merges these bindings with defaults, using `self` values where `Some`, + /// falling back to defaults for `None` values. + pub fn merged_with_defaults(self) -> Self { + let defaults = Self::default(); + Self { + backspace: self.backspace.or(defaults.backspace), + delete: self.delete.or(defaults.delete), + delete_word_left: self.delete_word_left.or(defaults.delete_word_left), + delete_word_right: self.delete_word_right.or(defaults.delete_word_right), + delete_to_beginning_of_line: self + .delete_to_beginning_of_line + .or(defaults.delete_to_beginning_of_line), + delete_to_end_of_line: self + .delete_to_end_of_line + .or(defaults.delete_to_end_of_line), + tab: self.tab.or(defaults.tab), + enter: self.enter.or(defaults.enter), + left: self.left.or(defaults.left), + right: self.right.or(defaults.right), + up: self.up.or(defaults.up), + down: self.down.or(defaults.down), + select_left: self.select_left.or(defaults.select_left), + select_right: self.select_right.or(defaults.select_right), + select_up: self.select_up.or(defaults.select_up), + select_down: self.select_down.or(defaults.select_down), + select_all: self.select_all.or(defaults.select_all), + home: self.home.or(defaults.home), + end: self.end.or(defaults.end), + move_to_beginning: self.move_to_beginning.or(defaults.move_to_beginning), + move_to_end: self.move_to_end.or(defaults.move_to_end), + select_to_beginning: self.select_to_beginning.or(defaults.select_to_beginning), + select_to_end: self.select_to_end.or(defaults.select_to_end), + word_left: self.word_left.or(defaults.word_left), + word_right: self.word_right.or(defaults.word_right), + select_word_left: self.select_word_left.or(defaults.select_word_left), + select_word_right: self.select_word_right.or(defaults.select_word_right), + copy: self.copy.or(defaults.copy), + cut: self.cut.or(defaults.cut), + paste: self.paste.or(defaults.paste), + undo: self.undo.or(defaults.undo), + redo: self.redo.or(defaults.redo), + escape: self.escape.or(defaults.escape), + } + } + + /// Collects all `Some` bindings into a `Vec`. + pub fn into_bindings(self) -> Vec { + [ + self.backspace, + self.delete, + self.delete_word_left, + self.delete_word_right, + self.delete_to_beginning_of_line, + self.delete_to_end_of_line, + self.tab, + self.enter, + self.left, + self.right, + self.up, + self.down, + self.select_left, + self.select_right, + self.select_up, + self.select_down, + self.select_all, + self.home, + self.end, + self.move_to_beginning, + self.move_to_end, + self.select_to_beginning, + self.select_to_end, + self.word_left, + self.word_right, + self.select_word_left, + self.select_word_right, + self.copy, + self.cut, + self.paste, + self.undo, + self.redo, + self.escape, + ] + .into_iter() + .flatten() + .collect() + } +} + +/// Binds input keybindings to the application. +/// +/// If no bindings are provided, platform defaults are used. When bindings are +/// provided, they are used exactly as-is - fields set to `None` will not have +/// any keybinding registered for that action. +/// +/// # Examples +/// +/// Use all platform defaults: +/// +/// ```ignore +/// bind_input_keys(cx, None); +/// ``` +/// +/// Unbind a specific key while keeping other defaults: +/// +/// ```ignore +/// bind_input_keys(cx, InputBindings { +/// up: None, // Unbind up arrow +/// ..Default::default() +/// }); +/// ``` +/// +/// Override a specific binding while keeping other defaults: +/// +/// ```ignore +/// bind_input_keys(cx, InputBindings { +/// select_all: Some(KeyBinding::new("ctrl-shift-a", SelectAll, Some(INPUT_CONTEXT))), +/// ..Default::default() +/// }); +/// ``` +/// +/// Use [`InputBindings::empty()`] with [`merged_with_defaults()`](InputBindings::merged_with_defaults) +/// if you only want to specify a few custom bindings and fill in the rest with defaults: +/// +/// ```ignore +/// let mut bindings = InputBindings::empty(); +/// bindings.select_all = Some(KeyBinding::new("ctrl-shift-a", SelectAll, Some(INPUT_CONTEXT))); +/// bind_input_keys(cx, bindings.merged_with_defaults()); +/// ``` +pub fn bind_input_keys(cx: &mut App, bindings: impl Into>) { + let bindings = bindings.into().unwrap_or_default(); + cx.bind_keys(bindings.into_bindings()); +} diff --git a/crates/input/src/blink.rs b/crates/input/src/blink.rs new file mode 100644 index 0000000..bafba7e --- /dev/null +++ b/crates/input/src/blink.rs @@ -0,0 +1,121 @@ +use std::time::Duration; + +use gpui::Context; +use smol::Timer; + +/// Manages the blinking state of a text cursor. +/// +/// The cursor blinks at a configurable interval when enabled. Blinking can be +/// temporarily paused (e.g., during typing) to provide immediate visual feedback. +pub struct CursorBlink { + interval: Duration, + generation: usize, + visible: bool, + active: bool, + paused: bool, +} + +impl CursorBlink { + /// Creates a new cursor blink manager with the given interval. + /// + /// The cursor starts in a disabled state with visibility set to true. + pub fn new(interval: Duration, _cx: &mut Context) -> Self { + Self { + interval, + generation: 0, + visible: true, + active: false, + paused: false, + } + } + + /// Returns whether the cursor should currently be rendered. + pub fn visible(&self) -> bool { + 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 + /// states at the configured interval. Has no effect if already active. + pub fn enable(&mut self, cx: &mut Context) { + if self.active { + return; + } + + self.active = true; + self.visible = false; + self.paused = false; + self.tick(cx); + } + + /// Deactivates cursor blinking. + /// + /// The cursor visibility is set to false when disabled. Call + /// `pause_blinking` instead if you want to temporarily stop blinking + /// while keeping the cursor visible. + pub fn disable(&mut self, cx: &mut Context) { + self.active = false; + self.visible = false; + self.paused = false; + cx.notify(); + } + + /// Temporarily pauses blinking and shows the cursor. + /// + /// This is useful during user input to provide immediate feedback. + /// Blinking resumes automatically after the blink interval elapses. + pub fn pause_blinking(&mut self, cx: &mut Context) { + if !self.visible { + self.visible = true; + cx.notify(); + } + + self.paused = true; + self.generation = self.generation.wrapping_add(1); + + let generation = self.generation; + let interval = self.interval; + + cx.spawn(async move |this, cx| { + Timer::after(interval).await; + this.update(cx, |this, cx| { + if this.generation == generation { + this.paused = false; + this.tick(cx); + } + }) + }) + .detach(); + } + + fn tick(&mut self, cx: &mut Context) { + if !self.active || self.paused { + return; + } + + self.visible = !self.visible; + cx.notify(); + + self.generation = self.generation.wrapping_add(1); + let generation = self.generation; + let interval = self.interval; + + cx.spawn(async move |this, cx| { + Timer::after(interval).await; + if let Some(this) = this.upgrade() { + this.update(cx, |this, cx| { + if this.generation == generation { + this.tick(cx); + } + }); + } + }) + .detach(); + } +} diff --git a/crates/input/src/handler.rs b/crates/input/src/handler.rs new file mode 100644 index 0000000..db5c2ba --- /dev/null +++ b/crates/input/src/handler.rs @@ -0,0 +1,181 @@ +use std::ops::Range; + +use gpui::{App, Bounds, Context, Entity, InputHandler, Pixels, Point, UTF16Selection, Window}; + +/// Implement this trait to allow views to handle textual input when implementing an editor, field, etc. +/// +/// Once your view implements this trait, you can use it to construct an [`ElementInputHandler`]. +/// This input handler can then be assigned during paint by calling [`Window::handle_input`]. +/// +/// See [`InputHandler`] for details on how to implement each method. +pub trait EntityInputHandler: 'static + Sized { + /// See [`InputHandler::text_for_range`] for details + fn text_for_range( + &mut self, + range: Range, + adjusted_range: &mut Option>, + window: &mut Window, + cx: &mut Context, + ) -> Option; + + /// See [`InputHandler::selected_text_range`] for details + fn selected_text_range( + &mut self, + ignore_disabled_input: bool, + window: &mut Window, + cx: &mut Context, + ) -> Option; + + /// See [`InputHandler::marked_text_range`] for details + fn marked_text_range( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option>; + + /// See [`InputHandler::unmark_text`] for details + fn unmark_text(&mut self, window: &mut Window, cx: &mut Context); + + /// See [`InputHandler::replace_text_in_range`] for details + fn replace_text_in_range( + &mut self, + range: Option>, + text: &str, + window: &mut Window, + cx: &mut Context, + ); + + /// See [`InputHandler::replace_and_mark_text_in_range`] for details + fn replace_and_mark_text_in_range( + &mut self, + range: Option>, + new_text: &str, + new_selected_range: Option>, + window: &mut Window, + cx: &mut Context, + ); + + /// See [`InputHandler::bounds_for_range`] for details + fn bounds_for_range( + &mut self, + range_utf16: Range, + element_bounds: Bounds, + window: &mut Window, + cx: &mut Context, + ) -> Option>; + + /// See [`InputHandler::character_index_for_point`] for details + fn character_index_for_point( + &mut self, + point: Point, + window: &mut Window, + cx: &mut Context, + ) -> Option; +} + +/// The canonical implementation of [`gpui::PlatformInputHandler`]. Call [`Window::handle_input`] +/// with an instance during your element's paint. +pub struct ElementInputHandler { + view: Entity, + element_bounds: Bounds, +} + +impl ElementInputHandler { + /// Used in [`Element::paint`][element_paint] with the element's bounds, a `Window`, and a `App` context. + /// + /// [element_paint]: gpui::Element::paint + pub fn new(element_bounds: Bounds, view: Entity) -> Self { + ElementInputHandler { + view, + element_bounds, + } + } +} + +impl InputHandler for ElementInputHandler { + fn selected_text_range( + &mut self, + ignore_disabled_input: bool, + window: &mut Window, + cx: &mut App, + ) -> Option { + self.view.update(cx, |view, cx| { + view.selected_text_range(ignore_disabled_input, window, cx) + }) + } + + fn marked_text_range(&mut self, window: &mut Window, cx: &mut App) -> Option> { + self.view + .update(cx, |view, cx| view.marked_text_range(window, cx)) + } + + fn text_for_range( + &mut self, + range_utf16: Range, + adjusted_range: &mut Option>, + window: &mut Window, + cx: &mut App, + ) -> Option { + self.view.update(cx, |view, cx| { + view.text_for_range(range_utf16, adjusted_range, window, cx) + }) + } + + fn replace_text_in_range( + &mut self, + replacement_range: Option>, + text: &str, + window: &mut Window, + cx: &mut App, + ) { + self.view.update(cx, |view, cx| { + view.replace_text_in_range(replacement_range, text, window, cx) + }); + } + + fn replace_and_mark_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + new_selected_range: Option>, + window: &mut Window, + cx: &mut App, + ) { + self.view.update(cx, |view, cx| { + view.replace_and_mark_text_in_range( + range_utf16, + new_text, + new_selected_range, + window, + cx, + ) + }); + } + + fn unmark_text(&mut self, window: &mut Window, cx: &mut App) { + self.view + .update(cx, |view, cx| view.unmark_text(window, cx)); + } + + fn bounds_for_range( + &mut self, + range_utf16: Range, + window: &mut Window, + cx: &mut App, + ) -> Option> { + self.view.update(cx, |view, cx| { + view.bounds_for_range(range_utf16, self.element_bounds, window, cx) + }) + } + + fn character_index_for_point( + &mut self, + point: Point, + window: &mut Window, + cx: &mut App, + ) -> Option { + self.view.update(cx, |view, cx| { + view.character_index_for_point(point, window, cx) + }) + } +} diff --git a/crates/input/src/input.rs b/crates/input/src/input.rs new file mode 100644 index 0000000..0ba6dea --- /dev/null +++ b/crates/input/src/input.rs @@ -0,0 +1,1301 @@ +//! A text input element that supports both single-line and multi-line modes. +//! +//! Input-based elements are made up of two parts: +//! +//! - `InputState`: a reusable entity that manages text content, insertion and selection state. +//! - An element: handles layout, painting, interaction and behavior specific to this type of input +//! +//! Use `input()` for single-line text fields and `text_area()` for multi-line text editing. + +use std::sync::Arc; + +use gpui::{ + Action, App, Bounds, ContentMask, Context, CursorStyle, DispatchPhase, Element, ElementId, + Entity, FocusHandle, Focusable, GlobalElementId, Hitbox, HitboxBehavior, Hsla, + InspectorElementId, InteractiveElement, Interactivity, IntoElement, LayoutId, Length, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollWheelEvent, + SharedString, StyleRefinement, Styled, TextAlign, TextRun, TextStyle, Window, WrappedLine, + fill, point, px, relative, size, +}; +use theme::ActiveTheme; + +use crate::bidi::TextDirection; +use crate::bindings::{Escape, INPUT_CONTEXT}; +use crate::handler::ElementInputHandler; +use crate::state::{InputLineLayout, InputState}; + +const CURSOR_WIDTH: f32 = 2.0; +const MARKED_TEXT_UNDERLINE_THICKNESS: f32 = 2.0; + +/// Creates a new single-line `Input` element powered by the given `InputState`. +/// +/// The `InputState` should be created with `InputState::new_singleline(cx)`. +#[track_caller] +pub fn input(input_state: &Entity, cx: &App) -> Input { + Input::new(input_state, false, cx) +} + +/// Creates a new multi-line `Input` element (text area) powered by the given `InputState`. +/// +/// The `InputState` should be created with `InputState::new_multiline(cx)`. +#[track_caller] +pub fn text_area(input_state: &Entity, cx: &App) -> Input { + Input::new(input_state, true, cx) +} + +/// A text editing element that supports both single-line and multi-line modes. +pub struct Input { + input: Entity, + interactivity: Interactivity, + placeholder: Option, + selection_color: Option, + cursor_color: Option, + multiline: bool, +} + +impl Input { + #[track_caller] + fn new(input_state: &Entity, multiline: bool, cx: &App) -> Self { + let focus_handle = input_state.focus_handle(cx); + let mut input = Input { + input: input_state.clone(), + interactivity: Interactivity::new(), + placeholder: None, + selection_color: None, + cursor_color: None, + multiline, + }; + input.register_actions(); + input.key_context(INPUT_CONTEXT).track_focus(&focus_handle) + } + + /// Sets the placeholder text shown when the input is empty. + pub fn placeholder(mut self, placeholder: impl Into) -> Self { + self.placeholder = Some(placeholder.into()); + self + } + + /// Sets the color used for text selection highlighting. + pub fn selection_color(mut self, color: impl Into) -> Self { + self.selection_color = Some(color.into()); + self + } + + /// Sets the color of the text cursor. + pub fn cursor_color(mut self, color: impl Into) -> Self { + self.cursor_color = Some(color.into()); + self + } + + fn color(&self, cx: &App) -> PaintColors { + PaintColors { + selection: self.selection_color.unwrap_or_else(|| cx.theme().selection), + cursor: self.cursor_color.unwrap_or_else(|| cx.theme().cursor), + } + } + + fn register_actions(&mut self) { + register_action(&mut self.interactivity, &self.input, InputState::left); + register_action(&mut self.interactivity, &self.input, InputState::right); + register_action(&mut self.interactivity, &self.input, InputState::up); + register_action(&mut self.interactivity, &self.input, InputState::down); + register_action( + &mut self.interactivity, + &self.input, + InputState::select_left, + ); + register_action( + &mut self.interactivity, + &self.input, + InputState::select_right, + ); + register_action(&mut self.interactivity, &self.input, InputState::select_up); + register_action( + &mut self.interactivity, + &self.input, + InputState::select_down, + ); + register_action(&mut self.interactivity, &self.input, InputState::select_all); + register_action(&mut self.interactivity, &self.input, InputState::home); + register_action(&mut self.interactivity, &self.input, InputState::end); + register_action( + &mut self.interactivity, + &self.input, + InputState::move_to_beginning, + ); + register_action( + &mut self.interactivity, + &self.input, + InputState::move_to_end, + ); + register_action( + &mut self.interactivity, + &self.input, + InputState::select_to_beginning, + ); + register_action( + &mut self.interactivity, + &self.input, + InputState::select_to_end, + ); + register_action(&mut self.interactivity, &self.input, InputState::word_left); + register_action(&mut self.interactivity, &self.input, InputState::word_right); + register_action( + &mut self.interactivity, + &self.input, + InputState::select_word_left, + ); + register_action( + &mut self.interactivity, + &self.input, + InputState::select_word_right, + ); + register_action(&mut self.interactivity, &self.input, InputState::backspace); + register_action(&mut self.interactivity, &self.input, InputState::delete); + register_action( + &mut self.interactivity, + &self.input, + InputState::delete_word_left, + ); + register_action( + &mut self.interactivity, + &self.input, + InputState::delete_word_right, + ); + register_action( + &mut self.interactivity, + &self.input, + InputState::delete_to_beginning_of_line, + ); + register_action( + &mut self.interactivity, + &self.input, + InputState::delete_to_end_of_line, + ); + register_action(&mut self.interactivity, &self.input, InputState::enter); + register_action(&mut self.interactivity, &self.input, InputState::tab); + register_action(&mut self.interactivity, &self.input, InputState::paste); + register_action(&mut self.interactivity, &self.input, InputState::copy); + register_action(&mut self.interactivity, &self.input, InputState::cut); + register_action(&mut self.interactivity, &self.input, InputState::undo); + register_action(&mut self.interactivity, &self.input, InputState::redo); + + self.interactivity + .on_action::(|_action, window, _cx| { + window.blur(); + }); + } +} + +fn register_action( + interactivity: &mut Interactivity, + input: &Entity, + listener: fn(&mut InputState, &A, &mut Window, &mut Context), +) { + let input = input.clone(); + interactivity.on_action::(move |action, window, cx| { + input.update(cx, |input, cx| { + listener(input, action, window, cx); + }); + }); +} + +impl Styled for Input { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.interactivity.base_style + } +} + +impl InteractiveElement for Input { + fn interactivity(&mut self) -> &mut Interactivity { + &mut self.interactivity + } +} + +/// Layout state passed from request_layout to prepaint. +pub struct InputLayoutState { + text_style: TextStyle, +} + +/// Prepaint state passed from prepaint to paint. +pub struct InputPrepaintState { + hitbox: Option, +} + +impl Element for Input { + type PrepaintState = InputPrepaintState; + type RequestLayoutState = InputLayoutState; + + fn id(&self) -> Option { + self.interactivity.element_id.clone() + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + self.interactivity.source_location() + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut resolved_text_style = None; + let multiline = self.multiline; + + let layout_id = self.interactivity.request_layout( + global_id, + inspector_id, + window, + cx, + |element_style, window, cx| { + window.with_text_style(element_style.text_style().cloned(), |window| { + resolved_text_style = Some(window.text_style()); + + let mut layout_style = element_style.clone(); + if multiline { + if let Length::Auto = layout_style.size.width { + layout_style.size.width = relative(1.).into(); + } + if let Length::Auto = layout_style.size.height { + layout_style.size.height = relative(1.).into(); + } + } + window.request_layout(layout_style, None, cx) + }) + }, + ); + + ( + layout_id, + InputLayoutState { + text_style: resolved_text_style.unwrap_or_else(|| window.text_style()), + }, + ) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + layout_state: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let line_height = layout_state + .text_style + .line_height_in_pixels(window.rem_size()); + + let wrap_width = if self.multiline { + bounds.size.width + } else { + px(100000.) + }; + + self.input.update(cx, |input, _cx| { + input.available_height = bounds.size.height; + input.available_width = bounds.size.width; + input.update_line_layouts(wrap_width, line_height, &layout_state.text_style, window); + }); + + let hitbox = self.interactivity.prepaint( + global_id, + inspector_id, + bounds, + bounds.size, + window, + cx, + |_style, _point, hitbox, window, _cx| { + hitbox.or_else(|| Some(window.insert_hitbox(bounds, HitboxBehavior::Normal))) + }, + ); + + InputPrepaintState { hitbox } + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + layout_state: &mut Self::RequestLayoutState, + prepaint_state: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let focus_handle = self.input.focus_handle(cx); + let colors = self.color(cx); + + if let Some(hitbox) = &prepaint_state.hitbox { + window.set_cursor_style(CursorStyle::IBeam, hitbox); + } + + window.handle_input( + &focus_handle, + ElementInputHandler::new(bounds, self.input.clone()), + cx, + ); + + let input = self.input.clone(); + let placeholder = self.placeholder.clone(); + let text_style = layout_state.text_style.clone(); + let multiline = self.multiline; + let is_focused = focus_handle.is_focused(window); + let cursor_visible = self + .input + .update(cx, |input, cx| input.cursor_visible(is_focused, cx)); + + self.interactivity.paint( + global_id, + inspector_id, + bounds, + prepaint_state.hitbox.as_ref(), + window, + cx, + |_style, window, cx| { + handle_mouse(&input, bounds, multiline, window, cx); + + let colors = colors.clone(); + + window.with_content_mask(Some(ContentMask { bounds }), |window| { + if multiline { + paint_multiline( + &input, + &focus_handle, + bounds, + &text_style, + placeholder.as_ref(), + &colors, + cursor_visible, + window, + cx, + ); + } else { + paint_singleline( + &input, + &focus_handle, + bounds, + &text_style, + placeholder.as_ref(), + &colors, + cursor_visible, + window, + cx, + ); + } + }); + }, + ); + } +} + +/// Colors used for painting. +#[derive(Clone)] +struct PaintColors { + pub selection: Hsla, + pub cursor: Hsla, +} + +/// Registers all mouse event handlers for the input. +fn handle_mouse( + input: &Entity, + bounds: Bounds, + multiline: bool, + window: &mut Window, + cx: &App, +) { + mouse_down(input.clone(), bounds, multiline, window); + mouse_up(input.clone(), window); + mouse_move(input.clone(), bounds, multiline, window); + handle_scroll(input.clone(), bounds, multiline, window, cx); +} + +fn mouse_down( + input: Entity, + bounds: Bounds, + multiline: bool, + window: &mut Window, +) { + window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + if !bounds.contains(&event.position) { + return; + } + if event.button != MouseButton::Left { + return; + } + + input.update(cx, |input, cx| { + let text_position = + screen_to_text_position(event.position, bounds, input.scroll_offset, multiline); + input.on_mouse_down( + text_position, + event.click_count, + event.modifiers.shift, + window, + cx, + ); + }); + }); +} + +fn mouse_up(input: Entity, window: &mut Window) { + window.on_mouse_event(move |event: &MouseUpEvent, phase, _window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + if event.button != MouseButton::Left { + return; + } + + input.update(cx, |input, cx| { + input.on_mouse_up(cx); + }); + }); +} + +fn mouse_move( + input: Entity, + bounds: Bounds, + multiline: bool, + window: &mut Window, +) { + window.on_mouse_event(move |event: &MouseMoveEvent, phase, _window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + + input.update(cx, |input, cx| { + let text_position = + screen_to_text_position(event.position, bounds, input.scroll_offset, multiline); + input.on_mouse_move(text_position, cx); + }); + }); +} + +fn handle_scroll( + input: Entity, + bounds: Bounds, + multiline: bool, + window: &mut Window, + cx: &App, +) { + let max_scroll = if multiline { + let total_height = input.read(cx).total_content_height(); + (total_height - bounds.size.height).max(px(0.)) + } else { + let text_width = input + .read(cx) + .line_layouts + .first() + .and_then(|l| l.wrapped_line.as_ref()) + .map(|w| w.width()) + .unwrap_or(px(0.)); + (text_width - bounds.size.width).max(px(0.)) + }; + + window.on_mouse_event(move |event: &ScrollWheelEvent, phase, _window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + if !bounds.contains(&event.position) { + return; + } + + let pixel_delta = event.delta.pixel_delta(px(20.)); + input.update(cx, |input, cx| { + if multiline { + input.scroll_offset = + (input.scroll_offset - pixel_delta.y).clamp(px(0.), max_scroll); + } else { + let delta = if pixel_delta.x.abs() > pixel_delta.y.abs() { + pixel_delta.x + } else { + pixel_delta.y + }; + input.scroll_offset = (input.scroll_offset - delta).clamp(px(0.), max_scroll); + } + cx.notify(); + }); + }); +} + +/// Converts a screen position to a position relative to the text area origin, +/// adjusted for scroll offset. +fn screen_to_text_position( + screen_position: Point, + bounds: Bounds, + scroll_offset: Pixels, + multiline: bool, +) -> Point { + if multiline { + point( + screen_position.x - bounds.origin.x, + screen_position.y - bounds.origin.y + scroll_offset, + ) + } else { + point( + screen_position.x - bounds.origin.x + scroll_offset, + screen_position.y - bounds.origin.y, + ) + } +} + +#[allow(clippy::too_many_arguments)] +fn paint_multiline( + input: &Entity, + focus_handle: &FocusHandle, + bounds: Bounds, + text_style: &TextStyle, + placeholder: Option<&SharedString>, + colors: &PaintColors, + cursor_visible: bool, + window: &mut Window, + cx: &mut App, +) { + let input_state = input.read(cx); + let content = input_state.content().to_string(); + let selected_range = input_state.selected_range().clone(); + let marked_range = input_state.marked_range().cloned(); + let cursor_offset = input_state.cursor_offset(); + let line_layouts = input_state.line_layouts.clone(); + let scroll_offset = input_state.scroll_offset; + let line_height = input_state.line_height; + let is_focused = focus_handle.is_focused(window); + + if !selected_range.is_empty() { + paint_multiline_selection( + &line_layouts, + &selected_range, + bounds, + scroll_offset, + line_height, + colors.selection, + window, + ); + } + + if content.is_empty() { + if let Some(placeholder_str) = placeholder + && !placeholder_str.is_empty() + { + paint_multiline_placeholder(placeholder_str, bounds, text_style, window, cx); + } + } else { + paint_multiline_text( + &line_layouts, + bounds, + scroll_offset, + line_height, + window, + cx, + ); + } + + if let Some(marked_range) = &marked_range + && !marked_range.is_empty() + { + paint_multiline_marked_underline( + &line_layouts, + marked_range, + bounds, + scroll_offset, + line_height, + colors.cursor, + window, + ); + } + + if is_focused && selected_range.is_empty() && cursor_visible { + paint_multiline_cursor( + &line_layouts, + cursor_offset, + &content, + bounds, + scroll_offset, + line_height, + colors.cursor, + window, + ); + } +} + +fn is_line_visible( + line_y: Pixels, + line_height: Pixels, + visual_line_count: usize, + visible_height: Pixels, +) -> bool { + let line_bottom = line_y + line_height * visual_line_count as f32; + line_bottom >= px(0.) && line_y <= visible_height +} + +fn line_intersects_range( + text_range: &std::ops::Range, + selected_range: &std::ops::Range, +) -> bool { + if text_range.is_empty() { + selected_range.start <= text_range.start && selected_range.end > text_range.start + } else { + selected_range.end > text_range.start && selected_range.start < text_range.end + } +} + +fn compute_alignment_offset(line: &InputLineLayout, available_width: Pixels) -> Pixels { + match line.direction { + TextDirection::Ltr => px(0.), + TextDirection::Rtl => { + let line_width = line + .wrapped_line + .as_ref() + .map(|w| w.width()) + .unwrap_or(px(0.)); + available_width - line_width + } + } +} + +fn compute_visual_line_index(y: Pixels, line_height: Pixels) -> usize { + (y / line_height).floor() as usize +} + +fn paint_multiline_selection( + line_layouts: &[InputLineLayout], + selected_range: &std::ops::Range, + bounds: Bounds, + scroll_offset: Pixels, + line_height: Pixels, + selection_color: Hsla, + window: &mut Window, +) { + for line in line_layouts { + let line_y = line.y_offset - scroll_offset; + + if !is_line_visible( + line_y, + line_height, + line.visual_line_count, + bounds.size.height, + ) { + continue; + } + + if !line_intersects_range(&line.text_range, selected_range) { + continue; + } + + let alignment_offset = compute_alignment_offset(line, bounds.size.width); + + if line.text_range.is_empty() { + let empty_line_selection_width = px(6.); + window.paint_quad(fill( + Bounds::from_corners( + point(bounds.left() + alignment_offset, bounds.top() + line_y), + point( + bounds.left() + alignment_offset + empty_line_selection_width, + bounds.top() + line_y + line_height, + ), + ), + selection_color, + )); + } else if let Some(wrapped) = &line.wrapped_line { + let line_start = line.text_range.start; + let line_end = line.text_range.end; + + let sel_start = selected_range.start.max(line_start) - line_start; + let sel_end = selected_range.end.min(line_end) - line_start; + + let start_pos = wrapped + .position_for_index(sel_start, line_height) + .unwrap_or(point(px(0.), px(0.))); + let end_pos = wrapped + .position_for_index(sel_end, line_height) + .unwrap_or_else(|| { + let last_line_y = line_height * (line.visual_line_count - 1) as f32; + point(wrapped.width(), last_line_y) + }); + + let start_visual_line = compute_visual_line_index(start_pos.y, line_height); + let end_visual_line = compute_visual_line_index(end_pos.y, line_height); + + if start_visual_line == end_visual_line { + window.paint_quad(fill( + Bounds::from_corners( + point( + bounds.left() + alignment_offset + start_pos.x, + bounds.top() + line_y + start_pos.y, + ), + point( + bounds.left() + alignment_offset + end_pos.x, + bounds.top() + line_y + start_pos.y + line_height, + ), + ), + selection_color, + )); + } else { + let line_width = wrapped.width(); + + // First visual line + window.paint_quad(fill( + Bounds::from_corners( + point( + bounds.left() + alignment_offset + start_pos.x, + bounds.top() + line_y + start_pos.y, + ), + point( + bounds.left() + alignment_offset + line_width, + bounds.top() + line_y + start_pos.y + line_height, + ), + ), + selection_color, + )); + + // Middle visual lines + for visual_line in (start_visual_line + 1)..end_visual_line { + let y = line_height * visual_line as f32; + window.paint_quad(fill( + Bounds::from_corners( + point(bounds.left() + alignment_offset, bounds.top() + line_y + y), + point( + bounds.left() + alignment_offset + line_width, + bounds.top() + line_y + y + line_height, + ), + ), + selection_color, + )); + } + + // Last visual line + window.paint_quad(fill( + Bounds::from_corners( + point( + bounds.left() + alignment_offset, + bounds.top() + line_y + end_pos.y, + ), + point( + bounds.left() + alignment_offset + end_pos.x, + bounds.top() + line_y + end_pos.y + line_height, + ), + ), + selection_color, + )); + } + } + } +} + +fn paint_multiline_placeholder( + placeholder: &SharedString, + bounds: Bounds, + text_style: &TextStyle, + window: &mut Window, + cx: &mut App, +) { + let placeholder_color = cx.theme().text_placeholder; + let run = TextRun { + len: placeholder.len(), + font: text_style.font(), + color: placeholder_color, + background_color: None, + underline: None, + strikethrough: None, + }; + + let font_size = text_style.font_size.to_pixels(window.rem_size()); + let shaped_line = window + .text_system() + .shape_line(placeholder.clone(), font_size, &[run], None); + let line_height = text_style.line_height_in_pixels(window.rem_size()); + let _ = shaped_line.paint( + bounds.origin, + line_height, + TextAlign::Left, + None, + window, + cx, + ); +} + +fn paint_multiline_text( + line_layouts: &[InputLineLayout], + bounds: Bounds, + scroll_offset: Pixels, + line_height: Pixels, + window: &mut Window, + cx: &mut App, +) { + for line_layout in line_layouts { + let line_y = line_layout.y_offset - scroll_offset; + + if !is_line_visible( + line_y, + line_height, + line_layout.visual_line_count, + bounds.size.height, + ) { + continue; + } + + if let Some(wrapped) = &line_layout.wrapped_line { + let paint_pos = point(bounds.left(), bounds.top() + line_y); + let text_align = match line_layout.direction { + TextDirection::Ltr => TextAlign::Left, + TextDirection::Rtl => TextAlign::Right, + }; + let _ = wrapped.paint(paint_pos, line_height, text_align, Some(bounds), window, cx); + } + } +} + +fn paint_multiline_marked_underline( + line_layouts: &[InputLineLayout], + marked_range: &std::ops::Range, + bounds: Bounds, + scroll_offset: Pixels, + line_height: Pixels, + underline_color: Hsla, + window: &mut Window, +) { + let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); + let underline_offset = line_height - underline_thickness; + + for line in line_layouts { + let line_y = line.y_offset - scroll_offset; + + if !is_line_visible( + line_y, + line_height, + line.visual_line_count, + bounds.size.height, + ) { + continue; + } + + if !line_intersects_range(&line.text_range, marked_range) { + continue; + } + + if line.text_range.is_empty() { + continue; + } + + if let Some(wrapped) = &line.wrapped_line { + let alignment_offset = compute_alignment_offset(line, bounds.size.width); + let line_start = line.text_range.start; + let line_end = line.text_range.end; + + let mark_start = marked_range.start.max(line_start) - line_start; + let mark_end = marked_range.end.min(line_end) - line_start; + + let start_pos = wrapped + .position_for_index(mark_start, line_height) + .unwrap_or(point(px(0.), px(0.))); + let end_pos = wrapped + .position_for_index(mark_end, line_height) + .unwrap_or_else(|| { + let last_line_y = line_height * (line.visual_line_count - 1) as f32; + point(wrapped.width(), last_line_y) + }); + + let start_visual_line = compute_visual_line_index(start_pos.y, line_height); + let end_visual_line = compute_visual_line_index(end_pos.y, line_height); + + if start_visual_line == end_visual_line { + window.paint_quad(fill( + Bounds::from_corners( + point( + bounds.left() + alignment_offset + start_pos.x, + bounds.top() + line_y + start_pos.y + underline_offset, + ), + point( + bounds.left() + alignment_offset + end_pos.x, + bounds.top() + line_y + start_pos.y + line_height, + ), + ), + underline_color, + )); + } else { + // First visual line + window.paint_quad(fill( + Bounds::from_corners( + point( + bounds.left() + alignment_offset + start_pos.x, + bounds.top() + line_y + start_pos.y + underline_offset, + ), + point( + bounds.left() + alignment_offset + wrapped.width(), + bounds.top() + line_y + start_pos.y + line_height, + ), + ), + underline_color, + )); + + // Middle visual lines + for visual_line in (start_visual_line + 1)..end_visual_line { + let y = line_height * visual_line as f32; + window.paint_quad(fill( + Bounds::from_corners( + point( + bounds.left() + alignment_offset, + bounds.top() + line_y + y + underline_offset, + ), + point( + bounds.left() + alignment_offset + wrapped.width(), + bounds.top() + line_y + y + line_height, + ), + ), + underline_color, + )); + } + + // Last visual line + window.paint_quad(fill( + Bounds::from_corners( + point( + bounds.left() + alignment_offset, + bounds.top() + line_y + end_pos.y + underline_offset, + ), + point( + bounds.left() + alignment_offset + end_pos.x, + bounds.top() + line_y + end_pos.y + line_height, + ), + ), + underline_color, + )); + } + } + } +} + +#[allow(clippy::too_many_arguments)] +fn paint_multiline_cursor( + line_layouts: &[InputLineLayout], + cursor_offset: usize, + _content: &str, + bounds: Bounds, + scroll_offset: Pixels, + line_height: Pixels, + cursor_color: Hsla, + window: &mut Window, +) { + for line in line_layouts.iter() { + let line_y = line.y_offset - scroll_offset; + + if !is_line_visible( + line_y, + line_height, + line.visual_line_count, + bounds.size.height, + ) { + continue; + } + + // Since range is non-inclusive of the end value we need to check for it explicitly + let is_cursor_in_line = if line.text_range.is_empty() { + cursor_offset == line.text_range.start + } else { + line.text_range.contains(&cursor_offset) || cursor_offset == line.text_range.end + }; + + if !is_cursor_in_line { + continue; + } + + let cursor_position = if let Some(wrapped) = &line.wrapped_line { + let local_offset = cursor_offset.saturating_sub(line.text_range.start); + wrapped + .position_for_index(local_offset, line_height) + .unwrap_or(point(px(0.), px(0.))) + } else { + point(px(0.), px(0.)) + }; + + let alignment_offset = compute_alignment_offset(line, bounds.size.width); + + window.paint_quad(fill( + Bounds::new( + point( + bounds.left() + alignment_offset + cursor_position.x, + bounds.top() + line_y + cursor_position.y, + ), + size(px(CURSOR_WIDTH), line_height), + ), + cursor_color, + )); + break; + } +} + +/// State for single-line painting that pre-computes character positions. +struct SingleLinePaintState { + content: String, + selected_range: std::ops::Range, + marked_range: Option>, + cursor_offset: usize, + scroll_offset: Pixels, + line_height: Pixels, + text_width: Pixels, + is_focused: bool, + char_positions: Vec, + wrapped_line: Option>, + direction: TextDirection, +} + +impl SingleLinePaintState { + fn from_input( + input: &Entity, + focus_handle: &FocusHandle, + window: &Window, + cx: &App, + ) -> Self { + let input_state = input.read(cx); + + let mut char_positions = Vec::new(); + let mut text_width = px(0.); + + if let Some(line) = input_state.line_layouts.first() + && let Some(wrapped) = &line.wrapped_line + { + text_width = wrapped.width(); + let content = input_state.content(); + let mut idx = 0; + for ch in content.chars() { + if let Some(pos) = wrapped.position_for_index(idx, input_state.line_height) { + char_positions.push(pos.x); + } else { + char_positions.push(text_width); + } + idx += ch.len_utf8(); + } + char_positions.push(text_width); + } + + let wrapped_line = input_state + .line_layouts + .first() + .and_then(|l| l.wrapped_line.clone()); + + let direction = input_state + .line_layouts + .first() + .map(|l| l.direction) + .unwrap_or_default(); + + Self { + content: input_state.content().to_string(), + selected_range: input_state.selected_range().clone(), + marked_range: input_state.marked_range().cloned(), + cursor_offset: input_state.cursor_offset(), + scroll_offset: input_state.scroll_offset, + line_height: input_state.line_height, + text_width, + is_focused: focus_handle.is_focused(window), + char_positions, + wrapped_line, + direction, + } + } + + fn x_for_index(&self, index: usize) -> Pixels { + let char_index = self.content[..index.min(self.content.len())] + .chars() + .count(); + self.char_positions + .get(char_index) + .copied() + .unwrap_or(self.text_width) + } + + fn alignment_offset(&self, available_width: Pixels) -> Pixels { + match self.direction { + TextDirection::Ltr => px(0.), + TextDirection::Rtl => available_width - self.text_width, + } + } +} + +#[allow(clippy::too_many_arguments)] +fn paint_singleline( + input: &Entity, + focus_handle: &FocusHandle, + bounds: Bounds, + text_style: &TextStyle, + placeholder: Option<&SharedString>, + colors: &PaintColors, + cursor_visible: bool, + window: &mut Window, + cx: &mut App, +) { + let state = SingleLinePaintState::from_input(input, focus_handle, window, cx); + + if !state.selected_range.is_empty() { + paint_singleline_selection(&state, bounds, colors.selection, window); + } + + if state.content.is_empty() { + if let Some(placeholder_str) = placeholder + && !placeholder_str.is_empty() + { + paint_singleline_placeholder(placeholder_str, bounds, text_style, window, cx); + } + } else { + paint_singleline_text(&state, bounds, window, cx); + } + + if let Some(marked_range) = &state.marked_range + && !marked_range.is_empty() + { + paint_singleline_marked_underline(&state, marked_range, bounds, colors.cursor, window); + } + + if state.is_focused && state.selected_range.is_empty() && cursor_visible { + paint_singleline_cursor(&state, bounds, colors.cursor, window); + } +} + +fn paint_singleline_selection( + state: &SingleLinePaintState, + bounds: Bounds, + selection_color: Hsla, + window: &mut Window, +) { + let alignment_offset = state.alignment_offset(bounds.size.width); + let start_x = + state.x_for_index(state.selected_range.start) - state.scroll_offset + alignment_offset; + let end_x = + state.x_for_index(state.selected_range.end) - state.scroll_offset + alignment_offset; + + let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; + + window.paint_quad(fill( + Bounds::from_corners( + point(bounds.left() + start_x, bounds.top() + y_offset), + point( + bounds.left() + end_x, + bounds.top() + y_offset + state.line_height, + ), + ), + selection_color, + )); +} + +fn paint_singleline_placeholder( + placeholder: &SharedString, + bounds: Bounds, + text_style: &TextStyle, + window: &mut Window, + cx: &mut App, +) { + let placeholder_color = cx.theme().text_placeholder; + let run = TextRun { + len: placeholder.len(), + font: text_style.font(), + color: placeholder_color, + background_color: None, + underline: None, + strikethrough: None, + }; + + let font_size = text_style.font_size.to_pixels(window.rem_size()); + let shaped_line = window + .text_system() + .shape_line(placeholder.clone(), font_size, &[run], None); + let line_height = text_style.line_height_in_pixels(window.rem_size()); + + let y_offset = (bounds.size.height - line_height).max(px(0.)) / 2.0; + let paint_origin = point(bounds.origin.x, bounds.origin.y + y_offset); + + let _ = shaped_line.paint(paint_origin, line_height, TextAlign::Left, None, window, cx); +} + +fn paint_singleline_text( + state: &SingleLinePaintState, + bounds: Bounds, + window: &mut Window, + cx: &mut App, +) { + let Some(wrapped_line) = &state.wrapped_line else { + return; + }; + + let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; + let paint_origin = point( + bounds.origin.x - state.scroll_offset, + bounds.origin.y + y_offset, + ); + + let text_align = match state.direction { + TextDirection::Ltr => TextAlign::Left, + TextDirection::Rtl => TextAlign::Right, + }; + let _ = wrapped_line.paint( + paint_origin, + state.line_height, + text_align, + Some(bounds), + window, + cx, + ); +} + +fn paint_singleline_marked_underline( + state: &SingleLinePaintState, + marked_range: &std::ops::Range, + bounds: Bounds, + underline_color: Hsla, + window: &mut Window, +) { + let alignment_offset = state.alignment_offset(bounds.size.width); + let start_x = state.x_for_index(marked_range.start) - state.scroll_offset + alignment_offset; + let end_x = state.x_for_index(marked_range.end) - state.scroll_offset + alignment_offset; + + let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); + let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; + let underline_y = bounds.top() + y_offset + state.line_height - underline_thickness; + + window.paint_quad(fill( + Bounds::from_corners( + point(bounds.left() + start_x, underline_y), + point(bounds.left() + end_x, underline_y + underline_thickness), + ), + underline_color, + )); +} + +fn paint_singleline_cursor( + state: &SingleLinePaintState, + bounds: Bounds, + cursor_color: Hsla, + window: &mut Window, +) { + let alignment_offset = state.alignment_offset(bounds.size.width); + let cursor_x = state.x_for_index(state.cursor_offset) - state.scroll_offset + alignment_offset; + + let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; + + window.paint_quad(fill( + Bounds::new( + point(bounds.left() + cursor_x, bounds.top() + y_offset), + size(px(CURSOR_WIDTH), state.line_height), + ), + cursor_color, + )); +} + +impl Focusable for Input { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.input.focus_handle(cx) + } +} + +impl IntoElement for Input { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} diff --git a/crates/input/src/lib.rs b/crates/input/src/lib.rs new file mode 100644 index 0000000..6eea206 --- /dev/null +++ b/crates/input/src/lib.rs @@ -0,0 +1,9 @@ +mod bidi; +mod bindings; +mod blink; +mod handler; +mod input; +mod state; + +pub use input::*; +pub use state::*; diff --git a/crates/input/src/state.rs b/crates/input/src/state.rs new file mode 100644 index 0000000..2993509 --- /dev/null +++ b/crates/input/src/state.rs @@ -0,0 +1,1731 @@ +use std::ops::Range; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use gpui::{ + App, AppContext, Bounds, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, + Pixels, Point, SharedString, Subscription, TextRun, TextStyle, UTF16Selection, Window, + WrappedLine, point, px, +}; +use unicode_segmentation::UnicodeSegmentation; + +use super::bidi::{TextDirection, detect_base_direction}; +use super::bindings::{ + Backspace, Copy, Cut, Delete, DeleteToBeginningOfLine, DeleteToEndOfLine, DeleteWordLeft, + DeleteWordRight, Down, End, Enter, Home, Left, MoveToBeginning, MoveToEnd, Paste, Redo, Right, + SelectAll, SelectDown, SelectLeft, SelectRight, SelectToBeginning, SelectToEnd, SelectUp, + SelectWordLeft, SelectWordRight, Tab, Undo, Up, WordLeft, WordRight, +}; +use super::blink::CursorBlink; +use super::handler::EntityInputHandler; + +/// Default interval for grouping consecutive edits into a single undo entry. +const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300); + +/// Default interval for cursor blinking. +const DEFAULT_BLINK_INTERVAL: Duration = Duration::from_millis(500); + +/// Maximum number of history entries to keep. +const MAX_HISTORY_LEN: usize = 1000; + +/// Events emitted by InputState when significant changes occur. +#[derive(Clone, Debug)] +pub enum InputStateEvent { + /// Emitted when the input gains focus. + Focus, + /// Emitted when the input loses focus. + Blur, + /// Emitted when the text content changes. + TextChanged, + /// Emitted when an undo operation is performed. + Undo, + /// Emitted when a redo operation is performed. + Redo, + /// Emitted when the enter key is pressed. + Enter, +} + +impl EventEmitter for InputState {} + +/// 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)] +struct HistoryEntry { + /// The byte range that was modified (after the edit, for undo; before the edit, for redo). + range: Range, + /// The text that was replaced (to restore on undo). + old_text: String, + /// The length of the new text that replaced old_text (to know how much to remove on undo). + new_text_len: usize, + /// The selection range before the edit. + selected_range: Range, + /// Whether the selection was reversed before the edit. + selection_reversed: bool, + /// Timestamp for grouping consecutive edits. + timestamp: Instant, +} + +impl HistoryEntry { + /// Apply this patch to undo an edit, returning the reverse patch for redo. + 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. + fn apply_redo(&self, content: &mut String) -> HistoryEntry { + // Redo is the same operation as undo - we're reversing the undo + self.apply_undo(content) + } +} + +/// `Input` is the state model for text input components. It handles: +/// - Text content storage and manipulation +/// - Selection and cursor management +/// - Keyboard navigation and editing actions +/// - IME (Input Method Editor) support via `EntityInputHandler` +/// ``` +pub struct InputState { + focus_handle: FocusHandle, + content: String, + placeholder: SharedString, + selected_range: Range, + selection_reversed: bool, + marked_range: Option>, + pub(crate) line_height: Pixels, + pub(crate) line_layouts: Vec, + pub(crate) wrap_width: Option, + pub(crate) text_style: Option, + pub(crate) needs_layout: bool, + is_selecting: bool, + last_click_position: Option>, + click_count: usize, + /// Scroll offset - vertical for multiline, horizontal for single-line + pub(crate) scroll_offset: Pixels, + pub(crate) available_height: Pixels, + pub(crate) available_width: Pixels, + multiline: bool, + /// Stack of previous states for undo. + undo_stack: Vec, + /// Stack of undone states for redo. + redo_stack: Vec, + /// Interval for grouping consecutive edits. + group_interval: Duration, + /// Optional cursor blink state for cursor blinking. + cursor_blink: Option>, + /// Subscriptions (e.g., for blink manager observation). + _subscriptions: Vec, + /// Tracks whether we were focused on the last update. + was_focused: bool, + /// Cached UTF-16 length of content for faster IME operations. + /// Lazily computed when None. + cached_utf16_len: Option, +} + +/// Layout information for a single logical line of text in an input. +/// +/// A logical line corresponds to content between newlines in the input text. +/// When text wrapping is enabled, a logical line may span multiple visual lines. +#[derive(Clone, Debug)] +pub struct InputLineLayout { + /// The byte range in the content string that this line covers. + pub text_range: Range, + /// The shaped and wrapped text for this line, if available. + pub wrapped_line: Option>, + /// The vertical offset from the top of the text area in pixels. + pub y_offset: Pixels, + /// The number of visual lines this logical line spans (due to wrapping). + pub visual_line_count: usize, + /// The base text direction for this line (LTR or RTL). + pub direction: TextDirection, +} + +impl InputState { + /// Creates a new multiline `Input` with empty content. + pub fn new_multiline(cx: &mut Context) -> Self { + Self::new(cx).multiline(true) + } + + /// Creates a new singleline `Input` with empty content. + pub fn new_singleline(cx: &mut Context) -> Self { + Self::new(cx).multiline(false) + } + + /// Creates a new `Input` with the specified multiline setting. + /// Cursor blinking is enabled by default. + pub fn new(cx: &mut Context) -> Self { + let cursor_blink = cx.new(|cx| CursorBlink::new(DEFAULT_BLINK_INTERVAL, cx)); + let blink_subscription = cx.observe(&cursor_blink, |_, _, cx| cx.notify()); + + Self { + focus_handle: cx.focus_handle(), + content: String::new(), + placeholder: SharedString::default(), + selected_range: 0..0, + selection_reversed: false, + marked_range: None, + line_height: px(0.), + line_layouts: Vec::new(), + wrap_width: None, + text_style: None, + needs_layout: true, + is_selecting: false, + last_click_position: None, + click_count: 0, + scroll_offset: px(0.), + available_height: px(0.), + available_width: px(0.), + multiline: false, + undo_stack: Vec::new(), + cached_utf16_len: None, + redo_stack: Vec::new(), + group_interval: DEFAULT_GROUP_INTERVAL, + cursor_blink: Some(cursor_blink), + _subscriptions: vec![blink_subscription], + was_focused: false, + } + } + + /// Sets whether this input allows multiple lines. + pub fn multiline(mut self, multiline: bool) -> Self { + self.multiline = multiline; + self + } + + /// Returns whether this input allows multiple lines. + pub fn is_multiline(&self) -> bool { + self.multiline + } + + /// Enables or disables cursor blinking. + /// + /// Cursor blinking is enabled by default. Call `cursor_blink(false)` to disable it. + /// When enabled, the cursor will blink while the input is focused. + /// Blinking is automatically paused during text editing for immediate feedback. + pub fn cursor_blink(mut self, enabled: bool) -> Self { + if !enabled { + self.cursor_blink = None; + } + self + } + + /// Sets a custom cursor blink interval. + /// + /// This also ensures cursor blinking is enabled. + /// Blinking is automatically paused during text editing for immediate feedback. + pub fn cursor_blink_interval(mut self, interval: Duration, cx: &mut Context) -> Self { + let cursor_blink = cx.new(|cx| CursorBlink::new(interval, cx)); + self._subscriptions + .push(cx.observe(&cursor_blink, |_, _, cx| cx.notify())); + self.cursor_blink = Some(cursor_blink); + self + } + + /// Returns whether the cursor should be visible (for blinking). + /// + /// If blinking is not enabled, always returns `true`. + /// This method also updates the blink manager's enabled state based on focus. + pub fn cursor_visible(&mut self, is_focused: bool, cx: &mut Context) -> bool { + // Update cursor blink based on focus changes + if let Some(cursor_blink) = &self.cursor_blink { + if is_focused && !self.was_focused { + cursor_blink.update(cx, |cb, cx| cb.enable(cx)); + cx.emit(InputStateEvent::Focus); + } else if !is_focused && self.was_focused { + cursor_blink.update(cx, |cb, cx| cb.disable(cx)); + cx.emit(InputStateEvent::Blur); + } + } + self.was_focused = is_focused; + + self.cursor_blink + .as_ref() + .map(|cb| cb.read(cx).visible()) + .unwrap_or(true) + } + + /// Pauses cursor blinking temporarily (e.g., during typing). + fn pause_cursor_blink(&self, cx: &mut Context) { + if let Some(cursor_blink) = &self.cursor_blink { + cursor_blink.update(cx, |cb, cx| cb.pause_blinking(cx)); + } + } + + /// Sets the text style used for layout. Marks layout as dirty if the style changed. + pub(crate) fn set_text_style(&mut self, style: &TextStyle) { + let changed = self.text_style.as_ref() != Some(style); + + if changed { + self.text_style = Some(style.clone()); + self.needs_layout = true; + } + } + + /// Returns the current text content. + pub fn content(&self) -> &str { + &self.content + } + + /// Sets the text content, resetting selection to the beginning. + /// This clears the undo/redo history. + pub fn set_content(&mut self, content: impl Into, cx: &mut Context) { + let content = content.into(); + self.content = if self.multiline { + content + } else { + // Strip newlines for single-line input + content.replace('\n', " ").replace('\r', "") + }; + self.selected_range = 0..0; + self.selection_reversed = false; + self.marked_range = None; + self.needs_layout = true; + self.undo_stack.clear(); + self.redo_stack.clear(); + self.cached_utf16_len = None; + self.pause_cursor_blink(cx); + cx.emit(InputStateEvent::TextChanged); + cx.notify(); + } + + /// Returns whether undo is available. + pub fn can_undo(&self) -> bool { + !self.undo_stack.is_empty() + } + + /// Returns whether redo is available. + pub fn can_redo(&self) -> bool { + !self.redo_stack.is_empty() + } + + /// Sets the interval for grouping consecutive edits into a single undo entry. + pub fn set_group_interval(&mut self, interval: Duration) { + self.group_interval = interval; + } + + /// Records a patch for undo. Called before making changes to content. + /// Returns true if a new entry was created, false if grouped with previous. + fn push_undo_patch(&mut self, range: Range, new_text_len: usize) { + // Don't record during IME composition + if self.marked_range.is_some() { + return; + } + + let now = Instant::now(); + + // Check if we should group with the last entry + if let Some(last) = self.undo_stack.last() + && now.duration_since(last.timestamp) < self.group_interval + { + // Within group interval - extend the existing patch + // We need to merge this edit with the previous one + return; + } + + // Capture the text that will be replaced + let old_text = self.content[range.clone()].to_string(); + + self.undo_stack.push(HistoryEntry { + range: range.start..range.start + new_text_len, + old_text, + new_text_len, + selected_range: self.selected_range.clone(), + selection_reversed: self.selection_reversed, + timestamp: now, + }); + + // Limit history size + if self.undo_stack.len() > MAX_HISTORY_LEN { + self.undo_stack.remove(0); + } + + // New edit invalidates redo stack + self.redo_stack.clear(); + } + + /// Undoes the last edit by applying the reverse patch. + pub(crate) fn undo(&mut self, _: &Undo, _: &mut Window, cx: &mut Context) { + if let Some(entry) = self.undo_stack.pop() { + // Remember selection to restore + let selected_range = entry.selected_range.clone(); + let selection_reversed = entry.selection_reversed; + + // Apply the undo patch and get the redo patch + let redo_entry = entry.apply_undo(&mut self.content); + self.redo_stack.push(redo_entry); + + // Restore selection state + self.selected_range = selected_range; + self.selection_reversed = selection_reversed; + self.needs_layout = true; + self.cached_utf16_len = None; + self.scroll_to_cursor(); + cx.emit(InputStateEvent::Undo); + cx.notify(); + } + } + + /// Redoes the last undone edit by applying the forward patch. + pub(crate) fn redo(&mut self, _: &Redo, _: &mut Window, cx: &mut Context) { + if let Some(entry) = self.redo_stack.pop() { + // Apply the redo patch and get the undo patch + let undo_entry = entry.apply_redo(&mut self.content); + + // The undo entry contains the selection state after the original edit + // We need to restore cursor to end of inserted text + let cursor_pos = undo_entry.range.start; + self.selected_range = cursor_pos..cursor_pos; + self.selection_reversed = false; + + self.undo_stack.push(undo_entry); + self.needs_layout = true; + self.cached_utf16_len = None; + self.scroll_to_cursor(); + cx.emit(InputStateEvent::Redo); + cx.notify(); + } + } + + /// Returns the placeholder text shown when content is empty. + pub fn placeholder(&self) -> &SharedString { + &self.placeholder + } + + /// Sets the placeholder text. + pub fn set_placeholder( + &mut self, + placeholder: impl Into, + cx: &mut Context, + ) { + self.placeholder = placeholder.into(); + cx.notify(); + } + + /// Returns the current selection range. + pub fn selected_range(&self) -> &Range { + &self.selected_range + } + + /// Returns true if the selection is reversed (cursor at start). + pub fn selection_reversed(&self) -> bool { + self.selection_reversed + } + + /// Returns the current cursor offset. + pub fn cursor_offset(&self) -> usize { + if self.selection_reversed { + self.selected_range.start + } else { + self.selected_range.end + } + } + + /// Returns the marked text range (for IME composition). + pub fn marked_range(&self) -> Option<&Range> { + self.marked_range.as_ref() + } + + /// Sets the selection range directly. + pub fn set_selected_range(&mut self, range: Range) { + let range = range.start.min(self.content.len())..range.end.min(self.content.len()); + self.selected_range = range; + self.selection_reversed = false; + } + + /// Returns the selected text range in UTF-16 offsets (for IME). + pub fn selected_text_range_utf16(&self) -> Range { + self.range_to_utf16(&self.selected_range) + } + + /// Inserts text at the current cursor position, replacing any selection. + pub fn insert_text(&mut self, text: &str, cx: &mut Context) { + let range = self + .marked_range + .clone() + .unwrap_or(self.selected_range.clone()); + let range = range.start.min(self.content.len())..range.end.min(self.content.len()); + + let sanitized_text; + let text_to_insert = if self.multiline { + text + } else { + sanitized_text = 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.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(); + } + + /// Deletes the character before the cursor (convenience method for benchmarks). + pub fn delete_backward(&mut self, cx: &mut Context) { + if self.selected_range.is_empty() { + self.select_to(self.previous_boundary(self.cursor_offset()), cx); + } + self.insert_text("", cx); + } + + /// Undoes the last edit (convenience method without Window). + pub fn undo_action(&mut self, cx: &mut Context) { + if let Some(entry) = self.undo_stack.pop() { + let selected_range = entry.selected_range.clone(); + let selection_reversed = entry.selection_reversed; + + let redo_entry = entry.apply_undo(&mut self.content); + self.redo_stack.push(redo_entry); + + self.selected_range = selected_range; + self.selection_reversed = selection_reversed; + self.needs_layout = true; + self.cached_utf16_len = None; + self.scroll_to_cursor(); + cx.emit(InputStateEvent::Undo); + cx.notify(); + } + } + + /// Redoes the last undone edit (convenience method without Window). + pub fn redo_action(&mut self, cx: &mut Context) { + if let Some(entry) = self.redo_stack.pop() { + let undo_entry = entry.apply_redo(&mut self.content); + + let cursor_pos = undo_entry.range.start; + self.selected_range = cursor_pos..cursor_pos; + self.selection_reversed = false; + + self.undo_stack.push(undo_entry); + self.needs_layout = true; + self.cached_utf16_len = None; + self.scroll_to_cursor(); + cx.emit(InputStateEvent::Redo); + cx.notify(); + } + } + + /// Selects all text. + pub fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { + self.selected_range = 0..self.content.len(); + self.selection_reversed = false; + cx.notify(); + } + + pub(crate) fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + let new_pos = self.previous_boundary(self.cursor_offset()); + self.move_to(new_pos, cx); + } else { + self.move_to(self.selected_range.start, cx); + } + } + + pub(crate) fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + let new_pos = self.next_boundary(self.cursor_offset()); + self.move_to(new_pos, cx); + } else { + self.move_to(self.selected_range.end, cx); + } + } + + pub(crate) fn up(&mut self, _: &Up, _window: &mut Window, cx: &mut Context) { + self.pause_cursor_blink(cx); + if !self.multiline { + // In single-line mode, up moves to start + self.selected_range = 0..0; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + return; + } + if let Some(new_offset) = self.move_vertically(self.cursor_offset(), -1) { + self.selected_range = new_offset..new_offset; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + } + } + + pub(crate) fn down(&mut self, _: &Down, _window: &mut Window, cx: &mut Context) { + self.pause_cursor_blink(cx); + if !self.multiline { + // In single-line mode, down moves to end + let end = self.content.len(); + self.selected_range = end..end; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + return; + } + if let Some(new_offset) = self.move_vertically(self.cursor_offset(), 1) { + self.selected_range = new_offset..new_offset; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + } + } + + pub(crate) fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { + self.select_to(self.previous_boundary(self.cursor_offset()), cx); + } + + pub(crate) fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { + self.select_to(self.next_boundary(self.cursor_offset()), cx); + } + + pub(crate) fn select_up(&mut self, _: &SelectUp, _window: &mut Window, cx: &mut Context) { + self.pause_cursor_blink(cx); + if !self.multiline { + // In single-line mode, select_up selects to start + self.select_to(0, cx); + return; + } + if let Some(new_offset) = self.move_vertically(self.cursor_offset(), -1) { + if self.selection_reversed { + self.selected_range.start = new_offset; + } else { + self.selected_range.end = new_offset; + } + if self.selected_range.end < self.selected_range.start { + self.selection_reversed = !self.selection_reversed; + self.selected_range = self.selected_range.end..self.selected_range.start; + } + self.scroll_to_cursor(); + cx.notify(); + } + } + + pub(crate) fn select_down( + &mut self, + _: &SelectDown, + _window: &mut Window, + cx: &mut Context, + ) { + self.pause_cursor_blink(cx); + if !self.multiline { + // In single-line mode, select_down selects to end + self.select_to(self.content.len(), cx); + return; + } + if let Some(new_offset) = self.move_vertically(self.cursor_offset(), 1) { + if self.selection_reversed { + self.selected_range.start = new_offset; + } else { + self.selected_range.end = new_offset; + } + if self.selected_range.end < self.selected_range.start { + self.selection_reversed = !self.selection_reversed; + self.selected_range = self.selected_range.end..self.selected_range.start; + } + self.scroll_to_cursor(); + cx.notify(); + } + } + + pub(crate) fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context) { + let line_start = self.find_line_start(self.cursor_offset()); + self.move_to(line_start, cx); + } + + pub(crate) fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context) { + let line_end = self.find_line_end(self.cursor_offset()); + self.move_to(line_end, cx); + } + + pub(crate) fn move_to_beginning( + &mut self, + _: &MoveToBeginning, + _: &mut Window, + cx: &mut Context, + ) { + self.move_to(0, cx); + } + + pub(crate) fn move_to_end(&mut self, _: &MoveToEnd, _: &mut Window, cx: &mut Context) { + self.move_to(self.content.len(), cx); + } + + pub(crate) fn select_to_beginning( + &mut self, + _: &SelectToBeginning, + _: &mut Window, + cx: &mut Context, + ) { + self.select_to(0, cx); + } + + pub(crate) fn select_to_end( + &mut self, + _: &SelectToEnd, + _: &mut Window, + cx: &mut Context, + ) { + self.select_to(self.content.len(), cx); + } + + pub(crate) fn word_left(&mut self, _: &WordLeft, _: &mut Window, cx: &mut Context) { + let new_pos = self.previous_word_boundary(self.cursor_offset()); + self.move_to(new_pos, cx); + } + + pub(crate) fn word_right(&mut self, _: &WordRight, _: &mut Window, cx: &mut Context) { + let new_pos = self.next_word_boundary(self.cursor_offset()); + self.move_to(new_pos, cx); + } + + pub(crate) fn select_word_left( + &mut self, + _: &SelectWordLeft, + _: &mut Window, + cx: &mut Context, + ) { + let new_pos = self.previous_word_boundary(self.cursor_offset()); + self.select_to(new_pos, cx); + } + + pub(crate) fn select_word_right( + &mut self, + _: &SelectWordRight, + _: &mut Window, + cx: &mut Context, + ) { + let new_pos = self.next_word_boundary(self.cursor_offset()); + self.select_to(new_pos, cx); + } + + pub(crate) fn enter(&mut self, _: &Enter, window: &mut Window, cx: &mut Context) { + if self.multiline { + self.replace_text_in_range(None, "\n", window, cx); + } + } + + pub(crate) fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { + self.replace_text_in_range(None, "\t", window, cx); + } + + pub(crate) fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + self.select_to(self.previous_boundary(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(crate) fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + self.select_to(self.next_boundary(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(crate) fn delete_word_left( + &mut self, + _: &DeleteWordLeft, + window: &mut Window, + cx: &mut Context, + ) { + if self.selected_range.is_empty() { + self.select_to(self.previous_word_boundary(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(crate) fn delete_word_right( + &mut self, + _: &DeleteWordRight, + window: &mut Window, + cx: &mut Context, + ) { + if self.selected_range.is_empty() { + self.select_to(self.next_word_boundary(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(crate) fn delete_to_beginning_of_line( + &mut self, + _: &DeleteToBeginningOfLine, + window: &mut Window, + cx: &mut Context, + ) { + if self.selected_range.is_empty() { + self.select_to(self.find_line_start(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(crate) fn delete_to_end_of_line( + &mut self, + _: &DeleteToEndOfLine, + window: &mut Window, + cx: &mut Context, + ) { + if self.selected_range.is_empty() { + self.select_to(self.find_line_end(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(crate) fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { + if self.multiline { + self.replace_text_in_range(None, &text, window, cx); + } else { + // Strip newlines for single-line input + let text = text.replace('\n', " ").replace('\r', ""); + self.replace_text_in_range(None, &text, window, cx); + } + } + } + + pub(crate) fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { + if !self.selected_range.is_empty() { + cx.write_to_clipboard(ClipboardItem::new_string( + self.content[self.selected_range.clone()].to_string(), + )); + } + } + + pub(crate) fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { + if !self.selected_range.is_empty() { + // Cut selected text + cx.write_to_clipboard(ClipboardItem::new_string( + self.content[self.selected_range.clone()].to_string(), + )); + self.replace_text_in_range(None, "", window, cx); + } else { + // No selection: cut the entire current line (including newline) + let cursor = self.cursor_offset(); + let line_start = self.find_line_start(cursor); + let line_end = self.find_line_end(cursor); + + // Include the newline character if there is one after the line + let cut_end = if line_end < self.content.len() { + line_end + 1 // Include the newline + } else if line_start > 0 { + // Last line with no trailing newline - include preceding newline instead + line_end + } else { + line_end + }; + + // For last line, also remove the preceding newline if it exists + let cut_start = if line_end >= self.content.len() && line_start > 0 { + line_start - 1 // Include preceding newline for last line + } else { + line_start + }; + + let line_text = self.content[cut_start..cut_end].to_string(); + cx.write_to_clipboard(ClipboardItem::new_string(line_text)); + + self.selected_range = cut_start..cut_end; + self.replace_text_in_range(None, "", window, cx); + } + } + + pub(crate) fn on_mouse_down( + &mut self, + position: Point, + click_count: usize, + shift: bool, + window: &mut Window, + cx: &mut Context, + ) { + window.focus(&self.focus_handle, cx); + self.is_selecting = true; + + let is_same_position = self + .last_click_position + .map(|last| { + let threshold = px(4.); + (position.x - last.x).abs() < threshold && (position.y - last.y).abs() < threshold + }) + .unwrap_or(false); + + if is_same_position && click_count > 1 { + self.click_count = click_count; + } else { + self.click_count = 1; + } + self.last_click_position = Some(position); + + let clicked_offset = self.index_for_position(position); + + match self.click_count { + 2 => { + let (word_start, word_end) = self.word_range_at(clicked_offset); + self.selected_range = word_start..word_end; + self.selection_reversed = false; + cx.notify(); + } + 3 => { + let line_start = self.find_line_start(clicked_offset); + let line_end = self.find_line_end(clicked_offset); + let line_end_with_newline = if line_end < self.content.len() { + line_end + 1 + } else { + line_end + }; + self.selected_range = line_start..line_end_with_newline; + self.selection_reversed = false; + cx.notify(); + } + _ => { + if shift { + self.select_to(clicked_offset, cx); + } else { + self.move_to(clicked_offset, cx); + } + } + } + } + + pub(crate) fn on_mouse_up(&mut self, _cx: &mut Context) { + self.is_selecting = false; + } + + pub(crate) fn on_mouse_move(&mut self, position: Point, cx: &mut Context) { + if self.is_selecting && self.click_count == 1 { + self.select_to(self.index_for_position(position), cx); + } + } + + fn move_to(&mut self, offset: usize, cx: &mut Context) { + self.pause_cursor_blink(cx); + let offset = offset.min(self.content.len()); + self.selected_range = offset..offset; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + } + + fn select_to(&mut self, offset: usize, cx: &mut Context) { + self.pause_cursor_blink(cx); + let offset = offset.min(self.content.len()); + if self.selection_reversed { + self.selected_range.start = offset; + } else { + self.selected_range.end = offset; + } + if self.selected_range.end < self.selected_range.start { + self.selection_reversed = !self.selection_reversed; + self.selected_range = self.selected_range.end..self.selected_range.start; + } + self.scroll_to_cursor(); + cx.notify(); + } + + pub(crate) fn find_line_start(&self, offset: usize) -> usize { + self.content[..offset.min(self.content.len())] + .rfind('\n') + .map(|pos| pos + 1) + .unwrap_or(0) + } + + pub(crate) fn find_line_end(&self, offset: usize) -> usize { + self.content[offset.min(self.content.len())..] + .find('\n') + .map(|pos| offset + pos) + .unwrap_or(self.content.len()) + } + + /// Returns the text direction for a specific line layout by index. + pub fn line_direction(&self, line_idx: usize) -> TextDirection { + self.line_layouts + .get(line_idx) + .map(|layout| layout.direction) + .unwrap_or_default() + } + + /// Returns the text direction at a given byte offset in the content. + pub fn direction_at_offset(&self, offset: usize) -> TextDirection { + let offset = offset.min(self.content.len()); + for layout in &self.line_layouts { + if offset >= layout.text_range.start && offset <= layout.text_range.end { + return layout.direction; + } + } + TextDirection::default() + } + + fn move_vertically(&self, offset: usize, direction: i32) -> Option { + let (visual_line_idx, x_pixels) = self.find_visual_line_and_x_offset(offset); + let target_visual_line_idx = (visual_line_idx as i32 + direction).max(0) as usize; + + let mut current_visual_line = 0; + for layout in self.line_layouts.iter() { + let visual_lines_in_layout = layout.visual_line_count; + + if target_visual_line_idx < current_visual_line + visual_lines_in_layout { + let visual_line_within_layout = target_visual_line_idx - current_visual_line; + + if layout.text_range.is_empty() { + return Some(layout.text_range.start); + } + + if let Some(wrapped) = &layout.wrapped_line { + let y_within_wrapped = self.line_height * visual_line_within_layout as f32; + let target_point = point(px(x_pixels), y_within_wrapped); + + let closest_result = + wrapped.closest_index_for_position(target_point, self.line_height); + + let closest_idx = closest_result.unwrap_or_else(|closest| closest); + let clamped = closest_idx.min(wrapped.text.len()); + let result = layout.text_range.start + clamped; + + return Some(result); + } + + return Some(layout.text_range.start); + } + + current_visual_line += visual_lines_in_layout; + } + + if direction > 0 { + Some(self.content.len()) + } else { + None + } + } + + fn find_visual_line_and_x_offset(&self, offset: usize) -> (usize, f32) { + if self.line_layouts.is_empty() { + return (0, 0.0); + } + + let mut visual_line_idx = 0; + + for line in &self.line_layouts { + if line.text_range.is_empty() { + if offset == line.text_range.start { + return (visual_line_idx, 0.0); + } + } else if offset >= line.text_range.start && offset <= line.text_range.end { + if let Some(wrapped) = &line.wrapped_line { + let local_offset = (offset - line.text_range.start).min(wrapped.text.len()); + if let Some(position) = + wrapped.position_for_index(local_offset, self.line_height) + { + let visual_line_within = (position.y / self.line_height).floor() as usize; + return (visual_line_idx + visual_line_within, position.x.into()); + } + } + return (visual_line_idx, 0.0); + } + visual_line_idx += line.visual_line_count; + } + + (visual_line_idx.saturating_sub(1), 0.0) + } + + pub(crate) fn index_for_position(&self, position: Point) -> usize { + if self.content.is_empty() { + return 0; + } + + for line in self.line_layouts.iter() { + let line_height_total = self.line_height * line.visual_line_count as f32; + + if position.y >= line.y_offset && position.y < line.y_offset + line_height_total { + if line.text_range.is_empty() { + return line.text_range.start; + } + + if let Some(wrapped) = &line.wrapped_line { + let relative_y = position.y - line.y_offset; + let relative_point = point(position.x, relative_y); + + let closest_result = + wrapped.closest_index_for_position(relative_point, self.line_height); + + let local_idx = closest_result.unwrap_or_else(|closest| closest); + let clamped = local_idx.min(wrapped.text.len()); + return line.text_range.start + clamped; + } + return line.text_range.start; + } + } + + self.content.len() + } + + pub(crate) fn scroll_to_cursor(&mut self) { + if self.line_layouts.is_empty() { + return; + } + + let cursor_offset = if self.selection_reversed { + self.selected_range.start + } else { + self.selected_range.end + }; + + if self.multiline { + self.scroll_to_cursor_vertical(cursor_offset); + } else { + self.scroll_to_cursor_horizontal(cursor_offset); + } + } + + fn scroll_to_cursor_vertical(&mut self, cursor_offset: usize) { + if self.available_height <= px(0.) { + return; + } + + let line_height = self.line_height; + + for line in &self.line_layouts { + let is_cursor_in_line = if line.text_range.is_empty() { + cursor_offset == line.text_range.start + } else { + line.text_range.contains(&cursor_offset) + || (cursor_offset == line.text_range.end && cursor_offset == self.content.len()) + }; + + if is_cursor_in_line { + let cursor_visual_y = if let Some(wrapped) = &line.wrapped_line { + let local_offset = cursor_offset.saturating_sub(line.text_range.start); + if let Some(position) = + wrapped.position_for_index(local_offset, self.line_height) + { + line.y_offset + position.y + } else { + line.y_offset + } + } else { + line.y_offset + }; + + let visible_top = self.scroll_offset; + let visible_bottom = self.scroll_offset + self.available_height; + + if cursor_visual_y < visible_top { + self.scroll_offset = cursor_visual_y; + } else if cursor_visual_y + line_height > visible_bottom { + self.scroll_offset = (cursor_visual_y + line_height) - self.available_height; + } + + self.scroll_offset = self.scroll_offset.max(px(0.)); + break; + } + } + } + + fn scroll_to_cursor_horizontal(&mut self, cursor_offset: usize) { + if self.available_width <= px(0.) { + return; + } + + // For single-line input, get cursor x position from the first (only) line + let Some(line) = self.line_layouts.first() else { + return; + }; + + let cursor_x = if let Some(wrapped) = &line.wrapped_line { + let local_offset = cursor_offset.saturating_sub(line.text_range.start); + wrapped + .position_for_index(local_offset, self.line_height) + .map(|p| p.x) + .unwrap_or(px(0.)) + } else { + px(0.) + }; + + let visible_left = self.scroll_offset; + let visible_right = self.scroll_offset + self.available_width; + + // Add some padding so cursor isn't right at the edge + let padding = px(2.0); + + if cursor_x < visible_left + padding { + self.scroll_offset = (cursor_x - padding).max(px(0.)); + } else if cursor_x > visible_right - padding { + self.scroll_offset = cursor_x - self.available_width + padding; + } + + self.scroll_offset = self.scroll_offset.max(px(0.)); + } + + pub(crate) fn update_line_layouts( + &mut self, + width: Pixels, + line_height: Pixels, + text_style: &TextStyle, + window: &mut Window, + ) { + self.line_height = line_height; + self.set_text_style(text_style); + + if !self.needs_layout && self.wrap_width == Some(width) { + return; + } + + self.line_layouts.clear(); + self.wrap_width = Some(width); + + let text_color = text_style.color; + let font_size = text_style.font_size.to_pixels(window.rem_size()); + + if self.content.is_empty() { + self.line_layouts.push(InputLineLayout { + text_range: 0..0, + wrapped_line: None, + y_offset: px(0.), + visual_line_count: 1, + direction: TextDirection::default(), + }); + self.needs_layout = false; + return; + } + + let mut last_direction = TextDirection::default(); + + let mut y_offset = px(0.); + let mut current_pos = 0; + + while current_pos < self.content.len() { + let line_end = self.content[current_pos..] + .find('\n') + .map(|pos| current_pos + pos) + .unwrap_or(self.content.len()); + + let line_text = &self.content[current_pos..line_end]; + + if line_text.is_empty() { + self.line_layouts.push(InputLineLayout { + text_range: current_pos..current_pos, + wrapped_line: None, + y_offset, + visual_line_count: 1, + direction: last_direction, + }); + y_offset += line_height; + } else { + let direction = detect_base_direction(line_text); + last_direction = direction; + let run = TextRun { + len: line_text.len(), + font: text_style.font(), + color: text_color, + background_color: None, + underline: None, + strikethrough: None, + }; + + let wrapped_lines = window + .text_system() + .shape_text( + SharedString::from(line_text.to_string()), + font_size, + &[run], + Some(width), + None, + ) + .unwrap_or_default(); + + for wrapped in wrapped_lines { + let visual_line_count = wrapped.wrap_boundaries().len() + 1; + let line_height_total = line_height * visual_line_count as f32; + + self.line_layouts.push(InputLineLayout { + text_range: current_pos..line_end, + wrapped_line: Some(Arc::new(wrapped)), + y_offset, + visual_line_count, + direction, + }); + + y_offset += line_height_total; + } + } + + current_pos = if line_end < self.content.len() { + line_end + 1 + } else { + self.content.len() + }; + } + + if self.content.ends_with('\n') { + self.line_layouts.push(InputLineLayout { + text_range: self.content.len()..self.content.len(), + wrapped_line: None, + y_offset, + visual_line_count: 1, + direction: last_direction, + }); + } + + self.needs_layout = false; + self.scroll_to_cursor(); + } + + pub(crate) fn total_content_height(&self) -> Pixels { + self.line_layouts + .last() + .map(|last| last.y_offset + self.line_height * last.visual_line_count as f32) + .unwrap_or(px(0.)) + } + + /// Returns true if the scroll position is at the top. + pub fn at_top(&self) -> bool { + self.scroll_offset <= px(0.) + } + + /// Returns true if the scroll position is at the bottom. + pub fn at_bottom(&self) -> bool { + let content_height = self.total_content_height(); + let visible_height = self.available_height; + + if content_height <= visible_height { + return true; + } + + self.scroll_offset + visible_height >= content_height + } + + /// Returns the scroll progress as a value from 0.0 (top) to 1.0 (bottom). + pub fn scroll_progress(&self) -> f32 { + let content_height = self.total_content_height(); + let visible_height = self.available_height; + let max_scroll = content_height - visible_height; + + if max_scroll <= px(0.) { + return 0.0; + } + + (self.scroll_offset / max_scroll).clamp(0.0, 1.0) + } + + /// Returns how far the content is scrolled from the top in pixels. + pub fn distance_from_top(&self) -> Pixels { + self.scroll_offset.max(px(0.)) + } + + /// Returns how far the content is from the bottom in pixels. + pub fn distance_from_bottom(&self) -> Pixels { + let content_height = self.total_content_height(); + let visible_height = self.available_height; + let max_scroll = content_height - visible_height; + + if max_scroll <= px(0.) { + return px(0.); + } + + (max_scroll - self.scroll_offset).max(px(0.)) + } + + fn offset_from_utf16(&self, offset: usize) -> usize { + // Fast path: if offset is 0, return 0 + if offset == 0 { + return 0; + } + + // Fast path: if we have cached length and offset is at or past end + if let Some(utf16_len) = self.cached_utf16_len + && offset >= utf16_len + { + return self.content.len(); + } + + let mut utf8_offset = 0; + let mut utf16_count = 0; + + for character in self.content.chars() { + if utf16_count >= offset { + break; + } + utf16_count += character.len_utf16(); + utf8_offset += character.len_utf8(); + } + + utf8_offset.min(self.content.len()) + } + + fn offset_to_utf16(&self, offset: usize) -> usize { + // Fast path: if offset is 0, return 0 + if offset == 0 { + return 0; + } + + // Fast path: if offset is at or past end, return cached length + if offset >= self.content.len() { + return self.utf16_len(); + } + + let mut utf16_offset = 0; + let mut utf8_count = 0; + + for character in self.content.chars() { + if utf8_count >= offset { + break; + } + utf8_count += character.len_utf8(); + utf16_offset += character.len_utf16(); + } + + utf16_offset + } + + /// Returns the UTF-16 length of the content, computing and caching if necessary. + fn utf16_len(&self) -> usize { + if let Some(len) = self.cached_utf16_len { + return len; + } + self.content.chars().map(|c| c.len_utf16()).sum() + } + + fn range_to_utf16(&self, range: &Range) -> Range { + self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end) + } + + fn range_from_utf16(&self, range_utf16: &Range) -> Range { + self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end) + } + + fn previous_boundary(&self, offset: usize) -> usize { + if offset == 0 { + return 0; + } + + let text_before = &self.content[..offset.min(self.content.len())]; + text_before + .grapheme_indices(true) + .map(|(i, _)| i) + .next_back() + .unwrap_or(0) + } + + fn next_boundary(&self, offset: usize) -> usize { + if offset >= self.content.len() { + return self.content.len(); + } + + let text_after = &self.content[offset..]; + text_after + .grapheme_indices(true) + .nth(1) + .map(|(i, _)| offset + i) + .unwrap_or(self.content.len()) + } + + fn previous_word_boundary(&self, offset: usize) -> usize { + if offset == 0 { + return 0; + } + + let text_before = &self.content[..offset.min(self.content.len())]; + + let mut last_word_start = 0; + for (idx, _) in text_before.unicode_word_indices() { + if idx < offset { + last_word_start = idx; + } + } + + if last_word_start == 0 && offset > 0 { + let trimmed = text_before.trim_end(); + if trimmed.is_empty() { + return 0; + } + for (idx, _) in trimmed.unicode_word_indices() { + last_word_start = idx; + } + } + + last_word_start + } + + fn next_word_boundary(&self, offset: usize) -> usize { + if offset >= self.content.len() { + return self.content.len(); + } + + let text_after = &self.content[offset..]; + + for (idx, word) in text_after.unicode_word_indices() { + let word_end = offset + idx + word.len(); + if word_end > offset { + return word_end; + } + } + + self.content.len() + } + + fn word_range_at(&self, offset: usize) -> (usize, usize) { + let offset = offset.min(self.content.len()); + + for (idx, word) in self.content.unicode_word_indices() { + let word_end = idx + word.len(); + if offset >= idx && offset <= word_end { + return (idx, word_end); + } + } + + (offset, offset) + } +} + +impl EntityInputHandler for InputState { + fn text_for_range( + &mut self, + range_utf16: Range, + adjusted_range: &mut Option>, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + let range = self.range_from_utf16(&range_utf16); + let clamped_range = range.start.min(self.content.len())..range.end.min(self.content.len()); + adjusted_range.replace(self.range_to_utf16(&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, + ) -> Option { + Some(UTF16Selection { + range: self.range_to_utf16(&self.selected_range), + reversed: self.selection_reversed, + }) + } + + fn marked_text_range( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + self.marked_range + .as_ref() + .map(|range| self.range_to_utf16(range)) + } + + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { + self.marked_range = None; + } + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + _window: &mut Window, + cx: &mut Context, + ) { + let range = range_utf16 + .as_ref() + .map(|range_utf16| self.range_from_utf16(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.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>, + new_text: &str, + new_selected_range_utf16: Option>, + _window: &mut Window, + cx: &mut Context, + ) { + let range = range_utf16 + .as_ref() + .map(|range_utf16| self.range_from_utf16(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.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.range_from_utf16(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, + bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + let range = self.range_from_utf16(&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) + && 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, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + let index = self.index_for_position(point); + Some(self.offset_to_utf16(index)) + } +} + +impl Focusable for InputState { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +}