Skip to main content

ccf_gpui_widgets/widgets/
password_input.rs

1//! Password input widget with visibility toggle
2//!
3//! A secure text input that masks its content with bullet characters and provides
4//! a button to toggle password visibility. When the `secure-password` feature is
5//! enabled, uses `SensitiveString` internally for automatic memory zeroization
6//! and exposes `SecretString` at API boundaries.
7//!
8//! # Example
9//!
10//! ```ignore
11//! use ccf_gpui_widgets::widgets::{PasswordInput, PasswordInputEvent};
12//!
13//! let password_input = cx.new(|cx| {
14//!     PasswordInput::new(cx)
15//!         .placeholder("Enter password")
16//! });
17//!
18//! // Subscribe to events
19//! cx.subscribe(&password_input, |this, _, event: &PasswordInputEvent, cx| {
20//!     match event {
21//!         PasswordInputEvent::Change(secret) => {
22//!             // Use secret.expose_secret() to access the password
23//!             println!("Password changed");
24//!         }
25//!         PasswordInputEvent::Enter => println!("Enter pressed"),
26//!         PasswordInputEvent::Blur => println!("Focus lost"),
27//!     }
28//! }).detach();
29//! ```
30
31use std::time::Duration;
32
33use gpui::prelude::*;
34use gpui::*;
35
36#[cfg(feature = "secure-password")]
37use secrecy::SecretString;
38
39use crate::theme::{get_theme_or, Theme};
40use super::cursor_blink::CursorBlink;
41use super::editing_core::EditingCore;
42use super::focus_navigation::{FocusNext, FocusPrev, handle_tab_navigation, EnabledCursorExt};
43use super::text_input::{
44    MoveLeft, MoveRight, MoveWordLeft, MoveWordRight, MoveToStart, MoveToEnd,
45    SelectLeft, SelectRight, SelectWordLeft, SelectWordRight, SelectToStart, SelectToEnd, SelectAll,
46    DeleteBackward, DeleteForward, DeleteWordBackward, DeleteWordForward,
47    Cut, Copy, Paste, Enter, Escape,
48};
49
50#[cfg(feature = "secure-password")]
51use super::sensitive_string::SensitiveString;
52
53/// Events emitted by PasswordInput
54#[derive(Debug, Clone)]
55pub enum PasswordInputEvent {
56    /// Password value changed
57    #[cfg(feature = "secure-password")]
58    Change(SecretString),
59    /// Password value changed (without secure-password feature)
60    #[cfg(not(feature = "secure-password"))]
61    Change(String),
62    /// Enter key was pressed
63    Enter,
64    /// Input lost focus (including Escape key)
65    Blur,
66}
67
68/// Character used to mask password input
69const MASK_CHAR: &str = "\u{25CF}"; // ● Black circle
70
71/// Password input widget with visibility toggle
72///
73/// This widget provides a secure password entry field with:
74/// - Masked display by default (bullet characters)
75/// - Toggle button to show/hide the actual password
76/// - Full cursor/selection support
77/// - When `secure-password` feature is enabled:
78///   - Automatic memory zeroization when dropped
79///   - `SecretString` at API boundaries
80///   - Redacted Debug output
81pub struct PasswordInput {
82    /// Core editing logic with secure storage
83    #[cfg(feature = "secure-password")]
84    core: EditingCore<SensitiveString>,
85    #[cfg(not(feature = "secure-password"))]
86    core: EditingCore<String>,
87    /// Focus handle for the text input area
88    input_focus_handle: FocusHandle,
89    /// Focus handle for the toggle button
90    toggle_focus_handle: FocusHandle,
91    /// Whether password is currently visible
92    show_password: bool,
93    /// Placeholder text
94    placeholder: Option<SharedString>,
95    /// Optional custom theme
96    custom_theme: Option<Theme>,
97    /// Horizontal scroll offset in pixels
98    scroll_offset: f32,
99    /// Visible width of the text area
100    visible_width: f32,
101    /// Left edge of content area in window coordinates
102    content_origin_x: f32,
103    /// Track previous focus state
104    was_focused: bool,
105    /// Whether focus-out subscription has been set up
106    focus_out_subscribed: bool,
107    /// Cursor blink state
108    cursor_blink: CursorBlink,
109    /// Whether blink timer is set up
110    blink_timer_active: bool,
111    /// Whether currently dragging to select text
112    is_dragging: bool,
113    /// Whether auto-scroll timer is active
114    auto_scroll_active: bool,
115    /// Current auto-scroll speed
116    auto_scroll_speed: f32,
117    /// Pending placeholder from builder
118    pending_placeholder: Option<SharedString>,
119    /// Pending value from builder
120    pending_value: Option<String>,
121    /// Whether the input is enabled
122    enabled: bool,
123}
124
125impl EventEmitter<PasswordInputEvent> for PasswordInput {}
126
127impl Focusable for PasswordInput {
128    fn focus_handle(&self, _cx: &App) -> FocusHandle {
129        self.input_focus_handle.clone()
130    }
131}
132
133impl PasswordInput {
134    /// Create a new password input
135    pub fn new(cx: &mut Context<Self>) -> Self {
136        #[cfg(feature = "secure-password")]
137        let core = EditingCore::<SensitiveString>::new().with_masked(true);
138        #[cfg(not(feature = "secure-password"))]
139        let core = EditingCore::<String>::new().with_masked(true);
140
141        Self {
142            core,
143            input_focus_handle: cx.focus_handle().tab_stop(true),
144            toggle_focus_handle: cx.focus_handle().tab_stop(true),
145            show_password: false,
146            placeholder: None,
147            custom_theme: None,
148            scroll_offset: 0.0,
149            visible_width: 200.0,
150            content_origin_x: 0.0,
151            was_focused: false,
152            focus_out_subscribed: false,
153            cursor_blink: CursorBlink::new(),
154            blink_timer_active: false,
155            is_dragging: false,
156            auto_scroll_active: false,
157            auto_scroll_speed: 0.0,
158            pending_placeholder: None,
159            pending_value: None,
160            enabled: true,
161        }
162    }
163
164    /// Set placeholder text shown when empty (builder pattern)
165    #[must_use]
166    pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
167        self.pending_placeholder = Some(text.into());
168        self
169    }
170
171    /// Set an initial value (builder pattern)
172    #[must_use]
173    pub fn with_value(mut self, value: impl Into<String>) -> Self {
174        self.pending_value = Some(value.into());
175        self
176    }
177
178    /// Set a custom theme for this widget (builder pattern)
179    #[must_use]
180    pub fn theme(mut self, theme: Theme) -> Self {
181        self.custom_theme = Some(theme);
182        self
183    }
184
185    /// Set whether the input is enabled (builder pattern)
186    #[must_use]
187    pub fn with_enabled(mut self, enabled: bool) -> Self {
188        self.enabled = enabled;
189        self
190    }
191
192    /// Apply any pending builder values (called on first render)
193    fn apply_pending(&mut self) {
194        if let Some(placeholder) = self.pending_placeholder.take() {
195            self.placeholder = Some(placeholder);
196        }
197        if let Some(value) = self.pending_value.take() {
198            self.core.set_content(&value);
199        }
200    }
201
202    /// Get the current password value as a SecretString
203    #[cfg(feature = "secure-password")]
204    pub fn value(&self, _cx: &App) -> SecretString {
205        // Access the sensitive string through the core and convert to SecretString
206        // The core stores SensitiveString which has to_secret_string()
207        // We need to get at the underlying storage
208        self.create_secret_from_content()
209    }
210
211    /// Get the current password value
212    #[cfg(not(feature = "secure-password"))]
213    pub fn value<'a>(&'a self, _cx: &'a App) -> &'a str {
214        self.core.content()
215    }
216
217    /// Create a SecretString from the current content
218    #[cfg(feature = "secure-password")]
219    fn create_secret_from_content(&self) -> SecretString {
220        SecretString::from(self.core.content().to_string())
221    }
222
223    /// Set the password value programmatically
224    pub fn set_value(&mut self, value: &str, cx: &mut Context<Self>) {
225        self.core.set_content(value);
226        self.scroll_offset = 0.0;
227        self.emit_change(cx);
228        cx.notify();
229    }
230
231    /// Set the password value from a SecretString
232    #[cfg(feature = "secure-password")]
233    pub fn set_value_secret(&mut self, secret: &SecretString, cx: &mut Context<Self>) {
234        use secrecy::ExposeSecret;
235        self.core.set_content(secret.expose_secret());
236        self.scroll_offset = 0.0;
237        self.emit_change(cx);
238        cx.notify();
239    }
240
241    /// Get the focus handle for this input
242    pub fn focus_handle(&self, _cx: &App) -> FocusHandle {
243        self.input_focus_handle.clone()
244    }
245
246    /// Check if the password is currently visible (unmasked)
247    pub fn is_password_visible(&self) -> bool {
248        self.show_password
249    }
250
251    /// Check if the input is enabled
252    pub fn is_enabled(&self) -> bool {
253        self.enabled
254    }
255
256    /// Set whether the input is enabled
257    pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
258        if self.enabled != enabled {
259            self.enabled = enabled;
260            cx.notify();
261        }
262    }
263
264    fn emit_change(&self, cx: &mut Context<Self>) {
265        #[cfg(feature = "secure-password")]
266        cx.emit(PasswordInputEvent::Change(self.create_secret_from_content()));
267        #[cfg(not(feature = "secure-password"))]
268        cx.emit(PasswordInputEvent::Change(self.core.content().to_string()));
269    }
270
271    fn toggle_visibility(&mut self, cx: &mut Context<Self>) {
272        self.show_password = !self.show_password;
273        self.core.set_masked(!self.show_password);
274        cx.notify();
275    }
276
277    /// Get the display content (masked or real)
278    fn display_content(&self) -> String {
279        if self.core.is_masked() {
280            MASK_CHAR.repeat(self.core.content().chars().count())
281        } else {
282            self.core.content().to_string()
283        }
284    }
285
286    /// Convert a byte index in content to a byte index in display content
287    fn content_byte_to_display_byte(&self, content_pos: usize) -> usize {
288        if !self.core.is_masked() || self.core.content().is_empty() {
289            return content_pos;
290        }
291        let char_count = self.core.content()[..content_pos].chars().count();
292        char_count * MASK_CHAR.len()
293    }
294
295    /// Convert a byte index in display content to a byte index in content
296    fn display_byte_to_content_byte(&self, display_pos: usize) -> usize {
297        if !self.core.is_masked() || self.core.content().is_empty() {
298            return display_pos;
299        }
300        let mask_char_len = MASK_CHAR.len();
301        let char_index = display_pos / mask_char_len;
302        self.core.content()
303            .char_indices()
304            .nth(char_index)
305            .map(|(i, _)| i)
306            .unwrap_or(self.core.content().len())
307    }
308
309    fn reset_cursor_blink(&mut self) {
310        self.cursor_blink.reset();
311    }
312
313    fn shape_line(&self, window: &Window) -> Option<ShapedLine> {
314        let display = self.display_content();
315        if display.is_empty() {
316            return None;
317        }
318
319        let style = window.text_style();
320        let font_size = window.rem_size() * 0.875;
321
322        let run = TextRun {
323            len: display.len(),
324            font: style.font(),
325            color: style.color,
326            background_color: None,
327            underline: None,
328            strikethrough: None,
329        };
330
331        Some(window.text_system().shape_line(
332            SharedString::from(display),
333            font_size,
334            &[run],
335            None,
336        ))
337    }
338
339    fn cursor_at_x(&self, x: f32, window: &Window) -> usize {
340        let adjusted_x = x + self.scroll_offset;
341
342        if adjusted_x <= 0.0 || self.core.content().is_empty() {
343            return 0;
344        }
345
346        if let Some(line) = self.shape_line(window) {
347            let display_pos = line.closest_index_for_x(px(adjusted_x));
348            self.display_byte_to_content_byte(display_pos)
349        } else {
350            0
351        }
352    }
353
354    fn x_for_cursor(&self, cursor: usize, window: &Window) -> f32 {
355        if self.core.content().is_empty() || cursor == 0 {
356            return 0.0;
357        }
358
359        if let Some(line) = self.shape_line(window) {
360            let display_cursor = self.content_byte_to_display_byte(cursor);
361            let pixels = line.x_for_index(display_cursor);
362            pixels.into()
363        } else {
364            0.0
365        }
366    }
367
368    fn ensure_cursor_visible(&mut self, window: &Window) {
369        let cursor_x = self.x_for_cursor(self.core.cursor(), window);
370        let content_width = self.x_for_cursor(self.core.content().len(), window);
371        let margin = 2.0;
372        let cursor_width = 1.0;
373        let padding = 2.0;
374
375        let actual_visible = self.visible_width - padding;
376
377        if content_width <= actual_visible {
378            self.scroll_offset = 0.0;
379            return;
380        }
381
382        let visual_cursor_x = cursor_x - self.scroll_offset;
383
384        if visual_cursor_x + cursor_width > actual_visible - margin {
385            self.scroll_offset = cursor_x + cursor_width - actual_visible + margin;
386        }
387
388        if visual_cursor_x < margin {
389            self.scroll_offset = (cursor_x - margin).max(0.0);
390        }
391
392        let max_scroll = (content_width + cursor_width - actual_visible + margin).max(0.0);
393        self.scroll_offset = self.scroll_offset.clamp(0.0, max_scroll);
394    }
395
396    fn handle_drag_move(&mut self, mouse_x: f32, window: &Window) -> f32 {
397        if !self.is_dragging {
398            return 0.0;
399        }
400
401        let relative_x = mouse_x - self.content_origin_x;
402        let padding = 2.0;
403        let actual_visible = self.visible_width - padding;
404
405        let scroll_speed = if relative_x < 0.0 {
406            -self.calculate_scroll_speed(-relative_x)
407        } else if relative_x > actual_visible {
408            self.calculate_scroll_speed(relative_x - actual_visible)
409        } else {
410            0.0
411        };
412
413        let clamped_x = relative_x.clamp(0.0, actual_visible);
414        let new_cursor = self.cursor_at_x(clamped_x, window);
415        self.core.extend_selection_to(new_cursor);
416        self.reset_cursor_blink();
417
418        scroll_speed
419    }
420
421    fn calculate_scroll_speed(&self, distance: f32) -> f32 {
422        let base_speed = 0.5;
423        let max_speed = 20.0;
424        let max_distance = 100.0;
425
426        let normalized = (distance / max_distance).min(1.0);
427        let eased = 1.0 - (1.0 - normalized).powi(2);
428        base_speed + eased * (max_speed - base_speed)
429    }
430
431    fn apply_auto_scroll(&mut self, window: &Window) {
432        if self.auto_scroll_speed == 0.0 {
433            return;
434        }
435
436        let content_width = self.x_for_cursor(self.core.content().len(), window);
437        let padding = 2.0;
438        let actual_visible = self.visible_width - padding;
439        let max_scroll = (content_width - actual_visible).max(0.0);
440
441        self.scroll_offset = (self.scroll_offset + self.auto_scroll_speed).clamp(0.0, max_scroll);
442
443        if self.auto_scroll_speed < 0.0 {
444            let new_cursor = self.cursor_at_x(0.0, window);
445            self.core.extend_selection_to(new_cursor);
446        } else {
447            let new_cursor = self.cursor_at_x(actual_visible, window);
448            self.core.extend_selection_to(new_cursor);
449        }
450    }
451
452    fn stop_drag(&mut self) {
453        self.is_dragging = false;
454        self.auto_scroll_active = false;
455        self.auto_scroll_speed = 0.0;
456    }
457
458    fn spawn_auto_scroll_timer_if_needed(&mut self, scroll_speed: f32, window: &mut Window, cx: &mut Context<Self>) {
459        self.auto_scroll_speed = scroll_speed;
460        if scroll_speed != 0.0 && !self.auto_scroll_active {
461            self.auto_scroll_active = true;
462            let entity = cx.entity();
463            window.spawn(cx, async move |async_cx| {
464                loop {
465                    smol::Timer::after(Duration::from_millis(32)).await;
466                    let should_continue = async_cx
467                        .update_entity(&entity, |this, cx| {
468                            if !this.auto_scroll_active || !this.is_dragging {
469                                this.auto_scroll_active = false;
470                                return false;
471                            }
472                            cx.notify();
473                            true
474                        })
475                        .unwrap_or(false);
476                    if !should_continue {
477                        break;
478                    }
479                }
480            }).detach();
481        }
482    }
483
484    fn on_focus(&mut self, cx: &mut Context<Self>) {
485        self.reset_cursor_blink();
486        cx.notify();
487    }
488
489    fn on_blur(&mut self, cx: &mut Context<Self>) {
490        self.stop_drag();
491        cx.emit(PasswordInputEvent::Blur);
492        cx.notify();
493    }
494
495    // Clipboard operations - password content should NEVER be copied
496    fn cut(&mut self, cx: &mut Context<Self>) {
497        // Delete selection but don't copy to clipboard (security)
498        if self.core.delete_selection() {
499            self.reset_cursor_blink();
500            self.emit_change(cx);
501            cx.notify();
502        }
503    }
504
505    fn paste(&mut self, cx: &mut Context<Self>) {
506        if let Some(clipboard) = cx.read_from_clipboard() {
507            if let Some(text) = clipboard.text() {
508                let clean_text = text.replace(['\n', '\r'], "");
509                self.core.insert_text(&clean_text);
510                self.reset_cursor_blink();
511                self.emit_change(cx);
512                cx.notify();
513            }
514        }
515    }
516
517    // Action handlers
518    fn handle_move_left(&mut self, cx: &mut Context<Self>) {
519        self.core.move_left();
520        self.reset_cursor_blink();
521        cx.notify();
522    }
523
524    fn handle_move_right(&mut self, cx: &mut Context<Self>) {
525        self.core.move_right();
526        self.reset_cursor_blink();
527        cx.notify();
528    }
529
530    fn handle_move_word_left(&mut self, cx: &mut Context<Self>) {
531        self.core.move_word_left();
532        self.reset_cursor_blink();
533        cx.notify();
534    }
535
536    fn handle_move_word_right(&mut self, cx: &mut Context<Self>) {
537        self.core.move_word_right();
538        self.reset_cursor_blink();
539        cx.notify();
540    }
541
542    fn handle_move_to_start(&mut self, cx: &mut Context<Self>) {
543        self.core.move_to_start();
544        self.reset_cursor_blink();
545        cx.notify();
546    }
547
548    fn handle_move_to_end(&mut self, cx: &mut Context<Self>) {
549        self.core.move_to_end();
550        self.reset_cursor_blink();
551        cx.notify();
552    }
553
554    fn handle_select_left(&mut self, cx: &mut Context<Self>) {
555        self.core.select_left();
556        self.reset_cursor_blink();
557        cx.notify();
558    }
559
560    fn handle_select_right(&mut self, cx: &mut Context<Self>) {
561        self.core.select_right();
562        self.reset_cursor_blink();
563        cx.notify();
564    }
565
566    fn handle_select_word_left(&mut self, cx: &mut Context<Self>) {
567        self.core.select_word_left();
568        self.reset_cursor_blink();
569        cx.notify();
570    }
571
572    fn handle_select_word_right(&mut self, cx: &mut Context<Self>) {
573        self.core.select_word_right();
574        self.reset_cursor_blink();
575        cx.notify();
576    }
577
578    fn handle_select_to_start(&mut self, cx: &mut Context<Self>) {
579        self.core.select_to_start();
580        self.reset_cursor_blink();
581        cx.notify();
582    }
583
584    fn handle_select_to_end(&mut self, cx: &mut Context<Self>) {
585        self.core.select_to_end();
586        self.reset_cursor_blink();
587        cx.notify();
588    }
589
590    fn handle_select_all(&mut self, cx: &mut Context<Self>) {
591        self.core.select_all();
592        self.reset_cursor_blink();
593        cx.notify();
594    }
595
596    fn handle_delete_backward(&mut self, cx: &mut Context<Self>) {
597        if self.core.delete_backward() {
598            self.reset_cursor_blink();
599            self.emit_change(cx);
600        }
601        cx.notify();
602    }
603
604    fn handle_delete_forward(&mut self, cx: &mut Context<Self>) {
605        if self.core.delete_forward() {
606            self.reset_cursor_blink();
607            self.emit_change(cx);
608        }
609        cx.notify();
610    }
611
612    fn handle_delete_word_backward(&mut self, cx: &mut Context<Self>) {
613        if self.core.delete_word_backward() {
614            self.reset_cursor_blink();
615            self.emit_change(cx);
616        }
617        cx.notify();
618    }
619
620    fn handle_delete_word_forward(&mut self, cx: &mut Context<Self>) {
621        if self.core.delete_word_forward() {
622            self.reset_cursor_blink();
623            self.emit_change(cx);
624        }
625        cx.notify();
626    }
627
628    fn handle_insert_text(&mut self, text: &str, cx: &mut Context<Self>) {
629        self.core.insert_text(text);
630        self.reset_cursor_blink();
631        self.emit_change(cx);
632        cx.notify();
633    }
634}
635
636impl Render for PasswordInput {
637    fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
638        // Apply any pending builder values on first render
639        self.apply_pending();
640
641        let theme = get_theme_or(cx, self.custom_theme.as_ref());
642        let enabled = self.enabled;
643        let input_focus_handle = self.input_focus_handle.clone().tab_stop(enabled);
644        let toggle_focus_handle = self.toggle_focus_handle.clone().tab_stop(enabled);
645        let input_is_focused = self.input_focus_handle.is_focused(window);
646        let toggle_is_focused = self.toggle_focus_handle.is_focused(window);
647        let either_focused = input_is_focused || toggle_is_focused;
648
649        // Set up focus-out subscription on first render
650        if !self.focus_out_subscribed {
651            self.focus_out_subscribed = true;
652            let input_fh = self.input_focus_handle.clone();
653            cx.on_focus_out(&input_fh, window, |this: &mut Self, _event, window, cx| {
654                // Only blur if neither input nor toggle has focus
655                if !this.input_focus_handle.is_focused(window) && !this.toggle_focus_handle.is_focused(window) {
656                    this.on_blur(cx);
657                }
658            }).detach();
659        }
660
661        // Detect focus-in
662        if input_is_focused && !self.was_focused {
663            self.on_focus(cx);
664        }
665        self.was_focused = input_is_focused;
666
667        let render_scroll_offset = if input_is_focused { self.scroll_offset } else { 0.0 };
668
669        // Set up blink timer when focused
670        if input_is_focused && !self.blink_timer_active {
671            self.blink_timer_active = true;
672            let entity = cx.entity();
673            let blink_period = CursorBlink::blink_period();
674            window.spawn(cx, async move |async_cx| {
675                loop {
676                    smol::Timer::after(blink_period).await;
677                    let should_continue = async_cx
678                        .update_entity(&entity, |this, cx| {
679                            if !this.blink_timer_active {
680                                return false;
681                            }
682                            cx.notify();
683                            true
684                        })
685                        .unwrap_or(false);
686                    if !should_continue {
687                        break;
688                    }
689                }
690            }).detach();
691        }
692
693        if !input_is_focused {
694            self.blink_timer_active = false;
695        }
696
697        // Apply auto-scroll if active
698        if self.auto_scroll_active && self.is_dragging {
699            self.apply_auto_scroll(window);
700        }
701
702        if input_is_focused {
703            self.ensure_cursor_visible(window);
704        }
705
706        let display_content = self.display_content();
707        let has_content = !self.core.content().is_empty();
708        let placeholder = self.placeholder.clone();
709
710        let cursor = self.core.cursor();
711        let selection = self.core.selection();
712
713        let cursor_x = self.x_for_cursor(cursor, window) - render_scroll_offset;
714        let cursor_visible = input_is_focused && self.cursor_blink.is_visible();
715
716        let selection_bounds: Option<(f32, f32)> = if input_is_focused {
717            selection.and_then(|(start, end)| {
718                if start != end {
719                    let start_x = self.x_for_cursor(start, window) - render_scroll_offset;
720                    let end_x = self.x_for_cursor(end, window) - render_scroll_offset;
721                    Some((start_x, end_x - start_x))
722                } else {
723                    None
724                }
725            })
726        } else {
727            None
728        };
729
730        let scroll_offset = render_scroll_offset;
731
732        // Colors
733        let bg_color = if enabled { theme.bg_input } else { theme.disabled_bg };
734        let border_color = if either_focused && enabled {
735            theme.border_focus
736        } else {
737            theme.border_input
738        };
739        let separator_color = theme.text_muted;
740        let button_text_color = if enabled { theme.text_muted } else { theme.disabled_text };
741        let selection_color = theme.selection;
742        let text_color = if enabled { theme.text_primary } else { theme.disabled_text };
743        let text_placeholder = theme.text_placeholder;
744
745        // Eye icons
746        let eye_icon = if self.show_password { "\u{2296}" } else { "\u{25CE}" }; // ⊖ / ◎
747
748        // Vertical separator
749        let separator = div()
750            .w(px(1.0))
751            .h_full()
752            .bg(rgb(separator_color));
753
754        // Main container
755        div()
756            .id("ccf_password_input")
757            .flex()
758            .flex_row()
759            .items_center()
760            .h(px(28.0))
761            .bg(rgb(bg_color))
762            .border_1()
763            .border_color(rgb(border_color))
764            .rounded_md()
765            .overflow_hidden()
766            // Input area
767            .child(
768                div()
769                    .id("ccf_password_input_field")
770                    .key_context("CcfTextInput")
771                    .track_focus(&input_focus_handle)
772                    .flex_1()
773                    .h_full()
774                    .px_2()
775                    .when(enabled, |d| d.cursor_text())
776                    .when(!enabled, |d| d.cursor_default())
777                    .relative()
778                    .overflow_hidden()
779                    // Navigation actions
780                    .on_action(cx.listener(|this, _: &MoveLeft, _window, cx| {
781                        if !this.enabled { return; }
782                        this.handle_move_left(cx);
783                    }))
784                    .on_action(cx.listener(|this, _: &MoveRight, _window, cx| {
785                        if !this.enabled { return; }
786                        this.handle_move_right(cx);
787                    }))
788                    .on_action(cx.listener(|this, _: &MoveWordLeft, _window, cx| {
789                        if !this.enabled { return; }
790                        this.handle_move_word_left(cx);
791                    }))
792                    .on_action(cx.listener(|this, _: &MoveWordRight, _window, cx| {
793                        if !this.enabled { return; }
794                        this.handle_move_word_right(cx);
795                    }))
796                    .on_action(cx.listener(|this, _: &MoveToStart, _window, cx| {
797                        if !this.enabled { return; }
798                        this.handle_move_to_start(cx);
799                    }))
800                    .on_action(cx.listener(|this, _: &MoveToEnd, _window, cx| {
801                        if !this.enabled { return; }
802                        this.handle_move_to_end(cx);
803                    }))
804                    // Selection actions
805                    .on_action(cx.listener(|this, _: &SelectLeft, _window, cx| {
806                        if !this.enabled { return; }
807                        this.handle_select_left(cx);
808                    }))
809                    .on_action(cx.listener(|this, _: &SelectRight, _window, cx| {
810                        if !this.enabled { return; }
811                        this.handle_select_right(cx);
812                    }))
813                    .on_action(cx.listener(|this, _: &SelectWordLeft, _window, cx| {
814                        if !this.enabled { return; }
815                        this.handle_select_word_left(cx);
816                    }))
817                    .on_action(cx.listener(|this, _: &SelectWordRight, _window, cx| {
818                        if !this.enabled { return; }
819                        this.handle_select_word_right(cx);
820                    }))
821                    .on_action(cx.listener(|this, _: &SelectToStart, _window, cx| {
822                        if !this.enabled { return; }
823                        this.handle_select_to_start(cx);
824                    }))
825                    .on_action(cx.listener(|this, _: &SelectToEnd, _window, cx| {
826                        if !this.enabled { return; }
827                        this.handle_select_to_end(cx);
828                    }))
829                    .on_action(cx.listener(|this, _: &SelectAll, _window, cx| {
830                        if !this.enabled { return; }
831                        this.handle_select_all(cx);
832                    }))
833                    // Delete actions
834                    .on_action(cx.listener(|this, _: &DeleteBackward, _window, cx| {
835                        if !this.enabled { return; }
836                        this.handle_delete_backward(cx);
837                    }))
838                    .on_action(cx.listener(|this, _: &DeleteForward, _window, cx| {
839                        if !this.enabled { return; }
840                        this.handle_delete_forward(cx);
841                    }))
842                    .on_action(cx.listener(|this, _: &DeleteWordBackward, _window, cx| {
843                        if !this.enabled { return; }
844                        this.handle_delete_word_backward(cx);
845                    }))
846                    .on_action(cx.listener(|this, _: &DeleteWordForward, _window, cx| {
847                        if !this.enabled { return; }
848                        this.handle_delete_word_forward(cx);
849                    }))
850                    // Clipboard actions - Copy is disabled for security
851                    .on_action(cx.listener(|this, _: &Cut, _window, cx| {
852                        if !this.enabled { return; }
853                        this.cut(cx);
854                    }))
855                    .on_action(cx.listener(|_this, _: &Copy, _window, _cx| {
856                        // Intentionally empty - don't allow copying password
857                    }))
858                    .on_action(cx.listener(|this, _: &Paste, _window, cx| {
859                        if !this.enabled { return; }
860                        this.paste(cx);
861                    }))
862                    // Enter/Escape
863                    .on_action(cx.listener(|this, _: &Enter, _window, cx| {
864                        if !this.enabled { return; }
865                        cx.emit(PasswordInputEvent::Enter);
866                    }))
867                    .on_action(cx.listener(|this, _: &Escape, _window, cx| {
868                        if !this.enabled { return; }
869                        this.on_blur(cx);
870                    }))
871                    // Focus navigation
872                    .on_action(cx.listener(|_this, _: &FocusNext, window, _cx| {
873                        window.focus_next();
874                    }))
875                    .on_action(cx.listener(|_this, _: &FocusPrev, window, _cx| {
876                        window.focus_prev();
877                    }))
878                    // Character input
879                    .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
880                        if handle_tab_navigation(event, window) {
881                            return;
882                        }
883                        if !this.enabled { return; }
884                        if !event.keystroke.modifiers.alt
885                            && !event.keystroke.modifiers.control
886                            && !event.keystroke.modifiers.platform
887                        {
888                            if let Some(ref ch) = event.keystroke.key_char {
889                                this.handle_insert_text(ch, cx);
890                            }
891                        }
892                    }))
893                    // Click to focus and position cursor
894                    .when(enabled, |d| d.on_mouse_down(MouseButton::Left, cx.listener(|this, event: &MouseDownEvent, window, cx| {
895                        let was_focused = this.input_focus_handle.is_focused(window);
896                        this.input_focus_handle.focus(window);
897
898                        if !was_focused && this.core.selection().is_some() {
899                            this.reset_cursor_blink();
900                            cx.notify();
901                            return;
902                        }
903
904                        let click_x: f32 = event.position.x.into();
905                        let relative_x = (click_x - this.content_origin_x).max(0.0);
906                        let new_cursor = this.cursor_at_x(relative_x, window);
907
908                        if event.modifiers.shift {
909                            this.core.start_selection_from_cursor();
910                            this.core.extend_selection_to(new_cursor);
911                        } else {
912                            this.core.clear_selection();
913                            this.core.set_cursor(new_cursor);
914                            this.core.start_selection_from_cursor();
915                        }
916
917                        this.is_dragging = true;
918                        this.reset_cursor_blink();
919                        cx.notify();
920                    })))
921                    // Drag to select
922                    .when(enabled, |d| d.on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, window, cx| {
923                        if !this.is_dragging {
924                            return;
925                        }
926                        let mouse_x: f32 = event.position.x.into();
927                        let scroll_speed = this.handle_drag_move(mouse_x, window);
928                        this.spawn_auto_scroll_timer_if_needed(scroll_speed, window, cx);
929                        cx.notify();
930                    })))
931                    // Mouse up ends drag
932                    .when(enabled, |d| d.on_mouse_up(MouseButton::Left, cx.listener(|this, _event: &MouseUpEvent, _window, cx| {
933                        this.stop_drag();
934                        cx.notify();
935                    })))
936                    // Content
937                    .child({
938                        let entity = cx.entity();
939                        let entity_paint = entity.clone();
940                        let is_dragging = self.is_dragging;
941
942                        div()
943                            .size_full()
944                            .flex()
945                            .items_center()
946                            .relative()
947                            // Measurement canvas
948                            .child(
949                                canvas(
950                                    move |bounds, _window, cx| {
951                                        let width: f32 = bounds.size.width.into();
952                                        let origin_x: f32 = bounds.origin.x.into();
953                                        entity.update(cx, |this: &mut PasswordInput, _cx| {
954                                            this.visible_width = width;
955                                            this.content_origin_x = origin_x;
956                                        });
957                                    },
958                                    {
959                                        let entity = entity_paint;
960                                        move |_bounds, _, window, _cx| {
961                                            if is_dragging {
962                                                let entity_move = entity.clone();
963                                                window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| {
964                                                    if phase != DispatchPhase::Capture {
965                                                        return;
966                                                    }
967                                                    let mouse_x: f32 = event.position.x.into();
968                                                    entity_move.update(cx, |this: &mut PasswordInput, cx| {
969                                                        let scroll_speed = this.handle_drag_move(mouse_x, window);
970                                                        this.spawn_auto_scroll_timer_if_needed(scroll_speed, window, cx);
971                                                        cx.notify();
972                                                    });
973                                                });
974
975                                                let entity_up = entity.clone();
976                                                window.on_mouse_event(move |_event: &MouseUpEvent, phase, _window, cx| {
977                                                    if phase != DispatchPhase::Capture {
978                                                        return;
979                                                    }
980                                                    entity_up.update(cx, |this: &mut PasswordInput, cx| {
981                                                        this.stop_drag();
982                                                        cx.notify();
983                                                    });
984                                                });
985                                            }
986                                        }
987                                    },
988                                )
989                                .size_full()
990                                .absolute()
991                            )
992                            // Content layer
993                            .child(
994                                div()
995                                    .relative()
996                                    .h_full()
997                                    .flex()
998                                    .items_center()
999                                    .min_w_0()
1000                                    // Selection highlight
1001                                    .when_some(selection_bounds, |d, (start_x, width)| {
1002                                        d.child(
1003                                            div()
1004                                                .absolute()
1005                                                .top_0()
1006                                                .bottom_0()
1007                                                .left(px(start_x))
1008                                                .w(px(width))
1009                                                .bg(rgb(selection_color))
1010                                        )
1011                                    })
1012                                    // Text content
1013                                    .child(
1014                                        div()
1015                                            .absolute()
1016                                            .left(px(-scroll_offset))
1017                                            .text_sm()
1018                                            .text_color(rgb(text_color))
1019                                            .whitespace_nowrap()
1020                                            .child(display_content.clone())
1021                                    )
1022                                    // Cursor
1023                                    .when(cursor_visible, |d| {
1024                                        d.child(
1025                                            div()
1026                                                .absolute()
1027                                                .top(px(4.))
1028                                                .bottom(px(4.))
1029                                                .left(px(cursor_x))
1030                                                .w(px(1.))
1031                                                .bg(rgb(text_color))
1032                                        )
1033                                    })
1034                            )
1035                            // Placeholder
1036                            .when(!has_content, |d| {
1037                                if let Some(ph) = placeholder {
1038                                    d.child(
1039                                        div()
1040                                            .absolute()
1041                                            .left_0()
1042                                            .text_sm()
1043                                            .text_color(rgb(text_placeholder))
1044                                            .child(ph)
1045                                    )
1046                                } else {
1047                                    d
1048                                }
1049                            })
1050                    })
1051            )
1052            .child(separator)
1053            // Toggle visibility button
1054            .child(
1055                div()
1056                    .id("password_toggle_button")
1057                    .flex()
1058                    .items_center()
1059                    .justify_center()
1060                    .w(px(28.0))
1061                    .h_full()
1062                    .cursor_for_enabled(enabled)
1063                    .text_color(rgb(button_text_color))
1064                    .when(toggle_is_focused && enabled, |d| d.bg(rgb(theme.bg_hover)))
1065                    .when(enabled, |d| d.hover(|d| d.bg(rgb(theme.bg_hover))))
1066                    .track_focus(&toggle_focus_handle)
1067                    .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
1068                        if handle_tab_navigation(event, window) {
1069                            return;
1070                        }
1071                        if matches!(event.keystroke.key.as_str(), "enter" | "space") {
1072                            if !this.enabled { return; }
1073                            this.toggle_visibility(cx);
1074                        }
1075                    }))
1076                    .when(enabled, |d| d.on_click(cx.listener(|this, _event, _window, cx| {
1077                        this.toggle_visibility(cx);
1078                    })))
1079                    .child(
1080                        div()
1081                            .text_sm()
1082                            .child(eye_icon)
1083                    )
1084            )
1085    }
1086}