Skip to main content

ccf_gpui_widgets/widgets/
text_input.rs

1//! Text input widget
2//!
3//! A full-featured text input with cursor positioning, selection, and clipboard support.
4//! Uses GPUI's text shaping APIs for accurate cursor positioning with variable-width fonts.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use ccf_gpui_widgets::widgets::TextInput;
10//!
11//! // Register keybindings at app startup
12//! ccf_gpui_widgets::widgets::text_input::register_keybindings(cx);
13//!
14//! // Create a text input
15//! let input = cx.new(|cx| TextInput::new(cx).placeholder("Enter text..."));
16//!
17//! // Subscribe to events
18//! cx.subscribe(&input, |this, _input, event: &TextInputEvent, cx| {
19//!     match event {
20//!         TextInputEvent::Change => {
21//!             // Handle content change
22//!         }
23//!         TextInputEvent::Enter => {
24//!             // Handle Enter key
25//!         }
26//!         _ => {}
27//!     }
28//! }).detach();
29//! ```
30
31use std::borrow::Cow;
32use std::time::Duration;
33
34use gpui::prelude::*;
35use gpui::*;
36
37use crate::theme::{get_theme_or, Theme};
38use super::cursor_blink::CursorBlink;
39use super::editing_core::EditingCore;
40use super::focus_navigation::{FocusNext, FocusPrev};
41
42// Actions for keyboard handling
43actions!(
44    ccf_text_input,
45    [
46        MoveLeft,
47        MoveRight,
48        MoveWordLeft,
49        MoveWordRight,
50        MoveToStart,
51        MoveToEnd,
52        SelectLeft,
53        SelectRight,
54        SelectWordLeft,
55        SelectWordRight,
56        SelectToStart,
57        SelectToEnd,
58        SelectAll,
59        DeleteBackward,
60        DeleteForward,
61        DeleteWordBackward,
62        DeleteWordForward,
63        Cut,
64        Copy,
65        Paste,
66        Enter,
67        Escape,
68    ]
69);
70
71/// Register key bindings for text input components
72///
73/// Call this once at application startup:
74/// ```ignore
75/// ccf_gpui_widgets::widgets::text_input::register_keybindings(cx);
76/// ```
77pub fn register_keybindings(cx: &mut App) {
78    cx.bind_keys([
79        // Navigation
80        KeyBinding::new("left", MoveLeft, Some("CcfTextInput")),
81        KeyBinding::new("right", MoveRight, Some("CcfTextInput")),
82        KeyBinding::new("home", MoveToStart, Some("CcfTextInput")),
83        KeyBinding::new("end", MoveToEnd, Some("CcfTextInput")),
84        // Selection
85        KeyBinding::new("shift-left", SelectLeft, Some("CcfTextInput")),
86        KeyBinding::new("shift-right", SelectRight, Some("CcfTextInput")),
87        KeyBinding::new("shift-home", SelectToStart, Some("CcfTextInput")),
88        KeyBinding::new("shift-end", SelectToEnd, Some("CcfTextInput")),
89        // Delete
90        KeyBinding::new("backspace", DeleteBackward, Some("CcfTextInput")),
91        KeyBinding::new("delete", DeleteForward, Some("CcfTextInput")),
92        // Actions
93        KeyBinding::new("enter", Enter, Some("CcfTextInput")),
94        KeyBinding::new("escape", Escape, Some("CcfTextInput")),
95        // Note: Tab/ShiftTab not bound here - let GPUI handle tab navigation
96    ]);
97
98    // Platform-specific bindings
99    #[cfg(target_os = "macos")]
100    cx.bind_keys([
101        // Clipboard
102        KeyBinding::new("cmd-a", SelectAll, Some("CcfTextInput")),
103        KeyBinding::new("cmd-c", Copy, Some("CcfTextInput")),
104        KeyBinding::new("cmd-x", Cut, Some("CcfTextInput")),
105        KeyBinding::new("cmd-v", Paste, Some("CcfTextInput")),
106        // Word navigation (Option+Arrow on macOS)
107        KeyBinding::new("alt-left", MoveWordLeft, Some("CcfTextInput")),
108        KeyBinding::new("alt-right", MoveWordRight, Some("CcfTextInput")),
109        KeyBinding::new("shift-alt-left", SelectWordLeft, Some("CcfTextInput")),
110        KeyBinding::new("shift-alt-right", SelectWordRight, Some("CcfTextInput")),
111        // Word delete
112        KeyBinding::new("alt-backspace", DeleteWordBackward, Some("CcfTextInput")),
113        KeyBinding::new("alt-delete", DeleteWordForward, Some("CcfTextInput")),
114    ]);
115
116    #[cfg(not(target_os = "macos"))]
117    cx.bind_keys([
118        // Clipboard
119        KeyBinding::new("ctrl-a", SelectAll, Some("CcfTextInput")),
120        KeyBinding::new("ctrl-c", Copy, Some("CcfTextInput")),
121        KeyBinding::new("ctrl-x", Cut, Some("CcfTextInput")),
122        KeyBinding::new("ctrl-v", Paste, Some("CcfTextInput")),
123        // Word navigation (Ctrl+Arrow on Windows/Linux)
124        KeyBinding::new("ctrl-left", MoveWordLeft, Some("CcfTextInput")),
125        KeyBinding::new("ctrl-right", MoveWordRight, Some("CcfTextInput")),
126        KeyBinding::new("shift-ctrl-left", SelectWordLeft, Some("CcfTextInput")),
127        KeyBinding::new("shift-ctrl-right", SelectWordRight, Some("CcfTextInput")),
128        // Word delete
129        KeyBinding::new("ctrl-backspace", DeleteWordBackward, Some("CcfTextInput")),
130        KeyBinding::new("ctrl-delete", DeleteWordForward, Some("CcfTextInput")),
131    ]);
132}
133
134/// Events emitted by TextInput
135#[derive(Clone, Debug)]
136pub enum TextInputEvent {
137    /// Content changed (use `state.read(cx).content()` to get the new value)
138    Change,
139    /// Enter key pressed
140    Enter,
141    /// Escape key pressed (use to cancel editing and return focus to parent)
142    Escape,
143    /// Input lost focus
144    Blur,
145    /// Input gained focus
146    Focus,
147    /// Tab key pressed (only emitted when emit_tab_events is true)
148    Tab,
149    /// Shift+Tab key pressed (only emitted when emit_tab_events is true)
150    ShiftTab,
151}
152
153/// Character used to mask password input
154const MASK_CHAR: &str = "\u{25CF}"; // ● Black circle
155
156/// Text input widget state
157pub struct TextInput {
158    /// Core editing logic
159    core: EditingCore<String>,
160    /// Placeholder text
161    placeholder: Option<SharedString>,
162    /// Focus handle for keyboard focus
163    focus_handle: FocusHandle,
164    /// Whether to select all text when focused
165    pub select_on_focus: bool,
166    /// Horizontal scroll offset in pixels
167    scroll_offset: f32,
168    /// Visible width of the text area
169    visible_width: f32,
170    /// Left edge of content area in window coordinates
171    content_origin_x: f32,
172    /// Track previous focus state
173    was_focused: bool,
174    /// Whether focus-out subscription has been set up
175    focus_out_subscribed: bool,
176    /// Cursor blink state
177    cursor_blink: CursorBlink,
178    /// Whether blink timer is set up
179    blink_timer_active: bool,
180    /// Optional custom theme
181    custom_theme: Option<Theme>,
182    /// Whether currently dragging to select text
183    is_dragging: bool,
184    /// Whether auto-scroll timer is active
185    auto_scroll_active: bool,
186    /// Current auto-scroll speed (pixels per frame, positive = scroll right)
187    auto_scroll_speed: f32,
188    /// Whether to render without border/background (for embedding in other controls)
189    borderless: bool,
190    /// Optional filter for allowed input characters
191    input_filter: Option<Box<dyn Fn(char) -> bool>>,
192    /// Whether to emit Tab/ShiftTab events instead of handling focus navigation
193    emit_tab_events: bool,
194    /// Whether the input is enabled
195    enabled: bool,
196}
197
198impl EventEmitter<TextInputEvent> for TextInput {}
199
200impl Focusable for TextInput {
201    fn focus_handle(&self, _cx: &App) -> FocusHandle {
202        self.focus_handle.clone()
203    }
204}
205
206impl TextInput {
207    /// Create a new text input
208    pub fn new(cx: &mut Context<Self>) -> Self {
209        Self {
210            core: EditingCore::new(),
211            placeholder: None,
212            focus_handle: cx.focus_handle().tab_stop(true),
213            select_on_focus: false,
214            scroll_offset: 0.0,
215            visible_width: 200.0,
216            content_origin_x: 0.0,
217            was_focused: false,
218            focus_out_subscribed: false,
219            cursor_blink: CursorBlink::new(),
220            blink_timer_active: false,
221            custom_theme: None,
222            is_dragging: false,
223            auto_scroll_active: false,
224            auto_scroll_speed: 0.0,
225            borderless: false,
226            input_filter: None,
227            emit_tab_events: false,
228            enabled: true,
229        }
230    }
231
232    /// Set placeholder text (builder pattern)
233    #[must_use]
234    pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
235        self.placeholder = Some(text.into());
236        self
237    }
238
239    /// Set initial value (builder pattern)
240    #[must_use]
241    pub fn with_value(mut self, text: impl Into<String>) -> Self {
242        let text = text.into();
243        self.core.set_content(&text);
244        self
245    }
246
247    /// Set custom theme (builder pattern)
248    #[must_use]
249    pub fn theme(mut self, theme: Theme) -> Self {
250        self.custom_theme = Some(theme);
251        self
252    }
253
254    /// Set select on focus (builder pattern)
255    #[must_use]
256    pub fn select_on_focus(mut self, select: bool) -> Self {
257        self.select_on_focus = select;
258        self
259    }
260
261    /// Set masked mode for password input (builder pattern)
262    ///
263    /// When masked, the input displays bullet characters instead of the actual text,
264    /// while retaining full editing functionality (cursor movement, selection, etc.)
265    #[must_use]
266    pub fn masked(mut self, masked: bool) -> Self {
267        self.core.set_masked(masked);
268        self
269    }
270
271    /// Check if this input is in masked mode
272    pub fn is_masked(&self) -> bool {
273        self.core.is_masked()
274    }
275
276    /// Set masked mode programmatically
277    pub fn set_masked(&mut self, masked: bool, cx: &mut Context<Self>) {
278        self.core.set_masked(masked);
279        cx.notify();
280    }
281
282    /// Set borderless mode for embedding in other controls (builder pattern)
283    ///
284    /// When borderless, the input renders without its own border, background,
285    /// and rounded corners, allowing it to be embedded in unified containers.
286    #[must_use]
287    pub fn borderless(mut self, borderless: bool) -> Self {
288        self.borderless = borderless;
289        self
290    }
291
292    /// Set an input filter to restrict allowed characters (builder pattern)
293    ///
294    /// The filter function receives each character and should return `true` to allow it.
295    /// Characters that fail the filter are silently dropped during typing and pasting.
296    ///
297    /// # Example
298    /// ```ignore
299    /// TextInput::new(cx)
300    ///     .input_filter(|c| c.is_ascii_digit() || c == '.' || c == '-')
301    /// ```
302    #[must_use]
303    pub fn input_filter(mut self, filter: impl Fn(char) -> bool + 'static) -> Self {
304        self.input_filter = Some(Box::new(filter));
305        self
306    }
307
308    /// Emit Tab/ShiftTab events instead of handling focus navigation (builder pattern)
309    ///
310    /// When true, pressing Tab or Shift+Tab emits `TextInputEvent::Tab` or
311    /// `TextInputEvent::ShiftTab` respectively, allowing the parent to handle
312    /// focus navigation. This is useful when embedding TextInput in other controls
313    /// that need to intercept Tab for custom behavior.
314    ///
315    /// When false (default), Tab/Shift+Tab directly calls `window.focus_next()`
316    /// or `window.focus_prev()`.
317    #[must_use]
318    pub fn emit_tab_events(mut self, emit: bool) -> Self {
319        self.emit_tab_events = emit;
320        self
321    }
322
323    /// Set enabled state (builder pattern)
324    ///
325    /// When disabled, the input does not accept focus, keyboard input, or mouse
326    /// interaction, and renders with disabled styling.
327    #[must_use]
328    pub fn with_enabled(mut self, enabled: bool) -> Self {
329        self.enabled = enabled;
330        self
331    }
332
333    /// Check if this input is enabled
334    pub fn is_enabled(&self) -> bool {
335        self.enabled
336    }
337
338    /// Set enabled state programmatically
339    pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
340        if self.enabled != enabled {
341            self.enabled = enabled;
342            cx.notify();
343        }
344    }
345
346    /// Get the display content (masked or real).
347    /// Uses `Cow<str>` to avoid allocation when not masked.
348    fn display_content(&self) -> Cow<'_, str> {
349        if self.core.is_masked() {
350            Cow::Owned(MASK_CHAR.repeat(self.core.content().chars().count()))
351        } else {
352            Cow::Borrowed(self.core.content())
353        }
354    }
355
356    /// Convert a byte index in content to a byte index in display content
357    fn content_byte_to_display_byte(&self, content_pos: usize) -> usize {
358        if !self.core.is_masked() || self.core.content().is_empty() {
359            return content_pos;
360        }
361        let char_count = self.core.content()[..content_pos].chars().count();
362        char_count * MASK_CHAR.len()
363    }
364
365    /// Convert a byte index in display content to a byte index in content
366    fn display_byte_to_content_byte(&self, display_pos: usize) -> usize {
367        if !self.core.is_masked() || self.core.content().is_empty() {
368            return display_pos;
369        }
370        let mask_char_len = MASK_CHAR.len();
371        let char_index = display_pos / mask_char_len;
372        // Convert char index to byte index in content
373        self.core.content()
374            .char_indices()
375            .nth(char_index)
376            .map(|(i, _)| i)
377            .unwrap_or(self.core.content().len())
378    }
379
380    /// Reset cursor blink timer
381    fn reset_cursor_blink(&mut self) {
382        self.cursor_blink.reset();
383    }
384
385    /// Get the current content
386    pub fn content(&self) -> &str {
387        self.core.content()
388    }
389
390    /// Set the content value
391    pub fn set_value(&mut self, value: &str, cx: &mut Context<Self>) {
392        self.core.set_content(value);
393        self.scroll_offset = 0.0;
394        cx.emit(TextInputEvent::Change);
395        cx.notify();
396    }
397
398    /// Set placeholder text programmatically
399    pub fn set_placeholder(&mut self, text: impl Into<SharedString>, cx: &mut Context<Self>) {
400        self.placeholder = Some(text.into());
401        cx.notify();
402    }
403
404    /// Get the focus handle
405    pub fn focus_handle(&self) -> &FocusHandle {
406        &self.focus_handle
407    }
408
409    /// Copy selected text to clipboard (disabled when masked)
410    fn copy(&self, cx: &mut Context<Self>) {
411        if self.core.is_masked() {
412            // Don't allow copying password content
413            return;
414        }
415        if let Some(text) = self.core.selected_text() {
416            cx.write_to_clipboard(ClipboardItem::new_string(text.to_string()));
417        }
418    }
419
420    /// Cut selected text to clipboard (delete only when masked, no copy)
421    fn cut(&mut self, cx: &mut Context<Self>) {
422        if !self.core.is_masked() {
423            self.copy(cx);
424        }
425        if self.core.delete_selection() {
426            self.reset_cursor_blink();
427            cx.emit(TextInputEvent::Change);
428            cx.notify();
429        }
430    }
431
432    /// Paste from clipboard
433    fn paste(&mut self, cx: &mut Context<Self>) {
434        if let Some(clipboard) = cx.read_from_clipboard() {
435            if let Some(text) = clipboard.text() {
436                let clean_text = text.replace(['\n', '\r'], "");
437
438                // Apply input filter if present
439                let filtered_text: String = if let Some(ref filter) = self.input_filter {
440                    clean_text.chars().filter(|c| filter(*c)).collect()
441                } else {
442                    clean_text
443                };
444
445                if filtered_text.is_empty() {
446                    return;
447                }
448
449                self.core.insert_text(&filtered_text);
450                self.reset_cursor_blink();
451                cx.emit(TextInputEvent::Change);
452                cx.notify();
453            }
454        }
455    }
456
457    /// Handle focus gained
458    fn on_focus(&mut self, cx: &mut Context<Self>) {
459        if self.select_on_focus && !self.core.content().is_empty() {
460            self.core.select_all();
461        }
462        self.reset_cursor_blink();
463        cx.emit(TextInputEvent::Focus);
464        cx.notify();
465    }
466
467    /// Handle focus lost
468    fn on_blur(&mut self, cx: &mut Context<Self>) {
469        // Stop any drag operation
470        self.stop_drag();
471        // Don't clear selection or scroll_offset - they'll be hidden visually
472        // but restored when focus returns
473        cx.emit(TextInputEvent::Blur);
474        cx.notify();
475    }
476
477    /// Shape the display content for measurement
478    fn shape_line(&self, window: &Window) -> Option<ShapedLine> {
479        let display = self.display_content().into_owned();
480        if display.is_empty() {
481            return None;
482        }
483
484        let style = window.text_style();
485        let font_size = window.rem_size() * 0.875;
486
487        let run = TextRun {
488            len: display.len(),
489            font: style.font(),
490            color: style.color,
491            background_color: None,
492            underline: None,
493            strikethrough: None,
494        };
495
496        Some(window.text_system().shape_line(
497            SharedString::from(display),
498            font_size,
499            &[run],
500            None,
501        ))
502    }
503
504    /// Calculate cursor position from click x coordinate
505    fn cursor_at_x(&self, x: f32, window: &Window) -> usize {
506        let adjusted_x = x + self.scroll_offset;
507
508        if adjusted_x <= 0.0 || self.core.content().is_empty() {
509            return 0;
510        }
511
512        if let Some(line) = self.shape_line(window) {
513            let display_pos = line.closest_index_for_x(px(adjusted_x));
514            self.display_byte_to_content_byte(display_pos)
515        } else {
516            0
517        }
518    }
519
520    /// Get x position for a character index
521    fn x_for_cursor(&self, cursor: usize, window: &Window) -> f32 {
522        if self.core.content().is_empty() || cursor == 0 {
523            return 0.0;
524        }
525
526        if let Some(line) = self.shape_line(window) {
527            let display_cursor = self.content_byte_to_display_byte(cursor);
528            let pixels = line.x_for_index(display_cursor);
529            pixels.into()
530        } else {
531            0.0
532        }
533    }
534
535    /// Ensure the cursor is visible within the viewport
536    fn ensure_cursor_visible(&mut self, window: &Window) {
537        let cursor_x = self.x_for_cursor(self.core.cursor(), window);
538        let content_width = self.x_for_cursor(self.core.content().len(), window);
539        let margin = 2.0;
540        let cursor_width = 1.0;
541        let padding = 2.0;
542
543        let actual_visible = self.visible_width - padding;
544
545        if content_width <= actual_visible {
546            self.scroll_offset = 0.0;
547            return;
548        }
549
550        let visual_cursor_x = cursor_x - self.scroll_offset;
551
552        if visual_cursor_x + cursor_width > actual_visible - margin {
553            self.scroll_offset = cursor_x + cursor_width - actual_visible + margin;
554        }
555
556        if visual_cursor_x < margin {
557            self.scroll_offset = (cursor_x - margin).max(0.0);
558        }
559
560        let max_scroll = (content_width + cursor_width - actual_visible + margin).max(0.0);
561        self.scroll_offset = self.scroll_offset.clamp(0.0, max_scroll);
562    }
563
564    /// Handle drag move during text selection
565    /// Returns the auto-scroll speed (0 if no scrolling needed)
566    fn handle_drag_move(&mut self, mouse_x: f32, window: &Window) -> f32 {
567        if !self.is_dragging {
568            return 0.0;
569        }
570
571        let relative_x = mouse_x - self.content_origin_x;
572        let padding = 2.0;
573        let actual_visible = self.visible_width - padding;
574
575        // Calculate auto-scroll speed based on distance from edge
576        let scroll_speed = if relative_x < 0.0 {
577            // Mouse is left of the input - scroll left (negative)
578            -self.calculate_scroll_speed(-relative_x)
579        } else if relative_x > actual_visible {
580            // Mouse is right of the input - scroll right (positive)
581            self.calculate_scroll_speed(relative_x - actual_visible)
582        } else {
583            0.0
584        };
585
586        // Update cursor position based on mouse x (clamped to visible area for cursor calculation)
587        let clamped_x = relative_x.clamp(0.0, actual_visible);
588        let new_cursor = self.cursor_at_x(clamped_x, window);
589        self.core.extend_selection_to(new_cursor);
590        self.reset_cursor_blink();
591
592        scroll_speed
593    }
594
595    /// Calculate scroll speed based on distance from edge
596    /// Uses an ease-out curve for acceleration
597    fn calculate_scroll_speed(&self, distance: f32) -> f32 {
598        let base_speed = 0.5; // pixels per frame at the edge
599        let max_speed = 20.0; // max pixels per frame
600        let max_distance = 100.0; // distance at which max speed is reached
601
602        let normalized = (distance / max_distance).min(1.0);
603        // Ease-out curve: fast start, slow end
604        let eased = 1.0 - (1.0 - normalized).powi(2);
605        base_speed + eased * (max_speed - base_speed)
606    }
607
608    /// Apply auto-scroll during drag
609    fn apply_auto_scroll(&mut self, window: &Window) {
610        if self.auto_scroll_speed == 0.0 {
611            return;
612        }
613
614        let content_width = self.x_for_cursor(self.core.content().len(), window);
615        let padding = 2.0;
616        let actual_visible = self.visible_width - padding;
617        let max_scroll = (content_width - actual_visible).max(0.0);
618
619        // Apply scroll
620        self.scroll_offset = (self.scroll_offset + self.auto_scroll_speed).clamp(0.0, max_scroll);
621
622        // Update cursor to match scroll direction
623        if self.auto_scroll_speed < 0.0 {
624            // Scrolling left - move cursor toward start
625            let new_cursor = self.cursor_at_x(0.0, window);
626            self.core.extend_selection_to(new_cursor);
627        } else {
628            // Scrolling right - move cursor toward end
629            let new_cursor = self.cursor_at_x(actual_visible, window);
630            self.core.extend_selection_to(new_cursor);
631        }
632    }
633
634    /// Stop drag operation
635    fn stop_drag(&mut self) {
636        self.is_dragging = false;
637        self.auto_scroll_active = false;
638        self.auto_scroll_speed = 0.0;
639    }
640
641    /// Spawn auto-scroll timer if scrolling is needed and timer isn't already active
642    fn spawn_auto_scroll_timer_if_needed(&mut self, scroll_speed: f32, window: &mut Window, cx: &mut Context<Self>) {
643        self.auto_scroll_speed = scroll_speed;
644        if scroll_speed != 0.0 && !self.auto_scroll_active {
645            self.auto_scroll_active = true;
646            let entity = cx.entity();
647            window.spawn(cx, async move |async_cx| {
648                loop {
649                    smol::Timer::after(Duration::from_millis(32)).await; // ~30fps
650                    let should_continue = async_cx
651                        .update_entity(&entity, |this, cx| {
652                            if !this.auto_scroll_active || !this.is_dragging {
653                                this.auto_scroll_active = false;
654                                return false;
655                            }
656                            cx.notify();
657                            true
658                        })
659                        .unwrap_or(false);
660                    if !should_continue {
661                        break;
662                    }
663                }
664            }).detach();
665        }
666    }
667
668    // Action handlers that delegate to core and emit events
669
670    fn handle_move_left(&mut self, cx: &mut Context<Self>) {
671        self.core.move_left();
672        self.reset_cursor_blink();
673        cx.notify();
674    }
675
676    fn handle_move_right(&mut self, cx: &mut Context<Self>) {
677        self.core.move_right();
678        self.reset_cursor_blink();
679        cx.notify();
680    }
681
682    fn handle_move_word_left(&mut self, cx: &mut Context<Self>) {
683        self.core.move_word_left();
684        self.reset_cursor_blink();
685        cx.notify();
686    }
687
688    fn handle_move_word_right(&mut self, cx: &mut Context<Self>) {
689        self.core.move_word_right();
690        self.reset_cursor_blink();
691        cx.notify();
692    }
693
694    fn handle_move_to_start(&mut self, cx: &mut Context<Self>) {
695        self.core.move_to_start();
696        self.reset_cursor_blink();
697        cx.notify();
698    }
699
700    fn handle_move_to_end(&mut self, cx: &mut Context<Self>) {
701        self.core.move_to_end();
702        self.reset_cursor_blink();
703        cx.notify();
704    }
705
706    fn handle_select_left(&mut self, cx: &mut Context<Self>) {
707        self.core.select_left();
708        self.reset_cursor_blink();
709        cx.notify();
710    }
711
712    fn handle_select_right(&mut self, cx: &mut Context<Self>) {
713        self.core.select_right();
714        self.reset_cursor_blink();
715        cx.notify();
716    }
717
718    fn handle_select_word_left(&mut self, cx: &mut Context<Self>) {
719        self.core.select_word_left();
720        self.reset_cursor_blink();
721        cx.notify();
722    }
723
724    fn handle_select_word_right(&mut self, cx: &mut Context<Self>) {
725        self.core.select_word_right();
726        self.reset_cursor_blink();
727        cx.notify();
728    }
729
730    fn handle_select_to_start(&mut self, cx: &mut Context<Self>) {
731        self.core.select_to_start();
732        self.reset_cursor_blink();
733        cx.notify();
734    }
735
736    fn handle_select_to_end(&mut self, cx: &mut Context<Self>) {
737        self.core.select_to_end();
738        self.reset_cursor_blink();
739        cx.notify();
740    }
741
742    fn handle_select_all(&mut self, cx: &mut Context<Self>) {
743        self.core.select_all();
744        self.reset_cursor_blink();
745        cx.notify();
746    }
747
748    fn handle_delete_backward(&mut self, cx: &mut Context<Self>) {
749        if self.core.delete_backward() {
750            self.reset_cursor_blink();
751            cx.emit(TextInputEvent::Change);
752            cx.notify();
753        }
754    }
755
756    fn handle_delete_forward(&mut self, cx: &mut Context<Self>) {
757        if self.core.delete_forward() {
758            self.reset_cursor_blink();
759            cx.emit(TextInputEvent::Change);
760            cx.notify();
761        }
762    }
763
764    fn handle_delete_word_backward(&mut self, cx: &mut Context<Self>) {
765        if self.core.delete_word_backward() {
766            self.reset_cursor_blink();
767            cx.emit(TextInputEvent::Change);
768            cx.notify();
769        }
770    }
771
772    fn handle_delete_word_forward(&mut self, cx: &mut Context<Self>) {
773        if self.core.delete_word_forward() {
774            self.reset_cursor_blink();
775            cx.emit(TextInputEvent::Change);
776            cx.notify();
777        }
778    }
779
780    fn handle_insert_text(&mut self, text: &str, cx: &mut Context<Self>) {
781        // Apply input filter if present
782        let filtered_text: String = if let Some(ref filter) = self.input_filter {
783            text.chars().filter(|c| filter(*c)).collect()
784        } else {
785            text.to_string()
786        };
787
788        if filtered_text.is_empty() {
789            return;
790        }
791
792        self.core.insert_text(&filtered_text);
793        self.reset_cursor_blink();
794        cx.emit(TextInputEvent::Change);
795        cx.notify();
796    }
797}
798
799impl Render for TextInput {
800    fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
801        let theme = get_theme_or(cx, self.custom_theme.as_ref());
802        let focus_handle = self.focus_handle.clone();
803        let is_focused = self.focus_handle.is_focused(window);
804        // Convert to owned String early to avoid borrowing issues
805        let display_content = self.display_content().into_owned();
806        let placeholder = self.placeholder.clone();
807        let has_content = !self.core.content().is_empty();
808
809        // Set up focus-out subscription on first render
810        if !self.focus_out_subscribed {
811            self.focus_out_subscribed = true;
812            let focus_handle = self.focus_handle.clone();
813            cx.on_focus_out(&focus_handle, window, |this: &mut Self, _event, _window, cx| {
814                this.on_blur(cx);
815            }).detach();
816        }
817
818        // Detect focus-in
819        if is_focused && !self.was_focused {
820            self.on_focus(cx);
821        }
822        self.was_focused = is_focused;
823
824        // Use actual scroll_offset when focused, 0 when unfocused (to show beginning of text)
825        let render_scroll_offset = if is_focused { self.scroll_offset } else { 0.0 };
826
827        // Set up blink timer when focused
828        if is_focused && !self.blink_timer_active {
829            self.blink_timer_active = true;
830            let entity = cx.entity();
831            let blink_period = CursorBlink::blink_period();
832            window.spawn(cx, async move |async_cx| {
833                loop {
834                    smol::Timer::after(blink_period).await;
835                    let should_continue = async_cx
836                        .update_entity(&entity, |this, cx| {
837                            if !this.blink_timer_active {
838                                return false;
839                            }
840                            cx.notify();
841                            true
842                        })
843                        .unwrap_or(false);
844                    if !should_continue {
845                        break;
846                    }
847                }
848            }).detach();
849        }
850
851        if !is_focused {
852            self.blink_timer_active = false;
853        }
854
855        // Apply auto-scroll if active
856        if self.auto_scroll_active && self.is_dragging {
857            self.apply_auto_scroll(window);
858        }
859
860        if is_focused {
861            self.ensure_cursor_visible(window);
862        }
863
864        // Re-capture cursor and selection after potential auto-scroll modification
865        let cursor = self.core.cursor();
866        let selection = self.core.selection();
867
868        let cursor_x = self.x_for_cursor(cursor, window) - render_scroll_offset;
869        let cursor_visible = is_focused && self.cursor_blink.is_visible();
870
871        // Only show selection when focused and selection is non-empty
872        let selection_bounds: Option<(f32, f32)> = selection
873            .filter(|_| is_focused)
874            .filter(|(start, end)| start != end)
875            .map(|(start, end)| {
876                let start_x = self.x_for_cursor(start, window) - render_scroll_offset;
877                let end_x = self.x_for_cursor(end, window) - render_scroll_offset;
878                (start_x, end_x - start_x)
879            });
880
881        let scroll_offset = render_scroll_offset;
882        let enabled = self.enabled;
883        let selection_color = theme.selection;
884        let text_color = if enabled { theme.text_primary } else { theme.disabled_text };
885        let text_placeholder = theme.text_placeholder;
886        let border_focus = theme.border_focus;
887        let border_input = theme.border_input;
888        let bg_input = if enabled { theme.bg_input } else { theme.disabled_bg };
889        let disabled_bg = theme.disabled_bg;
890
891        div()
892            .id("ccf_text_input")
893            .key_context("CcfTextInput")
894            .track_focus(&focus_handle)
895            .tab_stop(enabled)
896            // Navigation actions
897            .on_action(cx.listener(|this, _: &MoveLeft, _window, cx| {
898                if !this.enabled { return; }
899                this.handle_move_left(cx);
900            }))
901            .on_action(cx.listener(|this, _: &MoveRight, _window, cx| {
902                if !this.enabled { return; }
903                this.handle_move_right(cx);
904            }))
905            .on_action(cx.listener(|this, _: &MoveWordLeft, _window, cx| {
906                if !this.enabled { return; }
907                this.handle_move_word_left(cx);
908            }))
909            .on_action(cx.listener(|this, _: &MoveWordRight, _window, cx| {
910                if !this.enabled { return; }
911                this.handle_move_word_right(cx);
912            }))
913            .on_action(cx.listener(|this, _: &MoveToStart, _window, cx| {
914                if !this.enabled { return; }
915                this.handle_move_to_start(cx);
916            }))
917            .on_action(cx.listener(|this, _: &MoveToEnd, _window, cx| {
918                if !this.enabled { return; }
919                this.handle_move_to_end(cx);
920            }))
921            // Selection actions
922            .on_action(cx.listener(|this, _: &SelectLeft, _window, cx| {
923                if !this.enabled { return; }
924                this.handle_select_left(cx);
925            }))
926            .on_action(cx.listener(|this, _: &SelectRight, _window, cx| {
927                if !this.enabled { return; }
928                this.handle_select_right(cx);
929            }))
930            .on_action(cx.listener(|this, _: &SelectWordLeft, _window, cx| {
931                if !this.enabled { return; }
932                this.handle_select_word_left(cx);
933            }))
934            .on_action(cx.listener(|this, _: &SelectWordRight, _window, cx| {
935                if !this.enabled { return; }
936                this.handle_select_word_right(cx);
937            }))
938            .on_action(cx.listener(|this, _: &SelectToStart, _window, cx| {
939                if !this.enabled { return; }
940                this.handle_select_to_start(cx);
941            }))
942            .on_action(cx.listener(|this, _: &SelectToEnd, _window, cx| {
943                if !this.enabled { return; }
944                this.handle_select_to_end(cx);
945            }))
946            .on_action(cx.listener(|this, _: &SelectAll, _window, cx| {
947                if !this.enabled { return; }
948                this.handle_select_all(cx);
949            }))
950            // Delete actions
951            .on_action(cx.listener(|this, _: &DeleteBackward, _window, cx| {
952                if !this.enabled { return; }
953                this.handle_delete_backward(cx);
954            }))
955            .on_action(cx.listener(|this, _: &DeleteForward, _window, cx| {
956                if !this.enabled { return; }
957                this.handle_delete_forward(cx);
958            }))
959            .on_action(cx.listener(|this, _: &DeleteWordBackward, _window, cx| {
960                if !this.enabled { return; }
961                this.handle_delete_word_backward(cx);
962            }))
963            .on_action(cx.listener(|this, _: &DeleteWordForward, _window, cx| {
964                if !this.enabled { return; }
965                this.handle_delete_word_forward(cx);
966            }))
967            // Clipboard actions
968            .on_action(cx.listener(|this, _: &Cut, _window, cx| {
969                if !this.enabled { return; }
970                this.cut(cx);
971            }))
972            .on_action(cx.listener(|this, _: &Copy, _window, cx| {
973                if !this.enabled { return; }
974                this.copy(cx);
975            }))
976            .on_action(cx.listener(|this, _: &Paste, _window, cx| {
977                if !this.enabled { return; }
978                this.paste(cx);
979            }))
980            // Enter/Escape
981            .on_action(cx.listener(|this, _: &Enter, _window, cx| {
982                if !this.enabled { return; }
983                cx.emit(TextInputEvent::Enter);
984            }))
985            .on_action(cx.listener(|this, _: &Escape, _window, cx| {
986                if !this.enabled { return; }
987                cx.emit(TextInputEvent::Escape);
988            }))
989            // Focus navigation (Tab / Shift+Tab)
990            .on_action(cx.listener(|this, _: &FocusNext, window, _cx| {
991                if !this.enabled { return; }
992                window.focus_next();
993            }))
994            .on_action(cx.listener(|this, _: &FocusPrev, window, _cx| {
995                if !this.enabled { return; }
996                window.focus_prev();
997            }))
998            // Character input (Tab handled separately for focus navigation)
999            .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
1000                if !this.enabled { return; }
1001                // Handle Tab for focus navigation
1002                if event.keystroke.key == "tab" {
1003                    if this.emit_tab_events {
1004                        // Emit event for parent to handle
1005                        if event.keystroke.modifiers.shift {
1006                            cx.emit(TextInputEvent::ShiftTab);
1007                        } else {
1008                            cx.emit(TextInputEvent::Tab);
1009                        }
1010                    } else {
1011                        // Handle focus navigation directly
1012                        if event.keystroke.modifiers.shift {
1013                            window.focus_prev();
1014                        } else {
1015                            window.focus_next();
1016                        }
1017                    }
1018                    return;
1019                }
1020                if !event.keystroke.modifiers.alt
1021                    && !event.keystroke.modifiers.control
1022                    && !event.keystroke.modifiers.platform
1023                {
1024                    if let Some(ref ch) = event.keystroke.key_char {
1025                        this.handle_insert_text(ch, cx);
1026                    }
1027                }
1028            }))
1029            // Click to focus and position cursor, start drag (only when enabled)
1030            .when(enabled, |d| {
1031                d.on_mouse_down(MouseButton::Left, cx.listener(|this, event: &MouseDownEvent, window, cx| {
1032                    let was_focused = this.focus_handle.is_focused(window);
1033                    this.focus_handle.focus(window);
1034
1035                    // If clicking to restore focus and there's a selection to restore,
1036                    // just restore focus without changing cursor/selection
1037                    if !was_focused && this.core.selection().is_some() {
1038                        this.reset_cursor_blink();
1039                        cx.notify();
1040                        return;
1041                    }
1042
1043                    let click_x: f32 = event.position.x.into();
1044                    let relative_x = (click_x - this.content_origin_x).max(0.0);
1045                    let new_cursor = this.cursor_at_x(relative_x, window);
1046
1047                    if event.modifiers.shift {
1048                        // Shift+click extends selection
1049                        this.core.start_selection_from_cursor();
1050                        this.core.extend_selection_to(new_cursor);
1051                    } else {
1052                        // Regular click starts a new selection
1053                        this.core.clear_selection();
1054                        this.core.set_cursor(new_cursor);
1055                        // Set anchor for potential drag selection
1056                        this.core.start_selection_from_cursor();
1057                    }
1058
1059                    // Start drag operation
1060                    this.is_dragging = true;
1061                    this.reset_cursor_blink();
1062                    cx.notify();
1063                }))
1064                // Drag to select text
1065                .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, window, cx| {
1066                    if !this.is_dragging {
1067                        return;
1068                    }
1069
1070                    let mouse_x: f32 = event.position.x.into();
1071                    let scroll_speed = this.handle_drag_move(mouse_x, window);
1072                    this.spawn_auto_scroll_timer_if_needed(scroll_speed, window, cx);
1073                    cx.notify();
1074                }))
1075                // Mouse up ends drag
1076                .on_mouse_up(MouseButton::Left, cx.listener(|this, _event: &MouseUpEvent, _window, cx| {
1077                    this.stop_drag();
1078                    cx.notify();
1079                }))
1080            })
1081            // Styling
1082            .w_full()
1083            .h(px(28.))
1084            .px_2()
1085            // Only apply border/background/rounded corners when not borderless
1086            .when(!self.borderless, |d| {
1087                d.border_1()
1088                    .border_color(if is_focused { rgb(border_focus) } else { rgb(border_input) })
1089                    .rounded_md()
1090                    .bg(rgb(bg_input))
1091            })
1092            // Borderless disabled styling
1093            .when(self.borderless && !enabled, |d| {
1094                d.bg(rgb(disabled_bg))
1095            })
1096            .when(enabled, |d| d.cursor_text())
1097            .when(!enabled, |d| d.cursor_default())
1098            .relative()
1099            .overflow_hidden()
1100            .child({
1101                let entity = cx.entity();
1102                let entity_paint = entity.clone();
1103                let is_dragging = self.is_dragging;
1104
1105                div()
1106                    .size_full()
1107                    .flex()
1108                    .items_center()
1109                    .relative()
1110                    // Measurement canvas and window-level drag handlers
1111                    .child(
1112                        canvas(
1113                            move |bounds, _window, cx| {
1114                                let width: f32 = bounds.size.width.into();
1115                                let origin_x: f32 = bounds.origin.x.into();
1116                                // Update measurement values without triggering re-render
1117                                // to avoid potential render loops when used inside other widgets
1118                                entity.update(cx, |this: &mut TextInput, _cx| {
1119                                    this.visible_width = width;
1120                                    this.content_origin_x = origin_x;
1121                                });
1122                            },
1123                            {
1124                                let entity = entity_paint;
1125                                move |_bounds, _, window, _cx| {
1126                                    // Register window-level mouse handlers when dragging
1127                                    // This captures mouse events even outside the element bounds
1128                                    if is_dragging {
1129                                        // Mouse move handler for drag selection
1130                                        let entity_move = entity.clone();
1131                                        window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| {
1132                                            if phase != DispatchPhase::Capture {
1133                                                return;
1134                                            }
1135                                            let mouse_x: f32 = event.position.x.into();
1136                                            entity_move.update(cx, |this: &mut TextInput, cx| {
1137                                                let scroll_speed = this.handle_drag_move(mouse_x, window);
1138                                                this.spawn_auto_scroll_timer_if_needed(scroll_speed, window, cx);
1139                                                cx.notify();
1140                                            });
1141                                        });
1142
1143                                        // Mouse up handler to end drag
1144                                        let entity_up = entity.clone();
1145                                        window.on_mouse_event(move |_event: &MouseUpEvent, phase, _window, cx| {
1146                                            if phase != DispatchPhase::Capture {
1147                                                return;
1148                                            }
1149                                            entity_up.update(cx, |this: &mut TextInput, cx| {
1150                                                this.stop_drag();
1151                                                cx.notify();
1152                                            });
1153                                        });
1154                                    }
1155                                }
1156                            },
1157                        )
1158                        .size_full()
1159                        .absolute()
1160                    )
1161                    // Content layer
1162                    .child(
1163                        div()
1164                            .relative()
1165                            .h_full()
1166                            .flex()
1167                            .items_center()
1168                            .min_w_0()
1169                            // Selection highlight
1170                            .when_some(selection_bounds, |d, (start_x, width)| {
1171                                d.child(
1172                                    div()
1173                                        .absolute()
1174                                        .top_0()
1175                                        .bottom_0()
1176                                        .left(px(start_x))
1177                                        .w(px(width))
1178                                        .bg(rgb(selection_color))
1179                                )
1180                            })
1181                            // Text content
1182                            .child(
1183                                div()
1184                                    .absolute()
1185                                    .left(px(-scroll_offset))
1186                                    .text_sm()
1187                                    .text_color(rgb(text_color))
1188                                    .whitespace_nowrap()
1189                                    .child(display_content.clone())
1190                            )
1191                            // Cursor
1192                            .when(cursor_visible, |d| {
1193                                d.child(
1194                                    div()
1195                                        .absolute()
1196                                        .top(px(4.))
1197                                        .bottom(px(4.))
1198                                        .left(px(cursor_x))
1199                                        .w(px(1.))
1200                                        .bg(rgb(text_color))
1201                                )
1202                            })
1203                    )
1204                    // Placeholder
1205                    .when(!has_content, |d| {
1206                        if let Some(ph) = placeholder {
1207                            d.child(
1208                                div()
1209                                    .absolute()
1210                                    .left_0()
1211                                    .text_sm()
1212                                    .text_color(rgb(text_placeholder))
1213                                    .child(ph)
1214                            )
1215                        } else {
1216                            d
1217                        }
1218                    })
1219            })
1220    }
1221}