Skip to main content

agg_gui/widgets/text_area/
widget_impl.rs

1use super::*;
2
3/// Clamp the caret's vertical span `[p_y, p_y + line_h]` (Y-up) to the padded
4/// inner band `[inner_lo, inner_hi]`, returning the visible sub-segment. Yields
5/// `None` when the caret's line has scrolled entirely outside the inner rect,
6/// so the un-clipped `paint_overlay` never strokes the caret over the
7/// border/padding.
8pub(crate) fn caret_visible_segment(
9    p_y: f64,
10    line_h: f64,
11    inner_lo: f64,
12    inner_hi: f64,
13) -> Option<(f64, f64)> {
14    let y0 = p_y.max(inner_lo);
15    let y1 = (p_y + line_h).min(inner_hi);
16    if y1 > y0 {
17        Some((y0, y1))
18    } else {
19        None
20    }
21}
22
23impl Widget for TextArea {
24    fn type_name(&self) -> &'static str {
25        "TextArea"
26    }
27    fn bounds(&self) -> Rect {
28        self.bounds
29    }
30    fn set_bounds(&mut self, b: Rect) {
31        self.bounds = b;
32    }
33    fn children(&self) -> &[Box<dyn Widget>] {
34        &self.children
35    }
36    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
37        &mut self.children
38    }
39
40    fn is_focusable(&self) -> bool {
41        true
42    }
43
44    fn focus_id(&self) -> Option<crate::focus::FocusId> {
45        self.focus_request_id
46    }
47
48    fn accepts_text_input(&self) -> bool {
49        true
50    }
51
52    fn text_input_value(&self) -> Option<String> {
53        Some(self.text())
54    }
55
56    fn margin(&self) -> Insets {
57        self.base.margin
58    }
59    fn widget_base(&self) -> Option<&WidgetBase> {
60        Some(&self.base)
61    }
62    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
63        Some(&mut self.base)
64    }
65    fn h_anchor(&self) -> HAnchor {
66        self.base.h_anchor
67    }
68    fn v_anchor(&self) -> VAnchor {
69        self.base.v_anchor
70    }
71    fn min_size(&self) -> Size {
72        self.base.min_size
73    }
74    fn max_size(&self) -> Size {
75        self.base.max_size
76    }
77
78    fn backbuffer_cache_mut(&mut self) -> Option<&mut BackbufferCache> {
79        Some(&mut self.cache)
80    }
81
82    fn backbuffer_band(&self) -> Option<crate::widget::BackbufferBand> {
83        self.backbuffer_band_impl()
84    }
85
86    fn backbuffer_mode(&self) -> BackbufferMode {
87        // Same decision as `Label` / `TextField`: LCD-subpixel coverage buffer
88        // when the global toggle is on (default at scale ≤ 1.25), grayscale
89        // RGBA otherwise. `backbuffer_mode`'s mode-flip detection in
90        // `paint_subtree_backbuffered` re-rasters when the toggle changes.
91        if crate::font_settings::lcd_enabled() {
92            BackbufferMode::LcdCoverage
93        } else {
94            BackbufferMode::Rgba
95        }
96    }
97
98    fn measure_min_height(&self, available_w: f64) -> f64 {
99        // Wrap our text at the supplied width and report the total
100        // visual height + vertical padding.  This is what an
101        // ancestor `Window::tight_content_fit` sums to derive a
102        // window minimum that prevents text from going off-screen,
103        // even when this widget sits in a flex-fill slot whose
104        // `layout` would otherwise just return the available area.
105        //
106        // Cheap: `wrap_text_indexed` is the same function `layout`
107        // already calls; it doesn't mutate any cache.  Always at
108        // least one line so the cursor has somewhere to sit.
109        let inner_w = (available_w - self.padding * 2.0).max(1.0);
110        let lines = wrap_text_indexed(
111            &self.font,
112            &self.edit.borrow().text,
113            self.font_size,
114            inner_w,
115        );
116        let line_h = self.font_size * 1.35;
117        (lines.len().max(1) as f64) * line_h + self.padding * 2.0
118    }
119
120    fn layout(&mut self, available: Size) -> Size {
121        // Fill the slot we're given.  A parent that allocates us via
122        // a flex-weight gets everything; a parent that asks for our
123        // natural size gets the same (the caller is opting into
124        // "whatever you want" with `available`).
125        let w = available.width.max(self.padding * 2.0 + 20.0);
126        let h = available
127            .height
128            .max(self.padding * 2.0 + self.font_size * 1.6);
129        self.bounds = Rect::new(0.0, 0.0, w, h);
130        let inner_w = (w - self.padding * 2.0).max(1.0);
131        // Reset the wrap-change range for this pass; `refresh_wrap` records the
132        // edited line range only when it actually re-wraps. (`sync_scroll` calls
133        // `refresh_wrap` a second time below; a scroll-only frame must leave the
134        // range cleared so no stale strip is planned.)
135        self.wrap_change = None;
136        self.refresh_wrap(inner_w);
137        self.sync_scroll();
138        // Catch cursor moves made straight through the shared TextEditState
139        // (e.g. the demo's "start"/"end" buttons) that bypass the edit funnel
140        // and thus never ran ensure_cursor_visible. The first layout only
141        // records the baseline so seeding a caret at the end doesn't force an
142        // unexpected scroll before the user interacts.
143        let cursor = self.edit.borrow().cursor;
144        if matches!(self.last_layout_cursor, Some(prev) if prev != cursor) {
145            self.ensure_cursor_visible();
146        }
147        self.last_layout_cursor = Some(cursor);
148
149        // Re-plan the over-scan band now that wrap + offset are settled. This
150        // only re-anchors (and thus changes the sig → re-raster) when the offset
151        // has left the band; ordinary in-band scrolling is a no-op here.
152        self.recompute_band();
153
154        // Decide whether the next re-raster can be confined to the edited line
155        // strip. Must run before `last_sig` is overwritten below (it diffs the
156        // pending state against the previous signature).
157        self.plan_dirty_strip();
158
159        // The signature is the single source of truth for whether the cached
160        // bitmap must re-raster: it captures every paint-affecting input (text,
161        // cursor, selection, band anchor, focus/hover, size, alignment, font
162        // size). Blink phase, the scrollbar and the border paint in
163        // `paint_overlay`; an in-band scroll only shifts the blit; and
164        // theme / typography / async invalidation is tracked by separate epochs
165        // in `paint_subtree_backbuffered`.
166        //
167        // On a sig CHANGE we invalidate. On NO change we also *clear* any dirty
168        // flag the event dispatcher set: a consumed wheel/scrollbar scroll bumps
169        // the invalidation epoch (so retained ancestors like a Window FBO
170        // recomposite the moved blit), which `dispatch_event` turns into a
171        // `mark_dirty` on this widget — but for an in-band scroll that must be a
172        // pure re-blit, NOT an expensive LCD re-raster. Making the sig
173        // authoritative here is what lets the over-scan band actually pay off.
174        let sig = self.cache_sig();
175        if self.last_sig.as_ref() != Some(&sig) {
176            self.last_sig = Some(sig);
177            self.cache.invalidate();
178        } else {
179            self.cache.dirty = false;
180        }
181        Size::new(w, h)
182    }
183
184    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
185        // TEMPORARY env-gated (AGG_PAINT_TIMING) per-section timing; see the emit
186        // at the end of this fn (strip frames only). Cheap when off.
187        use crate::widget::paint_timing as pt;
188        let mut tt = pt::TextAreaPaintTiming::default();
189        let mut t = pt::start();
190
191        let v = ctx.visuals();
192        let w = self.bounds.width;
193        let h = self.bounds.height;
194
195        // The framework only calls `paint` when the backbuffer is dirty, so this
196        // counts real re-rasters. The band tests assert in-band scrolling never
197        // reaches here.
198        self.raster_count.set(self.raster_count.get() + 1);
199
200        // Band mode: raster at the band's fixed anchor (not the live offset) so
201        // the bitmap is scroll-stable, and paint over-scan content above/below.
202        let band = self.band.active;
203        if band {
204            self.render_band_offset.set(Some(self.band.anchor));
205        }
206        // Dirty-line-strip re-raster: an in-place edit repaints ONLY the changed
207        // line strip into the retained band buffer (see `plan_dirty_strip`). It
208        // engages only when the widget planned a strip AND the framework was
209        // able to reuse the retained buffer (`partial_allowed`); otherwise this
210        // is a full band repaint that covers every pixel with opaque content.
211        let strip = if band && self.cache.partial_allowed {
212            self.render_dirty_lines.get()
213        } else {
214            None
215        };
216        if strip.is_some() {
217            self.strip_raster_count
218                .set(self.strip_raster_count.get() + 1);
219        }
220
221        // Vertical extent painted into the buffer: the padded inner rect,
222        // expanded by the over-scan margins in band mode, then narrowed to the
223        // edited line strip when doing a partial re-raster.
224        let (clip_lo, clip_hi) = match strip {
225            Some((first, last)) => self.dirty_strip_y(first, last),
226            None => self.band_clip_y(),
227        };
228
229        // Background — theme widget fill. In band mode fill the WHOLE buffer
230        // (incl. the over-scan margins that sit outside the widget bounds) with
231        // a plain opaque rect so scrolling never reveals an un-painted gap; the
232        // rounded frame + border + padding-ring are re-established, fixed, in
233        // `paint_overlay`. For a strip re-raster fill only the strip rows (full
234        // width, opaque) so the changed lines' old pixels are replaced while the
235        // retained buffer keeps every other row. Otherwise bake the rounded fill
236        // into the cache.
237        tt.head_ms = pt::ms(&t);
238        t = pt::start();
239        ctx.set_fill_color(v.widget_bg);
240        ctx.begin_path();
241        if strip.is_some() {
242            ctx.rect(0.0, clip_lo, w, (clip_hi - clip_lo).max(0.0));
243        } else if band {
244            let bg_lo = -self.band.over_bottom;
245            let bg_h = h + self.band.over_top + self.band.over_bottom;
246            ctx.rect(0.0, bg_lo, w, bg_h.max(0.0));
247        } else {
248            ctx.rounded_rect(0.0, 0.0, w, h, 4.0);
249        }
250        ctx.fill();
251        tt.bg_fill_ms = pt::ms(&t);
252
253        // Clip content to the padded inner width and the (band-expanded, or
254        // strip-narrowed) inner height so overflow text can't leak sideways
255        // across the border. Text that bleeds into the top/bottom padding while
256        // scrolling is covered by the padding-ring fill in `paint_overlay`.
257        t = pt::start();
258        ctx.clip_rect(
259            self.padding,
260            clip_lo,
261            (w - self.padding * 2.0).max(0.0),
262            (clip_hi - clip_lo).max(0.0),
263        );
264
265        ctx.set_font(Arc::clone(&self.font));
266        ctx.set_font_size(self.font_size);
267        tt.clip_ms = pt::ms(&t);
268
269        // ── Selection highlight ───────────────────────────────────
270        t = pt::start();
271        let st = self.edit.borrow().clone();
272        tt.state_clone_ms = pt::ms(&t);
273        t = pt::start(); // sel_loop
274        if st.cursor != st.anchor {
275            let lo = st.cursor.min(st.anchor);
276            let hi = st.cursor.max(st.anchor);
277            let hl_color = if self.focused {
278                v.selection_bg
279            } else {
280                v.selection_bg_unfocused
281            };
282            ctx.set_fill_color(hl_color);
283            for (i, line) in self.cached_lines.iter().enumerate() {
284                if line.end < lo || line.start > hi {
285                    continue;
286                }
287                // Visible-range culling: skip lines wholly outside the (band-
288                // expanded) clip band. Clipping already guarantees correctness;
289                // this just avoids the per-line work for the thousands of lines
290                // a large document keeps off-screen.
291                let line_top = self.line_top_y(i);
292                if line_top < clip_lo || line_top - self.cached_line_h > clip_hi {
293                    continue;
294                }
295                let sel_s = lo.max(line.start) - line.start;
296                let sel_e = hi.min(line.end) - line.start;
297                let sel_e = sel_e.min(line.text.len());
298                if sel_e <= sel_s {
299                    continue;
300                }
301                let line_x = self.line_x_start(line);
302                let x0 = line_x + measure_advance(&self.font, &line.text[..sel_s], self.font_size);
303                let x1 = line_x + measure_advance(&self.font, &line.text[..sel_e], self.font_size);
304                let line_bottom = line_top - self.cached_line_h;
305                ctx.begin_path();
306                ctx.rect(x0, line_bottom, x1 - x0, self.cached_line_h);
307                ctx.fill();
308            }
309        }
310
311        // ── Text ───────────────────────────────────────────────────
312        tt.sel_loop_ms = pt::ms(&t);
313        t = pt::start();
314        ctx.set_fill_color(v.text_color);
315        for (i, line) in self.cached_lines.iter().enumerate() {
316            if line.text.is_empty() {
317                continue;
318            }
319            // Visible-range culling (see the selection loop): skip lines wholly
320            // outside the clip band.
321            let line_top = self.line_top_y(i);
322            if line_top < clip_lo || line_top - self.cached_line_h > clip_hi {
323                continue;
324            }
325            tt.painted_lines += 1;
326            let baseline_y = self.line_baseline_y(i);
327            let x0 = self.line_x_start(line);
328            match &self.highlighter {
329                // Syntax-highlighted path: paint each coloured run at its
330                // measured x offset; gaps stay in the ambient text colour.
331                Some(hl) => {
332                    let spans = hl(&line.text);
333                    let th = pt::start();
334                    self.paint_highlighted_line(
335                        ctx,
336                        &line.text,
337                        &spans,
338                        x0,
339                        baseline_y,
340                        v.text_color,
341                    );
342                    tt.hl_ms += pt::ms(&th);
343                }
344                None => {
345                    ctx.fill_text(&line.text, x0, baseline_y);
346                }
347            }
348        }
349        tt.text_loop_ms = pt::ms(&t);
350
351        // ── Hint / placeholder when empty ──────────────────────────
352        // Shown whenever the buffer is empty (egui shows hint text until the
353        // first character is typed, regardless of focus), and honours the
354        // same content alignment as real text.
355        t = pt::start();
356        if st.text.is_empty() && !self.hint.is_empty() {
357            ctx.set_fill_color(v.text_dim);
358            let hint_w = measure_advance(&self.font, &self.hint, self.font_size);
359            let slack = (self.inner_width() - hint_w).max(0.0);
360            let hint_x = self.padding
361                + match self.resolved_h_align() {
362                    TextHAlign::Left => 0.0,
363                    TextHAlign::Center => slack * 0.5,
364                    TextHAlign::Right => slack,
365                };
366            let baseline_y = self.line_baseline_y(0);
367            ctx.fill_text(&self.hint, hint_x, baseline_y);
368        }
369
370        ctx.reset_clip();
371
372        // Border: baked into the cache for the plain (non-scrolling) path so it
373        // stays byte-identical. In band mode the border would scroll with the
374        // blitted content, so it (plus the padding-ring bg that hides text bled
375        // into the padding while scrolling) is drawn fixed in `paint_overlay`.
376        if !band {
377            self.paint_border(ctx, &v);
378        }
379
380        // Clear the band anchor override so hit-test / caret / scroll math
381        // outside `paint` see the live offset again, and drop the strip request
382        // so a later full re-raster is never mistaken for a strip repaint.
383        self.render_band_offset.set(None);
384        self.render_dirty_lines.set(None);
385
386        tt.tail_ms = pt::ms(&t);
387        if strip.is_some() && pt::enabled() {
388            tt.emit();
389        }
390    }
391
392    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
393        // Band mode: the border + padding-ring bg live here (fixed) instead of
394        // in the scrolling cache. Drawn first so the scrollbar + caret land on
395        // top, matching the plain path's z-order.
396        if self.band.active {
397            let v = ctx.visuals();
398            self.paint_band_frame(ctx, &v);
399        }
400
401        // Vertical scroll bar (floating overlay). Painted here — after the
402        // cache blit — so its hover/fade animation stays live without forcing
403        // the text bitmap to re-raster each animation frame.
404        self.paint_scrollbar(ctx);
405
406        // Cursor blink (drawn in overlay so the blink doesn't
407        // invalidate a cached text bitmap).  500 ms half-cycle.
408        if !self.focused {
409            return;
410        }
411        if let Some(t) = self.focus_time {
412            let phase = (t.elapsed().as_millis() / 500) as u64;
413            self.blink_last_phase.set(phase);
414            if phase % 2 == 1 {
415                return;
416            }
417        }
418        let st = self.edit.borrow().clone();
419        let p = self.pos_for_cursor(st.cursor);
420        // `paint_overlay` is drawn AFTER the cached content blit and is NOT
421        // clipped by the framework, so a caret whose line has been scrolled
422        // (wheel/drag) outside the padded inner rect would otherwise streak
423        // over the border/padding. Clamp its vertical span to the inner band
424        // and skip entirely when the line is fully off-screen.
425        let inner_lo = self.padding;
426        let inner_hi = (self.bounds.height - self.padding).max(inner_lo);
427        let Some((y0, y1)) = caret_visible_segment(p.y, self.cached_line_h, inner_lo, inner_hi)
428        else {
429            return;
430        };
431        let v = ctx.visuals();
432        ctx.set_stroke_color(v.text_color);
433        ctx.set_line_width(1.5);
434        ctx.begin_path();
435        ctx.move_to(p.x, y0);
436        ctx.line_to(p.x, y1);
437        ctx.stroke();
438    }
439
440    fn on_event(&mut self, event: &Event) -> EventResult {
441        // While the right-click menu is open it captures events (see
442        // `has_active_modal`); route them through it first.
443        if let Some(result) = self.route_context_menu(event) {
444            return result;
445        }
446        match event {
447            Event::MouseMove { pos } => {
448                // Scroll bar takes priority: continue a thumb drag, or update
449                // its hover state before the text-hover / selection logic.
450                let bar_hover_changed = match self.scrollbar_on_mouse_move(*pos) {
451                    super::scroll::ScrollMove::Dragging(moved) => {
452                        if moved {
453                            crate::animation::request_draw();
454                        }
455                        return EventResult::Consumed;
456                    }
457                    super::scroll::ScrollMove::Hover(changed) => changed,
458                };
459                let was = self.hovered;
460                self.hovered = self.hit_test(*pos);
461                if self.hovered {
462                    set_cursor_icon(CursorIcon::Text);
463                }
464                if self.selecting_drag {
465                    let off = self.byte_offset_at(*pos);
466                    self.extend_selection_drag(off);
467                    crate::animation::request_draw();
468                    return EventResult::Consumed;
469                }
470                if was != self.hovered || bar_hover_changed {
471                    crate::animation::request_draw();
472                    return EventResult::Consumed;
473                }
474                EventResult::Ignored
475            }
476            Event::MouseDown {
477                button: MouseButton::Left,
478                pos,
479                modifiers,
480            } => {
481                // Grabbing the scroll thumb must not also place the caret.
482                if self.scrollbar_begin_drag(*pos) {
483                    crate::animation::request_draw();
484                    return EventResult::Consumed;
485                }
486                let off = self.byte_offset_at(*pos);
487                let clicks = self.multi_click.register(*pos);
488                self.begin_pointer_selection(off, clicks, modifiers.shift);
489                self.selecting_drag = true;
490                self.focus_time = Some(Instant::now());
491                crate::animation::request_draw();
492                EventResult::Consumed
493            }
494            Event::MouseDown {
495                button: MouseButton::Right,
496                pos,
497                ..
498            } => {
499                if self.context_menu_enabled {
500                    self.open_context_menu(*pos);
501                    EventResult::Consumed
502                } else {
503                    EventResult::Ignored
504                }
505            }
506            Event::MouseUp {
507                button: MouseButton::Left,
508                ..
509            } => {
510                self.scrollbar_end_drag();
511                self.selecting_drag = false;
512                EventResult::Consumed
513            }
514            Event::MouseWheel { delta_y, .. } => {
515                if self.scroll_by_wheel(*delta_y) {
516                    crate::animation::request_draw();
517                    EventResult::Consumed
518                } else {
519                    EventResult::Ignored
520                }
521            }
522            Event::FocusGained => {
523                self.focused = true;
524                self.focus_time = Some(Instant::now());
525                crate::animation::request_draw();
526                EventResult::Ignored
527            }
528            Event::FocusLost => {
529                self.focused = false;
530                self.selecting_drag = false;
531                crate::animation::request_draw();
532                EventResult::Ignored
533            }
534            Event::KeyDown { key, modifiers } => {
535                // Pre-default key-chord interception (e.g. the demo's Ctrl/Cmd+Y
536                // case-toggle). A `true` return consumes the event and skips
537                // built-in handling. Cloned out of `self` so the callback can
538                // freely borrow the shared edit state we also hold.
539                if let Some(cb) = self.on_key_chord.clone() {
540                    let epoch_before = self.edit.borrow().epoch;
541                    if (cb.borrow_mut())(key, modifiers) {
542                        // An interceptor that edits text advances the content
543                        // epoch (via `note_text_change`); mirror the built-in
544                        // funnels by re-wrapping, firing `on_change`, and
545                        // scrolling the (possibly moved) caret back into view.
546                        if self.edit.borrow().epoch != epoch_before {
547                            self.mark_dirty();
548                            self.notify_change();
549                            self.ensure_cursor_visible();
550                        }
551                        crate::animation::request_draw();
552                        return EventResult::Consumed;
553                    }
554                }
555                let shift = modifiers.shift;
556                let cmd = modifiers.ctrl || modifiers.meta;
557                // Word-wise navigation/deletion: Ctrl or Alt, matching egui
558                // (`alt || ctrl`) and TextField.
559                let word = modifiers.ctrl || modifiers.alt;
560                match key {
561                    Key::ArrowLeft => {
562                        if word {
563                            self.move_word(-1, shift);
564                        } else {
565                            self.move_char(-1, shift);
566                        }
567                    }
568                    Key::ArrowRight => {
569                        if word {
570                            self.move_word(1, shift);
571                        } else {
572                            self.move_char(1, shift);
573                        }
574                    }
575                    Key::ArrowUp => {
576                        self.move_line(-1, shift);
577                    }
578                    Key::ArrowDown => {
579                        self.move_line(1, shift);
580                    }
581                    Key::Home => {
582                        // Ctrl/Cmd+Home → document start; plain Home → start of
583                        // the current visual (wrapped) line.
584                        let target = if cmd {
585                            0
586                        } else {
587                            let cur = self.edit.borrow().cursor;
588                            let line = self.line_for_cursor(cur);
589                            self.cached_lines[line].start
590                        };
591                        self.move_cursor_to(target, shift);
592                    }
593                    Key::End => {
594                        // Ctrl/Cmd+End → document end; plain End → end of the
595                        // current visual (wrapped) line.
596                        let target = if cmd {
597                            self.edit.borrow().text.len()
598                        } else {
599                            let cur = self.edit.borrow().cursor;
600                            let line = self.line_for_cursor(cur);
601                            self.cached_lines[line].end
602                        };
603                        self.move_cursor_to(target, shift);
604                    }
605                    Key::PageUp => {
606                        let n = self.page_lines() as isize;
607                        self.move_lines(-n, shift);
608                    }
609                    Key::PageDown => {
610                        let n = self.page_lines() as isize;
611                        self.move_lines(n, shift);
612                    }
613                    Key::Backspace => {
614                        if word {
615                            self.delete_word(-1);
616                        } else {
617                            self.delete(-1);
618                        }
619                    }
620                    Key::Delete => {
621                        if word {
622                            self.delete_word(1);
623                        } else {
624                            self.delete(1);
625                        }
626                    }
627                    Key::Enter => {
628                        self.insert_str("\n");
629                    }
630                    Key::Tab => {
631                        // Code-editor Tab: Shift+Tab always outdents; a plain
632                        // Tab over a multi-line selection indents every touched
633                        // line, otherwise it inserts a single indent (replacing
634                        // any within-line selection).
635                        let multi_line = {
636                            let st = self.edit.borrow();
637                            let (lo, hi) = (st.cursor.min(st.anchor), st.cursor.max(st.anchor));
638                            hi > lo && st.text[lo..hi].contains('\n')
639                        };
640                        if shift {
641                            self.outdent_selection();
642                        } else if multi_line {
643                            self.indent_selection();
644                        } else {
645                            self.insert_str("    ");
646                        }
647                    }
648                    Key::Char('a') | Key::Char('A') if cmd => {
649                        self.select_all_text();
650                    }
651                    Key::Char('c') | Key::Char('C') if cmd => {
652                        self.clipboard_copy();
653                    }
654                    Key::Char('x') | Key::Char('X') if cmd => {
655                        self.clipboard_cut();
656                    }
657                    Key::Char('v') | Key::Char('V') if cmd => {
658                        self.clipboard_paste();
659                    }
660                    Key::Char(c) if !cmd => {
661                        let mut s = [0u8; 4];
662                        self.insert_str(c.encode_utf8(&mut s));
663                    }
664                    _ => return EventResult::Ignored,
665                }
666                // Keep the caret on-screen after any edit or navigation
667                // (re-wraps if the edit dirtied the cache, then scrolls).
668                self.ensure_cursor_visible();
669                self.focus_time = Some(Instant::now());
670                crate::animation::request_draw();
671                EventResult::Consumed
672            }
673            _ => EventResult::Ignored,
674        }
675    }
676
677    fn hit_test(&self, local_pos: Point) -> bool {
678        local_pos.x >= 0.0
679            && local_pos.x <= self.bounds.width
680            && local_pos.y >= 0.0
681            && local_pos.y <= self.bounds.height
682    }
683
684    fn properties(&self) -> Vec<(&'static str, String)> {
685        let st = self.edit.borrow();
686        vec![
687            ("len", st.text.len().to_string()),
688            ("cursor", st.cursor.to_string()),
689            ("lines", self.cached_lines.len().to_string()),
690            ("focused", self.focused.to_string()),
691        ]
692    }
693
694    fn needs_draw(&self) -> bool {
695        // Scrollbar fade/expand tween is a genuine per-frame animation while it
696        // runs, so it keeps requesting frames.  The cursor blink, by contrast,
697        // is transition-scheduled: report dirty only when wall-clock time has
698        // crossed a 500 ms flip boundary since the last painted phase.  An idle
699        // focused editor therefore wakes twice a second (see
700        // `next_draw_deadline`) instead of every frame.
701        if self.scrollbar_animating() {
702            return true;
703        }
704        if !self.focused {
705            return false;
706        }
707        let Some(t) = self.focus_time else {
708            return false;
709        };
710        let current_phase = (t.elapsed().as_millis() / 500) as u64;
711        current_phase != self.blink_last_phase.get()
712    }
713
714    fn next_draw_deadline(&self) -> Option<web_time::Instant> {
715        if !self.focused {
716            return None;
717        }
718        let t = self.focus_time?;
719        let ms = t.elapsed().as_millis() as u64;
720        let next_phase = (ms / 500) + 1;
721        Some(t + std::time::Duration::from_millis(next_phase * 500))
722    }
723
724    /// While the right-click menu is open it must capture every event (clicks
725    /// outside dismiss it, Escape closes it).
726    fn has_active_modal(&self) -> bool {
727        self.context_menu.is_open()
728    }
729
730    /// The context menu paints at app level so it can overflow the editor
731    /// bounds and clamp to the viewport.
732    fn paint_global_overlay(&mut self, ctx: &mut dyn DrawCtx) {
733        let font =
734            crate::font_settings::current_system_font().unwrap_or_else(|| Arc::clone(&self.font));
735        self.context_menu.paint(ctx, font, self.font_size);
736    }
737}