Skip to main content

agg_gui/widgets/text_area/
widget_impl.rs

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