Skip to main content

agg_gui/widgets/
text_field.rs

1//! `TextField` — single-line editable text input.
2//!
3//! See [`text_field_core`](super::text_field_core) for the internal helpers,
4//! shared edit state, and undo command type.
5//!
6//! Feature set mirrors C# agg-sharp `InternalTextEditWidget`:
7//! - Character / word navigation (arrows, Ctrl+arrow, Home, End)
8//! - Keyboard selection (Shift+movement), Ctrl+A select-all
9//! - Mouse click to position cursor, drag to extend selection
10//! - Double-click to select the word under the cursor
11//! - Cut / Copy / Paste (Ctrl+X/C/V, Shift+Del, Ctrl/Shift+Ins) — requires
12//!   the `clipboard` crate feature; silently no-ops without it
13//! - Undo / Redo via the shared [`UndoBuffer`](crate::undo::UndoBuffer)
14//! - Blinking cursor (500 ms half-period from the moment focus is gained)
15//! - Horizontal scroll to keep cursor visible
16//! - Placeholder text, read-only mode, SelectAllOnFocus
17//! - Callbacks: on_change, on_enter, on_edit_complete
18
19use std::rc::Rc;
20use std::cell::RefCell;
21use std::sync::Arc;
22
23// web-time provides a WASM-compatible Instant (uses performance.now() in the
24// browser; falls back to Instant on native).
25use web_time::Instant;
26
27use crate::event::{Event, EventResult, Key, Modifiers, MouseButton};
28use crate::geometry::{Rect, Size};
29use crate::draw_ctx::DrawCtx;
30use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
31use crate::text::{Font, measure_advance};
32use crate::undo::UndoBuffer;
33use crate::widget::{BackbufferCache, BackbufferMode, Widget};
34use super::text_field_core::{
35    TextEditCommand, TextEditState,
36    byte_at_x, next_char_boundary, next_word_boundary,
37    prev_char_boundary, prev_word_boundary, word_range_at,
38};
39
40// ---------------------------------------------------------------------------
41// Clipboard stubs
42// ---------------------------------------------------------------------------
43
44#[cfg(feature = "clipboard")]
45fn clipboard_get() -> Option<String> {
46    arboard::Clipboard::new().ok()?.get_text().ok()
47}
48/// Native non-clipboard build: silently no-ops (clipboard disabled at compile time).
49#[cfg(all(not(feature = "clipboard"), not(target_arch = "wasm32")))]
50fn clipboard_get() -> Option<String> { None }
51/// WASM build: read from the in-process buffer bridged by the JS harness.
52#[cfg(all(not(feature = "clipboard"), target_arch = "wasm32"))]
53fn clipboard_get() -> Option<String> { crate::wasm_clipboard::get() }
54
55#[cfg(feature = "clipboard")]
56fn clipboard_set(text: &str) {
57    if let Ok(mut cb) = arboard::Clipboard::new() { let _ = cb.set_text(text.to_string()); }
58}
59/// Native non-clipboard build: silently no-ops.
60#[cfg(all(not(feature = "clipboard"), not(target_arch = "wasm32")))]
61fn clipboard_set(_: &str) {}
62/// WASM build: write to the in-process buffer so the JS `copy`/`cut` handler
63/// can forward it to the browser's system clipboard.
64#[cfg(all(not(feature = "clipboard"), target_arch = "wasm32"))]
65fn clipboard_set(text: &str) { crate::wasm_clipboard::set(text); }
66
67// ---------------------------------------------------------------------------
68// TextField
69// ---------------------------------------------------------------------------
70
71/// Single-line editable text field.
72pub struct TextField {
73    bounds:   Rect,
74    children: Vec<Box<dyn Widget>>,
75    base:     WidgetBase,
76
77    // All mutable editing state lives here — shared with undo commands.
78    edit: Rc<RefCell<TextEditState>>,
79
80    // Undo/redo history.
81    undo: UndoBuffer,
82
83    // Pending coalesced insert command (committed when action type changes).
84    pending_insert: Option<TextEditCommand>,
85
86    // Snapshot of text when focus was gained — used to decide if on_edit_complete fires.
87    text_on_focus: String,
88
89    // Font
90    font:      Arc<Font>,
91    font_size: f64,
92
93    // Editing options
94    pub read_only:           bool,
95    pub select_all_on_focus: bool,
96    /// When `true`, every character is displayed as '•' (U+2022).
97    /// The actual text is stored and edited normally; only the render is masked.
98    pub password_mode:       bool,
99
100    // Interaction state
101    focused:    bool,
102    hovered:    bool,
103    mouse_down: bool,
104    scroll_x:   f64,
105
106    // Cursor blink: set to Some(Instant::now()) on FocusGained.
107    focus_time: Option<Instant>,
108    // Blink phase (floor(elapsed_ms / 500)) last drawn by `paint_overlay`.
109    // `needs_paint` compares the current phase against this and reports
110    // dirty when they diverge — i.e. the host-observed time has crossed a
111    // flip boundary since the last paint.  `Cell` so the check can happen
112    // from a `&self` method.  Initialised far out of range so the first
113    // paint after focus always writes the real phase.
114    blink_last_phase: std::cell::Cell<u64>,
115
116    // Double-click detection.
117    last_click_time: Option<Instant>,
118
119    // Content
120    pub placeholder: String,
121
122    // Layout
123    pub padding: f64,
124
125    // Callbacks
126    on_change:        Option<Box<dyn FnMut(&str)>>,
127    on_enter:         Option<Box<dyn FnMut(&str)>>,
128    on_edit_complete: Option<Box<dyn FnMut(&str)>>,
129
130    // ── Backbuffer cache ─────────────────────────────────────────────
131    //
132    // Cache holds bg + text + selection + border.  Cursor draws in
133    // `paint_overlay` directly on the outer ctx AFTER the cache blit
134    // so cursor-blink state flips (twice per second) don't invalidate
135    // the cache.  Sig deliberately excludes `blink_visible`.
136    cache:    BackbufferCache,
137    last_sig: Option<TextFieldSig>,
138}
139
140#[derive(Clone, PartialEq)]
141struct TextFieldSig {
142    text:          String,
143    cursor:        usize,
144    anchor:        usize,
145    focused:       bool,
146    hovered:       bool,
147    scroll_x_bits: u64,
148    w_bits:        u64,
149    h_bits:        u64,
150    // Font identity + size: the cached bitmap was rasterised with a specific
151    // typeface at a specific point size, so any live swap in the System
152    // window (which runs through `font_settings::set_system_font` /
153    // `set_font_size_scale`) must invalidate — otherwise the stale bitmap
154    // keeps blitting until some other field in the sig happens to change
155    // (e.g. the user hovers the control, which flips `hovered`).
156    font_ptr:      usize,
157    font_size_bits: u64,
158}
159
160impl TextField {
161    pub fn new(font: Arc<Font>) -> Self {
162        Self {
163            bounds:   Rect::default(),
164            children: Vec::new(),
165            base:     WidgetBase::new(),
166            edit:     Rc::new(RefCell::new(TextEditState::default())),
167            undo:     UndoBuffer::new(),
168            pending_insert: None,
169            text_on_focus:  String::new(),
170            font,
171            font_size:           14.0,
172            read_only:           false,
173            select_all_on_focus: false,
174            password_mode:       false,
175            focused:    false,
176            hovered:    false,
177            mouse_down: false,
178            scroll_x:   0.0,
179            focus_time:       None,
180            blink_last_phase: std::cell::Cell::new(u64::MAX),
181            last_click_time:  None,
182            placeholder: String::new(),
183            padding: 8.0,
184            on_change:        None,
185            on_enter:         None,
186            on_edit_complete: None,
187            cache:            BackbufferCache::default(),
188            last_sig:         None,
189        }
190    }
191
192    /// Currently-active font — honours the thread-local system-font override
193    /// (`font_settings::current_system_font`) so changes in the System window
194    /// propagate live without a widget-tree rebuild.  Falls back to the font
195    /// passed at construction when no override is set.
196    fn active_font(&self) -> Arc<Font> {
197        crate::font_settings::current_system_font()
198            .unwrap_or_else(|| Arc::clone(&self.font))
199    }
200
201    // ── Builder / setter methods ─────────────────────────────────────────────
202
203    pub fn with_font_size(mut self, s: f64) -> Self { self.font_size = s; self }
204    pub fn with_padding(mut self, p: f64)   -> Self { self.padding   = p; self }
205    pub fn with_read_only(mut self, v: bool) -> Self { self.read_only = v; self }
206    pub fn with_select_all_on_focus(mut self, v: bool) -> Self { self.select_all_on_focus = v; self }
207    pub fn with_password_mode(mut self, v: bool) -> Self { self.password_mode = v; self }
208
209    pub fn with_placeholder(mut self, s: impl Into<String>) -> Self { self.placeholder = s.into(); self }
210    pub fn with_text(self, s: impl Into<String>) -> Self {
211        let t = s.into();
212        let len = t.len();
213        let mut st = self.edit.borrow_mut();
214        st.text   = t;
215        st.cursor = len;
216        st.anchor = len;
217        drop(st);
218        self
219    }
220
221    pub fn on_change(mut self, cb: impl FnMut(&str) + 'static) -> Self {
222        self.on_change = Some(Box::new(cb)); self
223    }
224    pub fn on_enter(mut self, cb: impl FnMut(&str) + 'static) -> Self {
225        self.on_enter = Some(Box::new(cb)); self
226    }
227    pub fn on_edit_complete(mut self, cb: impl FnMut(&str) + 'static) -> Self {
228        self.on_edit_complete = Some(Box::new(cb)); self
229    }
230
231    pub fn with_margin(mut self, m: Insets)    -> Self { self.base.margin   = m; self }
232    pub fn with_h_anchor(mut self, h: HAnchor) -> Self { self.base.h_anchor = h; self }
233    pub fn with_v_anchor(mut self, v: VAnchor) -> Self { self.base.v_anchor = v; self }
234    pub fn with_min_size(mut self, s: Size)    -> Self { self.base.min_size = s; self }
235    pub fn with_max_size(mut self, s: Size)    -> Self { self.base.max_size = s; self }
236
237    // ── Getters ──────────────────────────────────────────────────────────────
238
239    pub fn text(&self) -> String { self.edit.borrow().text.clone() }
240    pub fn cursor_pos(&self) -> usize { self.edit.borrow().cursor }
241    pub fn selection(&self) -> String {
242        let st = self.edit.borrow();
243        let lo = st.cursor.min(st.anchor);
244        let hi = st.cursor.max(st.anchor);
245        st.text[lo..hi].to_string()
246    }
247
248    pub fn set_text(&mut self, s: impl Into<String>) {
249        let t = s.into();
250        let len = t.len();
251        let mut st = self.edit.borrow_mut();
252        st.text   = t;
253        st.cursor = len;
254        st.anchor = len;
255        drop(st);
256        self.undo.clear_history();
257        self.pending_insert = None;
258    }
259
260    // ── Private state helpers ────────────────────────────────────────────────
261
262    fn snap(&self) -> TextEditState { self.edit.borrow().clone() }
263    #[allow(dead_code)]
264    fn apply(&self, s: TextEditState) { *self.edit.borrow_mut() = s; }
265
266    #[allow(dead_code)]
267    fn sel_min(&self) -> usize { let s = self.edit.borrow(); s.cursor.min(s.anchor) }
268    #[allow(dead_code)]
269    fn sel_max(&self) -> usize { let s = self.edit.borrow(); s.cursor.max(s.anchor) }
270    fn has_selection(&self) -> bool { let s = self.edit.borrow(); s.cursor != s.anchor }
271
272    /// Commit any pending coalesced insert command to the undo buffer.
273    fn flush_pending(&mut self) {
274        if let Some(cmd) = self.pending_insert.take() {
275            self.undo.add(Box::new(cmd));
276        }
277    }
278
279    /// Convert a pixel x position (in text-local space) to a byte offset in
280    /// `real_text`.  In password mode, measures the masked string and maps back.
281    fn click_to_cursor(&self, real_text: &str, tx: f64) -> usize {
282        let font = self.active_font();
283        if self.password_mode {
284            const BULLET: char = '•';
285            const BULLET_LEN: usize = 3;
286            let n      = real_text.chars().count();
287            let masked = BULLET.to_string().repeat(n);
288            let disp   = byte_at_x(&font, &masked, self.font_size, tx);
289            // Map masked byte offset → char index → real byte offset.
290            let char_idx = disp / BULLET_LEN;
291            real_text.char_indices()
292                .nth(char_idx)
293                .map(|(i, _)| i)
294                .unwrap_or(real_text.len())
295        } else {
296            byte_at_x(&font, real_text, self.font_size, tx)
297        }
298    }
299
300    /// Scroll `scroll_x` so that the cursor stays visible.
301    fn ensure_cursor_visible(&mut self) {
302        if self.bounds.width < 1.0 { return; }
303        let inner_w = (self.bounds.width - self.padding * 2.0).max(0.0);
304        let font = self.active_font();
305        let cx = {
306            let st = self.edit.borrow();
307            if self.password_mode {
308                const BULLET: char = '•';
309                #[allow(dead_code)]
310                const BULLET_LEN: usize = 3;
311                let n      = st.text[..st.cursor].chars().count();
312                let masked = BULLET.to_string().repeat(n);
313                measure_advance(&font, &masked, self.font_size)
314            } else {
315                measure_advance(&font, &st.text[..st.cursor], self.font_size)
316            }
317        };
318        if cx < self.scroll_x { self.scroll_x = cx; }
319        else if cx > self.scroll_x + inner_w { self.scroll_x = cx - inner_w; }
320    }
321
322    // ── Edit operations ──────────────────────────────────────────────────────
323
324    /// Insert `s` at cursor, replacing any selection.
325    /// Consecutive single-char inserts are coalesced into one undo command.
326    fn do_insert(&mut self, s: &str, is_single_char: bool) {
327        let before = self.snap();
328        let had_selection = before.cursor != before.anchor;
329
330        // Apply the change
331        {
332            let mut st = self.edit.borrow_mut();
333            if st.cursor != st.anchor {
334                let lo = st.cursor.min(st.anchor);
335                let hi = st.cursor.max(st.anchor);
336                st.text.drain(lo..hi);
337                st.cursor = lo;
338                st.anchor = lo;
339            }
340            let cursor = st.cursor;
341            st.text.insert_str(cursor, s);
342            st.cursor = cursor + s.len();
343            st.anchor = st.cursor;
344        }
345
346        let after = self.snap();
347
348        if is_single_char && !had_selection {
349            // Extend the pending coalesced command if one exists, otherwise start one.
350            if let Some(ref mut pending) = self.pending_insert {
351                pending.after = after;
352            } else {
353                self.pending_insert = Some(TextEditCommand {
354                    name: "insert text",
355                    before,
356                    after,
357                    target: Rc::clone(&self.edit),
358                });
359            }
360        } else {
361            // Non-char insert (paste) or insert-over-selection: commit pending and push new.
362            self.flush_pending();
363            self.undo.add(Box::new(TextEditCommand {
364                name: "insert text",
365                before,
366                after,
367                target: Rc::clone(&self.edit),
368            }));
369        }
370
371        self.ensure_cursor_visible();
372        self.notify_change();
373    }
374
375    /// Delete the selection (if any) or a single char/word, then push undo.
376    fn do_delete(&mut self, forward: bool, word: bool) {
377        self.flush_pending();
378        let before = self.snap();
379        {
380            let mut st = self.edit.borrow_mut();
381            if st.cursor != st.anchor {
382                let lo = st.cursor.min(st.anchor);
383                let hi = st.cursor.max(st.anchor);
384                st.text.drain(lo..hi);
385                st.cursor = lo;
386                st.anchor = lo;
387            } else if forward {
388                let cursor = st.cursor;
389                let end = if word { next_word_boundary(&st.text, cursor) }
390                          else    { next_char_boundary(&st.text, cursor) };
391                if end > cursor { st.text.drain(cursor..end); }
392                st.anchor = st.cursor;
393            } else {
394                let cursor = st.cursor;
395                let start = if word { prev_word_boundary(&st.text, cursor) }
396                            else    { prev_char_boundary(&st.text, cursor) };
397                if start < cursor {
398                    st.text.drain(start..cursor);
399                    st.cursor = start;
400                    st.anchor = start;
401                }
402            }
403        }
404        let after = self.snap();
405        self.undo.add(Box::new(TextEditCommand {
406            name: "delete text", before, after, target: Rc::clone(&self.edit),
407        }));
408        self.ensure_cursor_visible();
409        self.notify_change();
410    }
411
412    fn do_undo(&mut self) {
413        self.flush_pending();
414        self.undo.undo();
415        // Clamp positions in case the text changed length.
416        let len = self.edit.borrow().text.len();
417        let mut st = self.edit.borrow_mut();
418        st.cursor = st.cursor.min(len);
419        st.anchor = st.anchor.min(len);
420        drop(st);
421        self.ensure_cursor_visible();
422        self.notify_change();
423    }
424
425    fn do_redo(&mut self) {
426        self.flush_pending();
427        self.undo.redo();
428        let len = self.edit.borrow().text.len();
429        let mut st = self.edit.borrow_mut();
430        st.cursor = st.cursor.min(len);
431        st.anchor = st.anchor.min(len);
432        drop(st);
433        self.ensure_cursor_visible();
434        self.notify_change();
435    }
436
437    // ── Callback dispatchers ─────────────────────────────────────────────────
438
439    fn notify_change(&mut self) {
440        if let Some(mut cb) = self.on_change.take() {
441            let t = self.text(); cb(&t); self.on_change = Some(cb);
442        }
443    }
444    fn notify_enter(&mut self) {
445        if let Some(mut cb) = self.on_enter.take() {
446            let t = self.text(); cb(&t); self.on_enter = Some(cb);
447        }
448    }
449    fn notify_edit_complete(&mut self) {
450        if let Some(mut cb) = self.on_edit_complete.take() {
451            let t = self.text(); cb(&t); self.on_edit_complete = Some(cb);
452        }
453    }
454
455    // ── Keyboard handler ─────────────────────────────────────────────────────
456
457    fn handle_key(&mut self, key: &Key, mods: Modifiers) -> EventResult {
458        // Snapshot cursor/anchor before movement so we can keep anchor on Shift.
459        let anchor_before = self.edit.borrow().anchor;
460
461        // Command modifier (clipboard / select-all / undo): `Ctrl` on Windows
462        // and Linux, `Cmd` (meta) on macOS.  Treating the two as equivalent
463        // means the same handler serves both OSes without branching.
464        let cmd = mods.ctrl || mods.meta;
465        // Word-navigation modifier: `Ctrl` on Windows/Linux, `Option`
466        // (alt) on macOS.  Used for Ctrl/Alt+Arrow, Ctrl/Alt+Backspace,
467        // Ctrl/Alt+Delete.
468        let word = mods.ctrl || mods.alt;
469
470        match key {
471            // ── Printable characters (and Ctrl/Cmd shortcuts on Char) ──────
472            Key::Char(c) if !self.read_only || cmd => {
473                if cmd {
474                    return match c {
475                        'a' | 'A' => {
476                            let len = self.edit.borrow().text.len();
477                            let mut st = self.edit.borrow_mut();
478                            st.anchor = 0; st.cursor = len;
479                            EventResult::Consumed
480                        }
481                        'z' | 'Z' if !mods.shift => { self.do_undo(); EventResult::Consumed }
482                        'z' | 'Z' | 'y' | 'Y'   => { self.do_redo(); EventResult::Consumed }
483                        'x' | 'X' => {
484                            if self.has_selection() {
485                                clipboard_set(&self.selection());
486                                self.do_delete(false, false); // delete selection via do_delete
487                            }
488                            EventResult::Consumed
489                        }
490                        'c' | 'C' => {
491                            if self.has_selection() { clipboard_set(&self.selection()); }
492                            EventResult::Consumed
493                        }
494                        'v' | 'V' => {
495                            if let Some(clip) = clipboard_get() { self.do_insert(&clip, false); }
496                            EventResult::Consumed
497                        }
498                        _ => EventResult::Ignored,
499                    };
500                }
501                if self.read_only { return EventResult::Ignored; }
502                let mut buf = [0u8; 4];
503                let s = c.encode_utf8(&mut buf);
504                self.do_insert(s, true);
505                EventResult::Consumed
506            }
507
508            // ── Insert clipboard shortcuts ────────────────────────────────
509            // Classic Windows bindings (still common on Linux):
510            //   Shift+Insert = Paste
511            //   Ctrl+Insert  = Copy
512            // Plain `Insert` toggles overwrite mode in many editors — we
513            // don't model overwrite, so plain Insert is a no-op here.
514            Key::Insert => {
515                if mods.shift && !self.read_only {
516                    if let Some(clip) = clipboard_get() { self.do_insert(&clip, false); }
517                    return EventResult::Consumed;
518                }
519                if cmd {
520                    if self.has_selection() { clipboard_set(&self.selection()); }
521                    return EventResult::Consumed;
522                }
523                EventResult::Ignored
524            }
525
526            // ── Backspace ─────────────────────────────────────────────────
527            Key::Backspace if !self.read_only => {
528                self.do_delete(false, word);
529                EventResult::Consumed
530            }
531
532            // ── Delete ────────────────────────────────────────────────────
533            Key::Delete if !self.read_only => {
534                if mods.shift {
535                    // Shift+Delete = Cut
536                    if self.has_selection() { clipboard_set(&self.selection()); self.do_delete(false, false); }
537                } else {
538                    self.do_delete(true, word);
539                }
540                EventResult::Consumed
541            }
542
543            // ── Arrow Left ────────────────────────────────────────────────
544            // Mac: `Cmd+Left` = start of line (Home behaviour).
545            // Win/Mac: `Ctrl+Left` / `Option+Left` = previous word.
546            // Plain: one character back (or collapse selection to left).
547            Key::ArrowLeft => {
548                self.flush_pending();
549                let (cur, anchor) = {
550                    let st = self.edit.borrow();
551                    (st.cursor, st.anchor)
552                };
553                let new_cur = if mods.meta {
554                    0                                        // Mac: Cmd+Left = line start
555                } else if !mods.shift && cur != anchor {
556                    cur.min(anchor)                          // collapse to left
557                } else if word {
558                    prev_word_boundary(&self.edit.borrow().text, cur)
559                } else {
560                    prev_char_boundary(&self.edit.borrow().text, cur)
561                };
562                let new_anchor = if mods.shift { anchor } else { new_cur };
563                let mut st = self.edit.borrow_mut();
564                st.cursor = new_cur; st.anchor = new_anchor;
565                drop(st);
566                if new_cur == 0 { self.scroll_x = 0.0; }
567                self.ensure_cursor_visible();
568                EventResult::Consumed
569            }
570
571            // ── Arrow Right ───────────────────────────────────────────────
572            // Symmetric with ArrowLeft.  Mac: `Cmd+Right` = end of line.
573            Key::ArrowRight => {
574                self.flush_pending();
575                let text_len = self.edit.borrow().text.len();
576                let (cur, anchor) = {
577                    let st = self.edit.borrow();
578                    (st.cursor, st.anchor)
579                };
580                let new_cur = if mods.meta {
581                    text_len                                 // Mac: Cmd+Right = line end
582                } else if !mods.shift && cur != anchor {
583                    cur.max(anchor)                          // collapse to right
584                } else if word {
585                    next_word_boundary(&self.edit.borrow().text, cur)
586                } else if cur < text_len {
587                    next_char_boundary(&self.edit.borrow().text, cur)
588                } else {
589                    cur
590                };
591                let new_anchor = if mods.shift { anchor } else { new_cur };
592                let mut st = self.edit.borrow_mut();
593                st.cursor = new_cur; st.anchor = new_anchor;
594                drop(st);
595                self.ensure_cursor_visible();
596                EventResult::Consumed
597            }
598
599            // ── Arrow Up / Down ──────────────────────────────────────────
600            // Single-line field, so vertical arrows only matter for the Mac
601            // `Cmd+Up` / `Cmd+Down` (start / end of document) convention —
602            // treat as Home / End.  Plain arrows fall through so callers
603            // can spin numeric-input-style steppers, etc.
604            Key::ArrowUp if mods.meta => {
605                self.flush_pending();
606                let (_, anchor) = { let st = self.edit.borrow(); (st.cursor, st.anchor) };
607                let new_cur = 0;
608                let new_anchor = if mods.shift { anchor } else { new_cur };
609                let mut st = self.edit.borrow_mut();
610                st.cursor = new_cur; st.anchor = new_anchor;
611                drop(st);
612                self.scroll_x = 0.0;
613                EventResult::Consumed
614            }
615            Key::ArrowDown if mods.meta => {
616                self.flush_pending();
617                let len = self.edit.borrow().text.len();
618                let (_, anchor) = { let st = self.edit.borrow(); (st.cursor, st.anchor) };
619                let new_cur = len;
620                let new_anchor = if mods.shift { anchor } else { new_cur };
621                let mut st = self.edit.borrow_mut();
622                st.cursor = new_cur; st.anchor = new_anchor;
623                drop(st);
624                self.ensure_cursor_visible();
625                EventResult::Consumed
626            }
627
628            // ── Home ──────────────────────────────────────────────────────
629            // Ctrl+Home is "start of document" on Windows — for a single-
630            // line field that's the same as plain Home; accept both.
631            Key::Home => {
632                self.flush_pending();
633                let mut st = self.edit.borrow_mut();
634                st.cursor = 0;
635                if !mods.shift { st.anchor = 0; }
636                drop(st);
637                self.scroll_x = 0.0;
638                EventResult::Consumed
639            }
640
641            // ── End ───────────────────────────────────────────────────────
642            // Ctrl+End analogous to Ctrl+Home — treated as plain End here.
643            Key::End => {
644                self.flush_pending();
645                let len = self.edit.borrow().text.len();
646                let mut st = self.edit.borrow_mut();
647                st.cursor = len;
648                if !mods.shift { st.anchor = len; }
649                drop(st);
650                self.ensure_cursor_visible();
651                EventResult::Consumed
652            }
653
654            // ── Enter ─────────────────────────────────────────────────────
655            // Commit as edit-complete too so numeric/parsed fields apply the
656            // value on Enter (not only on blur).  Snapshot text to prevent a
657            // second edit-complete firing on later focus loss.
658            Key::Enter => {
659                self.flush_pending();
660                self.notify_enter();
661                if self.text() != self.text_on_focus {
662                    self.notify_edit_complete();
663                    self.text_on_focus = self.text();
664                }
665                EventResult::Consumed
666            }
667
668            // ── Escape: clear selection ───────────────────────────────────
669            Key::Escape => {
670                self.flush_pending();
671                let cur = self.edit.borrow().cursor;
672                self.edit.borrow_mut().anchor = cur;
673                EventResult::Consumed
674            }
675
676            _ => { let _ = anchor_before; EventResult::Ignored }
677        }
678    }
679}
680
681// ---------------------------------------------------------------------------
682// Widget impl
683// ---------------------------------------------------------------------------
684
685impl Widget for TextField {
686    fn type_name(&self)  -> &'static str { "TextField" }
687    fn bounds(&self)     -> Rect         { self.bounds }
688    fn set_bounds(&mut self, b: Rect)    { self.bounds = b; }
689    fn children(&self)   -> &[Box<dyn Widget>] { &self.children }
690    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
691    fn is_focusable(&self) -> bool { true }
692
693    /// While focused, the cursor blinks at 500 ms half-period.  The field
694    /// itself drives its own repaint cadence: [`needs_paint`] reports dirty
695    /// whenever wall-clock time has crossed a flip boundary since the last
696    /// paint, and [`next_paint_deadline`] returns the exact wall-clock
697    /// instant of the next boundary so the host can `WaitUntil` it.
698    ///
699    /// Losing focus makes both return `None` / `false`, and the tree walk's
700    /// visibility check drops the field entirely when its enclosing window
701    /// is closed / collapsed / tab not selected — so an invisible focused
702    /// field does NOT keep the loop awake.
703    fn needs_paint(&self) -> bool {
704        if !self.focused { return false; }
705        let Some(t) = self.focus_time else { return false; };
706        let current_phase = (t.elapsed().as_millis() / 500) as u64;
707        current_phase != self.blink_last_phase.get()
708    }
709
710    fn next_paint_deadline(&self) -> Option<web_time::Instant> {
711        if !self.focused { return None; }
712        let t = self.focus_time?;
713        let ms = t.elapsed().as_millis() as u64;
714        let next_phase = (ms / 500) + 1;
715        Some(t + std::time::Duration::from_millis(next_phase * 500))
716    }
717
718    fn margin(&self)   -> Insets  { self.base.margin }
719    fn h_anchor(&self) -> HAnchor { self.base.h_anchor }
720    fn v_anchor(&self) -> VAnchor { self.base.v_anchor }
721    fn min_size(&self) -> Size    { self.base.min_size }
722    fn max_size(&self) -> Size    { self.base.max_size }
723
724    fn backbuffer_cache_mut(&mut self) -> Option<&mut BackbufferCache> {
725        Some(&mut self.cache)
726    }
727
728    fn backbuffer_mode(&self) -> BackbufferMode {
729        if crate::font_settings::lcd_enabled() {
730            BackbufferMode::LcdCoverage
731        } else {
732            BackbufferMode::Rgba
733        }
734    }
735
736    fn layout(&mut self, available: Size) -> Size {
737        // Sig excludes cursor-blink phase.  Cursor paints in
738        // `paint_overlay` after cache blit — no blink-driven
739        // invalidation.
740        let st   = self.edit.borrow();
741        let font = self.active_font();
742        let sig = TextFieldSig {
743            text:           st.text.clone(),
744            cursor:         st.cursor,
745            anchor:         st.anchor,
746            focused:        self.focused,
747            hovered:        self.hovered,
748            scroll_x_bits:  self.scroll_x.to_bits(),
749            w_bits:         self.bounds.width .to_bits(),
750            h_bits:         self.bounds.height.to_bits(),
751            font_ptr:       Arc::as_ptr(&font) as usize,
752            font_size_bits: self.font_size.to_bits(),
753        };
754        drop(st);
755        if self.last_sig.as_ref() != Some(&sig) {
756            self.last_sig = Some(sig);
757            self.cache.invalidate();
758        }
759        Size::new(available.width, (self.font_size * 2.4).max(28.0))
760    }
761
762    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
763        let w   = self.bounds.width;
764        let h   = self.bounds.height;
765        let r   = 6.0;
766        let pad = self.padding;
767        let (raw_text, raw_cursor, raw_anchor) = {
768            let st = self.edit.borrow();
769            (st.text.clone(), st.cursor, st.anchor)
770        };
771        // In password mode render '•' for every character, but keep byte positions
772        // consistent by recomputing them against the masked string.
773        let (text, cursor, anchor) = if self.password_mode {
774            const BULLET: char = '•';
775            const BULLET_LEN: usize = 3; // '•' is 3 bytes in UTF-8
776            let n     = raw_text.chars().count();
777            let masked = BULLET.to_string().repeat(n);
778            let cur   = raw_text[..raw_cursor].chars().count() * BULLET_LEN;
779            let anc   = raw_text[..raw_anchor].chars().count() * BULLET_LEN;
780            (masked, cur, anc)
781        } else {
782            (raw_text, raw_cursor, raw_anchor)
783        };
784
785        let v = ctx.visuals();
786
787        // ── Background ────────────────────────────────────────────────────
788        ctx.set_fill_color(v.widget_bg);
789        ctx.begin_path();
790        ctx.rounded_rect(0.0, 0.0, w, h, r);
791        ctx.fill();
792
793        // ── Text area clip ────────────────────────────────────────────────
794        ctx.clip_rect(pad, 0.0, (w - pad * 2.0).max(0.0), h);
795
796        let font = self.active_font();
797        ctx.set_font(Arc::clone(&font));
798        ctx.set_font_size(self.font_size);
799
800        let m          = ctx.measure_text("Ag").unwrap_or_default();
801        let baseline_y = h * 0.5 - (m.ascent - m.descent) * 0.5;
802        let text_x     = pad - self.scroll_x;
803
804        // ── Selection highlight ───────────────────────────────────────────
805        if cursor != anchor {
806            let lo = cursor.min(anchor);
807            let hi = cursor.max(anchor);
808            let lo_x = measure_advance(&font, &text[..lo], self.font_size);
809            let hi_x = measure_advance(&font, &text[..hi], self.font_size);
810            let sx   = (text_x + lo_x).max(pad);
811            let sw   = (text_x + hi_x).min(w - pad) - sx;
812            if sw > 0.0 {
813                let hl_bot = baseline_y - m.descent;
814                let hl_h   = (m.ascent + m.descent) * 1.2;
815                ctx.set_fill_color(if self.focused {
816                    v.selection_bg
817                } else {
818                    v.selection_bg_unfocused
819                });
820                ctx.begin_path();
821                ctx.rect(sx, hl_bot - hl_h * 0.1, sw, hl_h);
822                ctx.fill();
823            }
824        }
825
826        // ── Text or placeholder ───────────────────────────────────────────
827        if text.is_empty() && !self.focused {
828            ctx.set_fill_color(v.text_dim);
829            ctx.fill_text(&self.placeholder, text_x, baseline_y);
830        } else {
831            ctx.set_fill_color(v.text_color);
832            ctx.fill_text(&text, text_x, baseline_y);
833        }
834
835        // Cursor draws in `paint_overlay` — skipped here so blink
836        // state doesn't force the cache to re-raster twice per second.
837
838        ctx.reset_clip();
839
840        // ── Border ────────────────────────────────────────────────────────
841        let border_color = if self.focused { v.accent }
842            else if self.hovered { v.widget_stroke_active }
843            else { v.widget_stroke };
844        ctx.set_stroke_color(border_color);
845        ctx.set_line_width(if self.focused { 2.0 } else { 1.0 });
846        ctx.begin_path();
847        ctx.rounded_rect(0.0, 0.0, w, h, r);
848        ctx.stroke();
849    }
850
851    /// Cursor overlay — runs AFTER the cache blit on every frame, so
852    /// blink-phase flips don't invalidate the backbuffer.  Reads the
853    /// same edit state `paint()` does so cursor lands on the glyph the
854    /// cached text shows.
855    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
856        // Record the blink phase being drawn this frame.  The next tree
857        // walk's `needs_paint` will compare against this and report dirty
858        // once wall-clock time crosses the next 500 ms boundary — no
859        // host-side deadline bookkeeping, the widget drives itself.
860        if self.focused {
861            if let Some(t) = self.focus_time {
862                let phase = (t.elapsed().as_millis() / 500) as u64;
863                self.blink_last_phase.set(phase);
864            }
865        }
866
867        let cursor_visible = self.focused && {
868            let st = self.edit.borrow();
869            st.cursor == st.anchor
870        } && match self.focus_time {
871            Some(t) => (t.elapsed().as_millis() / 500) % 2 == 0,
872            None    => false,
873        };
874        if !cursor_visible { return; }
875
876        let (text, cursor) = {
877            let st = self.edit.borrow();
878            let text = if self.password_mode {
879                const BULLET: char = '•';
880                let n = st.text.chars().count();
881                BULLET.to_string().repeat(n)
882            } else {
883                st.text.clone()
884            };
885            let cursor = if self.password_mode {
886                const BULLET_LEN: usize = 3;
887                st.text[..st.cursor].chars().count() * BULLET_LEN
888            } else {
889                st.cursor
890            };
891            (text, cursor)
892        };
893
894        let h   = self.bounds.height;
895        let pad = self.padding;
896        let v   = ctx.visuals();
897
898        let font = self.active_font();
899        ctx.set_font(Arc::clone(&font));
900        ctx.set_font_size(self.font_size);
901        let m = ctx.measure_text("Ag").unwrap_or_default();
902        let baseline_y = h * 0.5 - (m.ascent - m.descent) * 0.5;
903        let text_x     = pad - self.scroll_x;
904        let cx  = text_x + measure_advance(&font, &text[..cursor], self.font_size);
905        let top = baseline_y + m.ascent;
906        let bot = baseline_y - m.descent;
907
908        // Clip to the text area so the cursor can't spill past the
909        // padding or the border.
910        ctx.save();
911        ctx.clip_rect(pad, 0.0, (self.bounds.width - pad * 2.0).max(0.0), h);
912        ctx.set_stroke_color(v.accent);
913        ctx.set_line_width(1.5);
914        ctx.begin_path();
915        ctx.move_to(cx, bot);
916        ctx.line_to(cx, top);
917        ctx.stroke();
918        ctx.restore();
919    }
920
921    fn on_event(&mut self, event: &Event) -> EventResult {
922        match event {
923            Event::MouseMove { pos } => {
924                let was = self.hovered;
925                self.hovered = self.hit_test(*pos);
926                if self.mouse_down && self.focused {
927                    let tx = pos.x - self.padding + self.scroll_x;
928                    let text = self.edit.borrow().text.clone();
929                    let new_cur = self.click_to_cursor(&text, tx);
930                    self.edit.borrow_mut().cursor = new_cur;
931                    crate::animation::request_tick();
932                }
933                if was != self.hovered { crate::animation::request_tick(); }
934                EventResult::Ignored
935            }
936
937            Event::MouseDown { pos, button: MouseButton::Left, modifiers: mods } => {
938                self.mouse_down = true;
939                let tx = pos.x - self.padding + self.scroll_x;
940                let text = self.edit.borrow().text.clone();
941                let new_cur = self.click_to_cursor(&text, tx);
942
943                // Double-click: select word
944                let is_double = self.last_click_time
945                    .map(|t| t.elapsed().as_millis() < 350)
946                    .unwrap_or(false);
947                self.last_click_time = Some(Instant::now());
948
949                if is_double && !mods.shift {
950                    let (ws, we) = word_range_at(&text, new_cur);
951                    self.edit.borrow_mut().anchor = ws;
952                    self.edit.borrow_mut().cursor = we;
953                } else if mods.shift {
954                    self.edit.borrow_mut().cursor = new_cur;
955                } else {
956                    self.edit.borrow_mut().cursor = new_cur;
957                    self.edit.borrow_mut().anchor = new_cur;
958                }
959                // Reset blink phase on click so cursor is immediately visible.
960                self.focus_time = Some(Instant::now());
961                crate::animation::request_tick();
962                EventResult::Consumed
963            }
964
965            Event::MouseUp { button: MouseButton::Left, .. } => {
966                self.mouse_down = false;
967                EventResult::Ignored
968            }
969
970            Event::FocusGained => {
971                self.focused        = true;
972                self.focus_time     = Some(Instant::now());
973                self.text_on_focus  = self.text();
974                if self.select_all_on_focus {
975                    let len = self.edit.borrow().text.len();
976                    self.edit.borrow_mut().anchor = 0;
977                    self.edit.borrow_mut().cursor = len;
978                }
979                crate::animation::request_tick();
980                EventResult::Ignored
981            }
982
983            Event::FocusLost => {
984                let was_focused = self.focused;
985                self.focused    = false;
986                self.focus_time = None;
987                self.mouse_down = false;
988                self.flush_pending();
989                if self.text() != self.text_on_focus { self.notify_edit_complete(); }
990                if was_focused { crate::animation::request_tick(); }
991                EventResult::Ignored
992            }
993
994            Event::KeyDown { key, modifiers } if self.focused => {
995                // Reset blink on any keypress so cursor is visible immediately.
996                self.focus_time = Some(Instant::now());
997                let result = self.handle_key(key, *modifiers);
998                // Any text-editing keystroke that reached the focused field
999                // visibly mutates the text / cursor / selection; repaint.
1000                if result == EventResult::Consumed {
1001                    crate::animation::request_tick();
1002                }
1003                result
1004            }
1005
1006            _ => EventResult::Ignored,
1007        }
1008    }
1009}