Skip to main content

agg_gui/widgets/text_field/
widget_impl.rs

1use super::*;
2
3impl Widget for TextField {
4    fn type_name(&self) -> &'static str {
5        "TextField"
6    }
7    fn bounds(&self) -> Rect {
8        self.bounds
9    }
10    fn set_bounds(&mut self, b: Rect) {
11        self.bounds = b;
12    }
13    fn children(&self) -> &[Box<dyn Widget>] {
14        &self.children
15    }
16    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
17        &mut self.children
18    }
19    fn is_focusable(&self) -> bool {
20        true
21    }
22
23    fn focus_id(&self) -> Option<crate::focus::FocusId> {
24        self.focus_request_id
25    }
26
27    fn accepts_text_input(&self) -> bool {
28        !self.read_only
29    }
30
31    fn text_input_value(&self) -> Option<String> {
32        Some(self.text())
33    }
34
35    fn text_input_mode(&self) -> crate::widgets::on_screen_keyboard::KeyboardInputMode {
36        self.keyboard_mode()
37    }
38
39    /// Composite parents (e.g. `ColorWheelPicker`) use this to push a live
40    /// value into the field without bypassing `set_text`'s cell sync and
41    /// cache invalidation.  Skipped while the field is focused so the user
42    /// isn't fighting the parent for cursor position mid-edit; the parent
43    /// is expected to read back via [`TextField::text`] after focus is
44    /// released to pick up any user-typed value.
45    fn set_label_text(&mut self, text: &str) {
46        if self.focused {
47            return;
48        }
49        if self.edit.borrow().text == text {
50            return;
51        }
52        self.set_text(text);
53    }
54
55    /// While focused, the cursor blinks at 500 ms half-period.  The field
56    /// itself drives its own repaint cadence: [`needs_draw`] reports dirty
57    /// whenever wall-clock time has crossed a flip boundary since the last
58    /// paint, and [`next_draw_deadline`] returns the exact wall-clock
59    /// instant of the next boundary so the host can `WaitUntil` it.
60    ///
61    /// Losing focus makes both return `None` / `false`, and the tree walk's
62    /// visibility check drops the field entirely when its enclosing window
63    /// is closed / collapsed / tab not selected — so an invisible focused
64    /// field does NOT keep the loop awake.
65    fn needs_draw(&self) -> bool {
66        if !self.focused {
67            return false;
68        }
69        let Some(t) = self.focus_time else {
70            return false;
71        };
72        let current_phase = (t.elapsed().as_millis() / 500) as u64;
73        current_phase != self.blink_last_phase.get()
74    }
75
76    fn next_draw_deadline(&self) -> Option<web_time::Instant> {
77        if !self.focused {
78            return None;
79        }
80        let t = self.focus_time?;
81        let ms = t.elapsed().as_millis() as u64;
82        let next_phase = (ms / 500) + 1;
83        Some(t + std::time::Duration::from_millis(next_phase * 500))
84    }
85
86    /// While the right-click menu is open it must capture every event (clicks
87    /// outside dismiss it, Escape closes it) even though the field is small.
88    fn has_active_modal(&self) -> bool {
89        self.context_menu.is_open()
90    }
91
92    /// The context menu paints at app level so it can overflow the field bounds
93    /// and clamp to the viewport.
94    fn paint_global_overlay(&mut self, ctx: &mut dyn DrawCtx) {
95        let font = self.active_font();
96        self.context_menu.paint(ctx, font, self.font_size);
97    }
98
99    fn margin(&self) -> Insets {
100        self.base.margin
101    }
102    fn widget_base(&self) -> Option<&WidgetBase> {
103        Some(&self.base)
104    }
105    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
106        Some(&mut self.base)
107    }
108    fn h_anchor(&self) -> HAnchor {
109        self.base.h_anchor
110    }
111    fn v_anchor(&self) -> VAnchor {
112        self.base.v_anchor
113    }
114    fn min_size(&self) -> Size {
115        self.base.min_size
116    }
117    fn max_size(&self) -> Size {
118        self.base.max_size
119    }
120
121    fn backbuffer_cache_mut(&mut self) -> Option<&mut BackbufferCache> {
122        Some(&mut self.cache)
123    }
124
125    fn backbuffer_mode(&self) -> BackbufferMode {
126        if crate::font_settings::lcd_enabled() {
127            BackbufferMode::LcdCoverage
128        } else {
129            BackbufferMode::Rgba
130        }
131    }
132
133    fn layout(&mut self, available: Size) -> Size {
134        self.sync_from_text_cell();
135        // Sig excludes cursor-blink phase.  Cursor paints in
136        // `paint_overlay` after cache blit — no blink-driven
137        // invalidation.
138        let st = self.edit.borrow();
139        let font = self.active_font();
140        let sig = TextFieldSig {
141            text: st.text.clone(),
142            cursor: st.cursor,
143            anchor: st.anchor,
144            focused: self.focused,
145            hovered: self.hovered,
146            scroll_x_bits: self.scroll_x.to_bits(),
147            w_bits: self.bounds.width.to_bits(),
148            h_bits: self.bounds.height.to_bits(),
149            font_ptr: Arc::as_ptr(&font) as usize,
150            font_size_bits: self.font_size.to_bits(),
151            masking: self.masking_active(),
152        };
153        drop(st);
154        if self.last_sig.as_ref() != Some(&sig) {
155            self.last_sig = Some(sig);
156            self.cache.invalidate();
157        }
158        Size::new(available.width, (self.font_size * 2.4).max(28.0))
159    }
160
161    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
162        let w = self.bounds.width;
163        let h = self.bounds.height;
164        let r = self.theme.border_radius.unwrap_or(6.0);
165        let pad = self.padding;
166        let (raw_text, raw_cursor, raw_anchor) = {
167            let st = self.edit.borrow();
168            (st.text.clone(), st.cursor, st.anchor)
169        };
170        // In password mode render '•' for every character, but keep byte positions
171        // consistent by recomputing them against the masked string.
172        let (text, cursor, anchor) = if self.masking_active() {
173            const BULLET: char = '•';
174            const BULLET_LEN: usize = 3; // '•' is 3 bytes in UTF-8
175            let n = raw_text.chars().count();
176            let masked = BULLET.to_string().repeat(n);
177            let cur = raw_text[..raw_cursor].chars().count() * BULLET_LEN;
178            let anc = raw_text[..raw_anchor].chars().count() * BULLET_LEN;
179            (masked, cur, anc)
180        } else {
181            (raw_text, raw_cursor, raw_anchor)
182        };
183
184        let v = ctx.visuals();
185        let t = &self.theme;
186
187        // ── Background ────────────────────────────────────────────────────
188        ctx.set_fill_color(t.background.unwrap_or(v.widget_bg));
189        ctx.begin_path();
190        ctx.rounded_rect(0.0, 0.0, w, h, r);
191        ctx.fill();
192
193        // ── Text area clip ────────────────────────────────────────────────
194        ctx.clip_rect(pad, 0.0, (w - pad * 2.0).max(0.0), h);
195
196        let font = self.active_font();
197        ctx.set_font(Arc::clone(&font));
198        ctx.set_font_size(self.font_size);
199
200        let m = ctx.measure_text("Ag").unwrap_or_default();
201        let baseline_y = h * 0.5 - (m.ascent - m.descent) * 0.5;
202        let text_x = pad - self.scroll_x;
203
204        // ── Selection highlight ───────────────────────────────────────────
205        if cursor != anchor {
206            let lo = cursor.min(anchor);
207            let hi = cursor.max(anchor);
208            let lo_x = measure_advance(&font, &text[..lo], self.font_size);
209            let hi_x = measure_advance(&font, &text[..hi], self.font_size);
210            let sx = (text_x + lo_x).max(pad);
211            let sw = (text_x + hi_x).min(w - pad) - sx;
212            if sw > 0.0 {
213                let hl_bot = baseline_y - m.descent;
214                let hl_h = (m.ascent + m.descent) * 1.2;
215                ctx.set_fill_color(if self.focused {
216                    t.selection_bg.unwrap_or(v.selection_bg)
217                } else {
218                    t.selection_bg_unfocused.unwrap_or(v.selection_bg_unfocused)
219                });
220                ctx.begin_path();
221                ctx.rect(sx, hl_bot - hl_h * 0.1, sw, hl_h);
222                ctx.fill();
223            }
224        }
225
226        // ── Text or placeholder ───────────────────────────────────────────
227        if text.is_empty() && !self.focused {
228            ctx.set_fill_color(t.placeholder_color.unwrap_or(v.text_dim));
229            ctx.fill_text(&self.placeholder, text_x, baseline_y);
230        } else {
231            ctx.set_fill_color(t.text_color.unwrap_or(v.text_color));
232            ctx.fill_text(&text, text_x, baseline_y);
233        }
234
235        // Cursor draws in `paint_overlay` — skipped here so blink
236        // state doesn't force the cache to re-raster twice per second.
237
238        ctx.reset_clip();
239
240        // ── Border ────────────────────────────────────────────────────────
241        let border_color = if self.focused {
242            t.border_color_focused.unwrap_or(v.accent)
243        } else if self.hovered {
244            t.border_color_hovered.unwrap_or(v.widget_stroke_active)
245        } else {
246            t.border_color.unwrap_or(v.widget_stroke)
247        };
248        ctx.set_stroke_color(border_color);
249        let border_w = if self.focused { 2.0 } else { 1.0 };
250        ctx.set_line_width(border_w);
251        ctx.begin_path();
252        let inset = border_w * 0.5;
253        ctx.rounded_rect(
254            inset,
255            inset,
256            (w - border_w).max(0.0),
257            (h - border_w).max(0.0),
258            r,
259        );
260        ctx.stroke();
261    }
262
263    /// Cursor overlay — runs AFTER the cache blit on every frame, so
264    /// blink-phase flips don't invalidate the backbuffer.  Reads the
265    /// same edit state `paint()` does so cursor lands on the glyph the
266    /// cached text shows.
267    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
268        // Record the blink phase being drawn this frame.  The next tree
269        // walk's `needs_draw` will compare against this and report dirty
270        // once wall-clock time crosses the next 500 ms boundary — no
271        // host-side deadline bookkeeping, the widget drives itself.
272        if self.focused {
273            if let Some(t) = self.focus_time {
274                let phase = (t.elapsed().as_millis() / 500) as u64;
275                self.blink_last_phase.set(phase);
276            }
277        }
278
279        let cursor_visible = self.focused
280            && {
281                let st = self.edit.borrow();
282                st.cursor == st.anchor
283            }
284            && match self.focus_time {
285                Some(t) => (t.elapsed().as_millis() / 500) % 2 == 0,
286                None => false,
287            };
288        if !cursor_visible {
289            return;
290        }
291
292        let h = self.bounds.height;
293        let pad = self.padding;
294        let v = ctx.visuals();
295
296        let font = self.active_font();
297        ctx.set_font(Arc::clone(&font));
298        ctx.set_font_size(self.font_size);
299        let m = ctx.measure_text("Ag").unwrap_or_default();
300        let baseline_y = h * 0.5 - (m.ascent - m.descent) * 0.5;
301        let cx = self.caret_x();
302        let top = baseline_y + m.ascent;
303        let bot = baseline_y - m.descent;
304
305        // Clip to the text area so the cursor can't spill past the
306        // padding or the border.
307        ctx.save();
308        ctx.clip_rect(pad, 0.0, (self.bounds.width - pad * 2.0).max(0.0), h);
309        ctx.set_stroke_color(self.theme.cursor_color.unwrap_or(v.accent));
310        ctx.set_line_width(1.5);
311        ctx.begin_path();
312        ctx.move_to(cx, bot);
313        ctx.line_to(cx, top);
314        ctx.stroke();
315        ctx.restore();
316    }
317
318    fn on_event(&mut self, event: &Event) -> EventResult {
319        // While the right-click menu is open it captures events (see
320        // `has_active_modal`); route them through it first.
321        if let Some(result) = self.route_context_menu(event) {
322            return result;
323        }
324        match event {
325            Event::MouseMove { pos } => {
326                let was = self.hovered;
327                self.hovered = self.hit_test(*pos);
328                if self.mouse_down && self.focused {
329                    let tx = pos.x - self.padding + self.scroll_x;
330                    let text = self.edit.borrow().text.clone();
331                    let new_cur = self.click_to_cursor(&text, tx);
332                    self.extend_selection_drag(&text, new_cur);
333                    crate::animation::request_draw();
334                    return EventResult::Consumed;
335                }
336                if was != self.hovered {
337                    crate::animation::request_draw();
338                    return EventResult::Consumed;
339                }
340                EventResult::Ignored
341            }
342
343            Event::MouseDown {
344                pos,
345                button: MouseButton::Left,
346                modifiers: mods,
347            } => {
348                self.mouse_down = true;
349                let tx = pos.x - self.padding + self.scroll_x;
350                let text = self.edit.borrow().text.clone();
351                let new_cur = self.click_to_cursor(&text, tx);
352
353                // Distinguish single / double / triple click. A double-click
354                // selects the word, a triple-click selects the whole field
355                // (a TextField is a single logical line). Shift+click always
356                // extends the existing selection to the click point.
357                let clicks = self.multi_click.register(*pos);
358                use crate::widgets::multi_click::SelectGranularity;
359
360                if mods.shift {
361                    self.select_granularity = SelectGranularity::Char;
362                    self.select_pivot = (new_cur, new_cur);
363                    self.edit.borrow_mut().cursor = new_cur;
364                } else if clicks >= 3 {
365                    self.select_granularity = SelectGranularity::Line;
366                    let len = text.len();
367                    self.select_pivot = (0, len);
368                    self.edit.borrow_mut().anchor = 0;
369                    self.edit.borrow_mut().cursor = len;
370                } else if clicks == 2 {
371                    self.select_granularity = SelectGranularity::Word;
372                    let (ws, we) = word_range_at(&text, new_cur);
373                    self.select_pivot = (ws, we);
374                    self.edit.borrow_mut().anchor = ws;
375                    self.edit.borrow_mut().cursor = we;
376                } else {
377                    self.select_granularity = SelectGranularity::Char;
378                    self.select_pivot = (new_cur, new_cur);
379                    self.edit.borrow_mut().cursor = new_cur;
380                    self.edit.borrow_mut().anchor = new_cur;
381                }
382                // Reset blink phase on click so cursor is immediately visible.
383                self.focus_time = Some(Instant::now());
384                crate::animation::request_draw();
385                EventResult::Consumed
386            }
387
388            Event::MouseDown {
389                pos,
390                button: MouseButton::Right,
391                ..
392            } => {
393                if self.context_menu_enabled {
394                    self.open_context_menu(*pos);
395                    EventResult::Consumed
396                } else {
397                    EventResult::Ignored
398                }
399            }
400
401            Event::MouseUp {
402                button: MouseButton::Left,
403                ..
404            } => {
405                self.mouse_down = false;
406                EventResult::Ignored
407            }
408
409            Event::FocusGained => {
410                self.focused = true;
411                self.focus_time = Some(Instant::now());
412                self.text_on_focus = self.text();
413                if self.select_all_on_focus {
414                    let len = self.edit.borrow().text.len();
415                    self.edit.borrow_mut().anchor = 0;
416                    self.edit.borrow_mut().cursor = len;
417                }
418                crate::animation::request_draw();
419                EventResult::Ignored
420            }
421
422            Event::FocusLost => {
423                let was_focused = self.focused;
424                self.focused = false;
425                self.focus_time = None;
426                self.mouse_down = false;
427                self.flush_pending();
428                if self.text() != self.text_on_focus {
429                    self.notify_edit_complete();
430                }
431                if was_focused {
432                    crate::animation::request_draw();
433                }
434                EventResult::Ignored
435            }
436
437            Event::KeyDown { key, modifiers } if self.focused => {
438                // Reset blink on any keypress so cursor is visible immediately.
439                self.focus_time = Some(Instant::now());
440                let result = self.handle_key(key, *modifiers);
441                // Any text-editing keystroke that reached the focused field
442                // visibly mutates the text / cursor / selection; repaint.
443                if result.is_consumed() {
444                    crate::animation::request_draw();
445                }
446                result
447            }
448
449            _ => EventResult::Ignored,
450        }
451    }
452}
453
454impl TextField {
455    /// X pixel position (widget-local) of the caret's vertical line, folding in
456    /// the horizontal scroll offset and password masking. Because this measures
457    /// the actual text prefix `text[..cursor]` (never a trimmed one), trailing
458    /// spaces advance the caret glyph-by-glyph like every other character —
459    /// matching egui. Shared by the overlay paint and the caret regression test.
460    pub(crate) fn caret_x(&self) -> f64 {
461        let st = self.edit.borrow();
462        let font = self.active_font();
463        let advance = if self.masking_active() {
464            const BULLET: char = '•';
465            let n = st.text[..st.cursor].chars().count();
466            let masked = BULLET.to_string().repeat(n);
467            measure_advance(&font, &masked, self.font_size)
468        } else {
469            measure_advance(&font, &st.text[..st.cursor], self.font_size)
470        };
471        self.padding - self.scroll_x + advance
472    }
473}