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    /// While focused, the cursor blinks at 500 ms half-period.  The field
24    /// itself drives its own repaint cadence: [`needs_draw`] reports dirty
25    /// whenever wall-clock time has crossed a flip boundary since the last
26    /// paint, and [`next_draw_deadline`] returns the exact wall-clock
27    /// instant of the next boundary so the host can `WaitUntil` it.
28    ///
29    /// Losing focus makes both return `None` / `false`, and the tree walk's
30    /// visibility check drops the field entirely when its enclosing window
31    /// is closed / collapsed / tab not selected — so an invisible focused
32    /// field does NOT keep the loop awake.
33    fn needs_draw(&self) -> bool {
34        if !self.focused {
35            return false;
36        }
37        let Some(t) = self.focus_time else {
38            return false;
39        };
40        let current_phase = (t.elapsed().as_millis() / 500) as u64;
41        current_phase != self.blink_last_phase.get()
42    }
43
44    fn next_draw_deadline(&self) -> Option<web_time::Instant> {
45        if !self.focused {
46            return None;
47        }
48        let t = self.focus_time?;
49        let ms = t.elapsed().as_millis() as u64;
50        let next_phase = (ms / 500) + 1;
51        Some(t + std::time::Duration::from_millis(next_phase * 500))
52    }
53
54    fn margin(&self) -> Insets {
55        self.base.margin
56    }
57    fn widget_base(&self) -> Option<&WidgetBase> {
58        Some(&self.base)
59    }
60    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
61        Some(&mut self.base)
62    }
63    fn h_anchor(&self) -> HAnchor {
64        self.base.h_anchor
65    }
66    fn v_anchor(&self) -> VAnchor {
67        self.base.v_anchor
68    }
69    fn min_size(&self) -> Size {
70        self.base.min_size
71    }
72    fn max_size(&self) -> Size {
73        self.base.max_size
74    }
75
76    fn backbuffer_cache_mut(&mut self) -> Option<&mut BackbufferCache> {
77        Some(&mut self.cache)
78    }
79
80    fn backbuffer_mode(&self) -> BackbufferMode {
81        if crate::font_settings::lcd_enabled() {
82            BackbufferMode::LcdCoverage
83        } else {
84            BackbufferMode::Rgba
85        }
86    }
87
88    fn layout(&mut self, available: Size) -> Size {
89        self.sync_from_text_cell();
90        // Sig excludes cursor-blink phase.  Cursor paints in
91        // `paint_overlay` after cache blit — no blink-driven
92        // invalidation.
93        let st = self.edit.borrow();
94        let font = self.active_font();
95        let sig = TextFieldSig {
96            text: st.text.clone(),
97            cursor: st.cursor,
98            anchor: st.anchor,
99            focused: self.focused,
100            hovered: self.hovered,
101            scroll_x_bits: self.scroll_x.to_bits(),
102            w_bits: self.bounds.width.to_bits(),
103            h_bits: self.bounds.height.to_bits(),
104            font_ptr: Arc::as_ptr(&font) as usize,
105            font_size_bits: self.font_size.to_bits(),
106        };
107        drop(st);
108        if self.last_sig.as_ref() != Some(&sig) {
109            self.last_sig = Some(sig);
110            self.cache.invalidate();
111        }
112        Size::new(available.width, (self.font_size * 2.4).max(28.0))
113    }
114
115    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
116        let w = self.bounds.width;
117        let h = self.bounds.height;
118        let r = 6.0;
119        let pad = self.padding;
120        let (raw_text, raw_cursor, raw_anchor) = {
121            let st = self.edit.borrow();
122            (st.text.clone(), st.cursor, st.anchor)
123        };
124        // In password mode render '•' for every character, but keep byte positions
125        // consistent by recomputing them against the masked string.
126        let (text, cursor, anchor) = if self.password_mode {
127            const BULLET: char = '•';
128            const BULLET_LEN: usize = 3; // '•' is 3 bytes in UTF-8
129            let n = raw_text.chars().count();
130            let masked = BULLET.to_string().repeat(n);
131            let cur = raw_text[..raw_cursor].chars().count() * BULLET_LEN;
132            let anc = raw_text[..raw_anchor].chars().count() * BULLET_LEN;
133            (masked, cur, anc)
134        } else {
135            (raw_text, raw_cursor, raw_anchor)
136        };
137
138        let v = ctx.visuals();
139
140        // ── Background ────────────────────────────────────────────────────
141        ctx.set_fill_color(v.widget_bg);
142        ctx.begin_path();
143        ctx.rounded_rect(0.0, 0.0, w, h, r);
144        ctx.fill();
145
146        // ── Text area clip ────────────────────────────────────────────────
147        ctx.clip_rect(pad, 0.0, (w - pad * 2.0).max(0.0), h);
148
149        let font = self.active_font();
150        ctx.set_font(Arc::clone(&font));
151        ctx.set_font_size(self.font_size);
152
153        let m = ctx.measure_text("Ag").unwrap_or_default();
154        let baseline_y = h * 0.5 - (m.ascent - m.descent) * 0.5;
155        let text_x = pad - self.scroll_x;
156
157        // ── Selection highlight ───────────────────────────────────────────
158        if cursor != anchor {
159            let lo = cursor.min(anchor);
160            let hi = cursor.max(anchor);
161            let lo_x = measure_advance(&font, &text[..lo], self.font_size);
162            let hi_x = measure_advance(&font, &text[..hi], self.font_size);
163            let sx = (text_x + lo_x).max(pad);
164            let sw = (text_x + hi_x).min(w - pad) - sx;
165            if sw > 0.0 {
166                let hl_bot = baseline_y - m.descent;
167                let hl_h = (m.ascent + m.descent) * 1.2;
168                ctx.set_fill_color(if self.focused {
169                    v.selection_bg
170                } else {
171                    v.selection_bg_unfocused
172                });
173                ctx.begin_path();
174                ctx.rect(sx, hl_bot - hl_h * 0.1, sw, hl_h);
175                ctx.fill();
176            }
177        }
178
179        // ── Text or placeholder ───────────────────────────────────────────
180        if text.is_empty() && !self.focused {
181            ctx.set_fill_color(v.text_dim);
182            ctx.fill_text(&self.placeholder, text_x, baseline_y);
183        } else {
184            ctx.set_fill_color(v.text_color);
185            ctx.fill_text(&text, text_x, baseline_y);
186        }
187
188        // Cursor draws in `paint_overlay` — skipped here so blink
189        // state doesn't force the cache to re-raster twice per second.
190
191        ctx.reset_clip();
192
193        // ── Border ────────────────────────────────────────────────────────
194        let border_color = if self.focused {
195            v.accent
196        } else if self.hovered {
197            v.widget_stroke_active
198        } else {
199            v.widget_stroke
200        };
201        ctx.set_stroke_color(border_color);
202        ctx.set_line_width(if self.focused { 2.0 } else { 1.0 });
203        ctx.begin_path();
204        ctx.rounded_rect(0.0, 0.0, w, h, r);
205        ctx.stroke();
206    }
207
208    /// Cursor overlay — runs AFTER the cache blit on every frame, so
209    /// blink-phase flips don't invalidate the backbuffer.  Reads the
210    /// same edit state `paint()` does so cursor lands on the glyph the
211    /// cached text shows.
212    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
213        // Record the blink phase being drawn this frame.  The next tree
214        // walk's `needs_draw` will compare against this and report dirty
215        // once wall-clock time crosses the next 500 ms boundary — no
216        // host-side deadline bookkeeping, the widget drives itself.
217        if self.focused {
218            if let Some(t) = self.focus_time {
219                let phase = (t.elapsed().as_millis() / 500) as u64;
220                self.blink_last_phase.set(phase);
221            }
222        }
223
224        let cursor_visible = self.focused
225            && {
226                let st = self.edit.borrow();
227                st.cursor == st.anchor
228            }
229            && match self.focus_time {
230                Some(t) => (t.elapsed().as_millis() / 500) % 2 == 0,
231                None => false,
232            };
233        if !cursor_visible {
234            return;
235        }
236
237        let (text, cursor) = {
238            let st = self.edit.borrow();
239            let text = if self.password_mode {
240                const BULLET: char = '•';
241                let n = st.text.chars().count();
242                BULLET.to_string().repeat(n)
243            } else {
244                st.text.clone()
245            };
246            let cursor = if self.password_mode {
247                const BULLET_LEN: usize = 3;
248                st.text[..st.cursor].chars().count() * BULLET_LEN
249            } else {
250                st.cursor
251            };
252            (text, cursor)
253        };
254
255        let h = self.bounds.height;
256        let pad = self.padding;
257        let v = ctx.visuals();
258
259        let font = self.active_font();
260        ctx.set_font(Arc::clone(&font));
261        ctx.set_font_size(self.font_size);
262        let m = ctx.measure_text("Ag").unwrap_or_default();
263        let baseline_y = h * 0.5 - (m.ascent - m.descent) * 0.5;
264        let text_x = pad - self.scroll_x;
265        let cx = text_x + measure_advance(&font, &text[..cursor], self.font_size);
266        let top = baseline_y + m.ascent;
267        let bot = baseline_y - m.descent;
268
269        // Clip to the text area so the cursor can't spill past the
270        // padding or the border.
271        ctx.save();
272        ctx.clip_rect(pad, 0.0, (self.bounds.width - pad * 2.0).max(0.0), h);
273        ctx.set_stroke_color(v.accent);
274        ctx.set_line_width(1.5);
275        ctx.begin_path();
276        ctx.move_to(cx, bot);
277        ctx.line_to(cx, top);
278        ctx.stroke();
279        ctx.restore();
280    }
281
282    fn on_event(&mut self, event: &Event) -> EventResult {
283        match event {
284            Event::MouseMove { pos } => {
285                let was = self.hovered;
286                self.hovered = self.hit_test(*pos);
287                if self.mouse_down && self.focused {
288                    let tx = pos.x - self.padding + self.scroll_x;
289                    let text = self.edit.borrow().text.clone();
290                    let new_cur = self.click_to_cursor(&text, tx);
291                    self.edit.borrow_mut().cursor = new_cur;
292                    crate::animation::request_draw();
293                    return EventResult::Consumed;
294                }
295                if was != self.hovered {
296                    crate::animation::request_draw();
297                    return EventResult::Consumed;
298                }
299                EventResult::Ignored
300            }
301
302            Event::MouseDown {
303                pos,
304                button: MouseButton::Left,
305                modifiers: mods,
306            } => {
307                self.mouse_down = true;
308                let tx = pos.x - self.padding + self.scroll_x;
309                let text = self.edit.borrow().text.clone();
310                let new_cur = self.click_to_cursor(&text, tx);
311
312                // Double-click: select word
313                let is_double = self
314                    .last_click_time
315                    .map(|t| t.elapsed().as_millis() < 350)
316                    .unwrap_or(false);
317                self.last_click_time = Some(Instant::now());
318
319                if is_double && !mods.shift {
320                    let (ws, we) = word_range_at(&text, new_cur);
321                    self.edit.borrow_mut().anchor = ws;
322                    self.edit.borrow_mut().cursor = we;
323                } else if mods.shift {
324                    self.edit.borrow_mut().cursor = new_cur;
325                } else {
326                    self.edit.borrow_mut().cursor = new_cur;
327                    self.edit.borrow_mut().anchor = new_cur;
328                }
329                // Reset blink phase on click so cursor is immediately visible.
330                self.focus_time = Some(Instant::now());
331                crate::animation::request_draw();
332                EventResult::Consumed
333            }
334
335            Event::MouseUp {
336                button: MouseButton::Left,
337                ..
338            } => {
339                self.mouse_down = false;
340                EventResult::Ignored
341            }
342
343            Event::FocusGained => {
344                self.focused = true;
345                self.focus_time = Some(Instant::now());
346                self.text_on_focus = self.text();
347                if self.select_all_on_focus {
348                    let len = self.edit.borrow().text.len();
349                    self.edit.borrow_mut().anchor = 0;
350                    self.edit.borrow_mut().cursor = len;
351                }
352                crate::animation::request_draw();
353                EventResult::Ignored
354            }
355
356            Event::FocusLost => {
357                let was_focused = self.focused;
358                self.focused = false;
359                self.focus_time = None;
360                self.mouse_down = false;
361                self.flush_pending();
362                if self.text() != self.text_on_focus {
363                    self.notify_edit_complete();
364                }
365                if was_focused {
366                    crate::animation::request_draw();
367                }
368                EventResult::Ignored
369            }
370
371            Event::KeyDown { key, modifiers } if self.focused => {
372                // Reset blink on any keypress so cursor is visible immediately.
373                self.focus_time = Some(Instant::now());
374                let result = self.handle_key(key, *modifiers);
375                // Any text-editing keystroke that reached the focused field
376                // visibly mutates the text / cursor / selection; repaint.
377                if result == EventResult::Consumed {
378                    crate::animation::request_draw();
379                }
380                result
381            }
382
383            _ => EventResult::Ignored,
384        }
385    }
386}