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
94impl Widget for TextArea {
95    fn type_name(&self) -> &'static str {
96        "TextArea"
97    }
98    fn bounds(&self) -> Rect {
99        self.bounds
100    }
101    fn set_bounds(&mut self, b: Rect) {
102        self.bounds = b;
103    }
104    fn children(&self) -> &[Box<dyn Widget>] {
105        &self.children
106    }
107    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
108        &mut self.children
109    }
110
111    fn is_focusable(&self) -> bool {
112        true
113    }
114
115    fn focus_id(&self) -> Option<crate::focus::FocusId> {
116        self.focus_request_id
117    }
118
119    fn accepts_text_input(&self) -> bool {
120        true
121    }
122
123    fn text_input_value(&self) -> Option<String> {
124        Some(self.text())
125    }
126
127    fn margin(&self) -> Insets {
128        self.base.margin
129    }
130    fn widget_base(&self) -> Option<&WidgetBase> {
131        Some(&self.base)
132    }
133    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
134        Some(&mut self.base)
135    }
136    fn h_anchor(&self) -> HAnchor {
137        self.base.h_anchor
138    }
139    fn v_anchor(&self) -> VAnchor {
140        self.base.v_anchor
141    }
142    fn min_size(&self) -> Size {
143        self.base.min_size
144    }
145    fn max_size(&self) -> Size {
146        self.base.max_size
147    }
148
149    fn backbuffer_cache_mut(&mut self) -> Option<&mut BackbufferCache> {
150        Some(&mut self.cache)
151    }
152
153    fn backbuffer_mode(&self) -> BackbufferMode {
154        // Same decision as `Label` / `TextField`: LCD-subpixel coverage buffer
155        // when the global toggle is on (default at scale ≤ 1.25), grayscale
156        // RGBA otherwise. `backbuffer_mode`'s mode-flip detection in
157        // `paint_subtree_backbuffered` re-rasters when the toggle changes.
158        if crate::font_settings::lcd_enabled() {
159            BackbufferMode::LcdCoverage
160        } else {
161            BackbufferMode::Rgba
162        }
163    }
164
165    fn measure_min_height(&self, available_w: f64) -> f64 {
166        // Wrap our text at the supplied width and report the total
167        // visual height + vertical padding.  This is what an
168        // ancestor `Window::tight_content_fit` sums to derive a
169        // window minimum that prevents text from going off-screen,
170        // even when this widget sits in a flex-fill slot whose
171        // `layout` would otherwise just return the available area.
172        //
173        // Cheap: `wrap_text_indexed` is the same function `layout`
174        // already calls; it doesn't mutate any cache.  Always at
175        // least one line so the cursor has somewhere to sit.
176        let inner_w = (available_w - self.padding * 2.0).max(1.0);
177        let lines = wrap_text_indexed(
178            &self.font,
179            &self.edit.borrow().text,
180            self.font_size,
181            inner_w,
182        );
183        let line_h = self.font_size * 1.35;
184        (lines.len().max(1) as f64) * line_h + self.padding * 2.0
185    }
186
187    fn layout(&mut self, available: Size) -> Size {
188        // Fill the slot we're given.  A parent that allocates us via
189        // a flex-weight gets everything; a parent that asks for our
190        // natural size gets the same (the caller is opting into
191        // "whatever you want" with `available`).
192        let w = available.width.max(self.padding * 2.0 + 20.0);
193        let h = available
194            .height
195            .max(self.padding * 2.0 + self.font_size * 1.6);
196        self.bounds = Rect::new(0.0, 0.0, w, h);
197        let inner_w = (w - self.padding * 2.0).max(1.0);
198        self.refresh_wrap(inner_w);
199        self.sync_scroll();
200        // Catch cursor moves made straight through the shared TextEditState
201        // (e.g. the demo's "start"/"end" buttons) that bypass the edit funnel
202        // and thus never ran ensure_cursor_visible. The first layout only
203        // records the baseline so seeding a caret at the end doesn't force an
204        // unexpected scroll before the user interacts.
205        let cursor = self.edit.borrow().cursor;
206        if matches!(self.last_layout_cursor, Some(prev) if prev != cursor) {
207            self.ensure_cursor_visible();
208        }
209        self.last_layout_cursor = Some(cursor);
210
211        // Drop the cached bitmap when any painted input changed (text, cursor,
212        // selection, scroll, focus/hover, size, alignment). Blink phase and the
213        // floating scrollbar are excluded — they paint in `paint_overlay`.
214        let sig = self.cache_sig();
215        if self.last_sig.as_ref() != Some(&sig) {
216            self.last_sig = Some(sig);
217            self.cache.invalidate();
218        }
219        Size::new(w, h)
220    }
221
222    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
223        let v = ctx.visuals();
224        let w = self.bounds.width;
225        let h = self.bounds.height;
226
227        // Background — theme widget fill.
228        ctx.set_fill_color(v.widget_bg);
229        ctx.begin_path();
230        ctx.rounded_rect(0.0, 0.0, w, h, 4.0);
231        ctx.fill();
232
233        // Clip content to the padded inner rect so overflow text can't
234        // leak across the border.
235        ctx.clip_rect(
236            self.padding,
237            self.padding,
238            (w - self.padding * 2.0).max(0.0),
239            (h - self.padding * 2.0).max(0.0),
240        );
241
242        ctx.set_font(Arc::clone(&self.font));
243        ctx.set_font_size(self.font_size);
244
245        // ── Selection highlight ───────────────────────────────────
246        let st = self.edit.borrow().clone();
247        if st.cursor != st.anchor {
248            let lo = st.cursor.min(st.anchor);
249            let hi = st.cursor.max(st.anchor);
250            let hl_color = if self.focused {
251                v.selection_bg
252            } else {
253                v.selection_bg_unfocused
254            };
255            ctx.set_fill_color(hl_color);
256            for (i, line) in self.cached_lines.iter().enumerate() {
257                if line.end < lo || line.start > hi {
258                    continue;
259                }
260                let sel_s = lo.max(line.start) - line.start;
261                let sel_e = hi.min(line.end) - line.start;
262                let sel_e = sel_e.min(line.text.len());
263                if sel_e <= sel_s {
264                    continue;
265                }
266                let line_x = self.line_x_start(line);
267                let x0 = line_x + measure_advance(&self.font, &line.text[..sel_s], self.font_size);
268                let x1 = line_x + measure_advance(&self.font, &line.text[..sel_e], self.font_size);
269                let line_top = self.line_top_y(i);
270                let line_bottom = line_top - self.cached_line_h;
271                ctx.begin_path();
272                ctx.rect(x0, line_bottom, x1 - x0, self.cached_line_h);
273                ctx.fill();
274            }
275        }
276
277        // ── Text ───────────────────────────────────────────────────
278        ctx.set_fill_color(v.text_color);
279        for (i, line) in self.cached_lines.iter().enumerate() {
280            if line.text.is_empty() {
281                continue;
282            }
283            let baseline_y = self.line_baseline_y(i);
284            let x0 = self.line_x_start(line);
285            match &self.highlighter {
286                // Syntax-highlighted path: paint each coloured run at its
287                // measured x offset; gaps stay in the ambient text colour.
288                Some(hl) => {
289                    let spans = hl(&line.text);
290                    self.paint_highlighted_line(ctx, &line.text, &spans, x0, baseline_y, v.text_color);
291                }
292                None => {
293                    ctx.fill_text(&line.text, x0, baseline_y);
294                }
295            }
296        }
297
298        // ── Hint / placeholder when empty ──────────────────────────
299        // Shown whenever the buffer is empty (egui shows hint text until the
300        // first character is typed, regardless of focus), and honours the
301        // same content alignment as real text.
302        if st.text.is_empty() && !self.hint.is_empty() {
303            ctx.set_fill_color(v.text_dim);
304            let hint_w = measure_advance(&self.font, &self.hint, self.font_size);
305            let slack = (self.inner_width() - hint_w).max(0.0);
306            let hint_x = self.padding
307                + match self.resolved_h_align() {
308                    TextHAlign::Left => 0.0,
309                    TextHAlign::Center => slack * 0.5,
310                    TextHAlign::Right => slack,
311                };
312            let baseline_y = self.line_baseline_y(0);
313            ctx.fill_text(&self.hint, hint_x, baseline_y);
314        }
315
316        ctx.reset_clip();
317
318        // ── Border ────────────────────────────────────────────────
319        let border = if self.focused {
320            v.accent
321        } else if self.hovered {
322            v.widget_stroke_active
323        } else {
324            v.widget_stroke
325        };
326        let line_width = if self.focused { 2.0 } else { 1.0 };
327        ctx.set_stroke_color(border);
328        ctx.set_line_width(line_width);
329        ctx.begin_path();
330        let inset = line_width * 0.5;
331        ctx.rounded_rect(
332            inset,
333            inset,
334            (w - line_width).max(0.0),
335            (h - line_width).max(0.0),
336            4.0,
337        );
338        ctx.stroke();
339    }
340
341    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
342        // Vertical scroll bar (floating overlay). Painted here — after the
343        // cache blit — so its hover/fade animation stays live without forcing
344        // the text bitmap to re-raster each animation frame.
345        self.paint_scrollbar(ctx);
346
347        // Cursor blink (drawn in overlay so the blink doesn't
348        // invalidate a cached text bitmap).  500 ms half-cycle.
349        if !self.focused {
350            return;
351        }
352        if let Some(t) = self.focus_time {
353            let phase = (t.elapsed().as_millis() / 500) as u64;
354            self.blink_last_phase.set(phase);
355            if phase % 2 == 1 {
356                return;
357            }
358        }
359        let st = self.edit.borrow().clone();
360        let p = self.pos_for_cursor(st.cursor);
361        // `paint_overlay` is drawn AFTER the cached content blit and is NOT
362        // clipped by the framework, so a caret whose line has been scrolled
363        // (wheel/drag) outside the padded inner rect would otherwise streak
364        // over the border/padding. Clamp its vertical span to the inner band
365        // and skip entirely when the line is fully off-screen.
366        let inner_lo = self.padding;
367        let inner_hi = (self.bounds.height - self.padding).max(inner_lo);
368        let Some((y0, y1)) =
369            caret_visible_segment(p.y, self.cached_line_h, inner_lo, inner_hi)
370        else {
371            return;
372        };
373        let v = ctx.visuals();
374        ctx.set_stroke_color(v.text_color);
375        ctx.set_line_width(1.5);
376        ctx.begin_path();
377        ctx.move_to(p.x, y0);
378        ctx.line_to(p.x, y1);
379        ctx.stroke();
380    }
381
382    fn on_event(&mut self, event: &Event) -> EventResult {
383        // While the right-click menu is open it captures events (see
384        // `has_active_modal`); route them through it first.
385        if let Some(result) = self.route_context_menu(event) {
386            return result;
387        }
388        match event {
389            Event::MouseMove { pos } => {
390                // Scroll bar takes priority: continue a thumb drag, or update
391                // its hover state before the text-hover / selection logic.
392                let bar_hover_changed = match self.scrollbar_on_mouse_move(*pos) {
393                    super::scroll::ScrollMove::Dragging(moved) => {
394                        if moved {
395                            crate::animation::request_draw();
396                        }
397                        return EventResult::Consumed;
398                    }
399                    super::scroll::ScrollMove::Hover(changed) => changed,
400                };
401                let was = self.hovered;
402                self.hovered = self.hit_test(*pos);
403                if self.hovered {
404                    set_cursor_icon(CursorIcon::Text);
405                }
406                if self.selecting_drag {
407                    let off = self.byte_offset_at(*pos);
408                    self.extend_selection_drag(off);
409                    crate::animation::request_draw();
410                    return EventResult::Consumed;
411                }
412                if was != self.hovered || bar_hover_changed {
413                    crate::animation::request_draw();
414                    return EventResult::Consumed;
415                }
416                EventResult::Ignored
417            }
418            Event::MouseDown {
419                button: MouseButton::Left,
420                pos,
421                modifiers,
422            } => {
423                // Grabbing the scroll thumb must not also place the caret.
424                if self.scrollbar_begin_drag(*pos) {
425                    crate::animation::request_draw();
426                    return EventResult::Consumed;
427                }
428                let off = self.byte_offset_at(*pos);
429                let clicks = self.multi_click.register(*pos);
430                self.begin_pointer_selection(off, clicks, modifiers.shift);
431                self.selecting_drag = true;
432                self.focus_time = Some(Instant::now());
433                crate::animation::request_draw();
434                EventResult::Consumed
435            }
436            Event::MouseDown {
437                button: MouseButton::Right,
438                pos,
439                ..
440            } => {
441                if self.context_menu_enabled {
442                    self.open_context_menu(*pos);
443                    EventResult::Consumed
444                } else {
445                    EventResult::Ignored
446                }
447            }
448            Event::MouseUp {
449                button: MouseButton::Left,
450                ..
451            } => {
452                self.scrollbar_end_drag();
453                self.selecting_drag = false;
454                EventResult::Consumed
455            }
456            Event::MouseWheel { delta_y, .. } => {
457                if self.scroll_by_wheel(*delta_y) {
458                    crate::animation::request_draw();
459                    EventResult::Consumed
460                } else {
461                    EventResult::Ignored
462                }
463            }
464            Event::FocusGained => {
465                self.focused = true;
466                self.focus_time = Some(Instant::now());
467                crate::animation::request_draw();
468                EventResult::Ignored
469            }
470            Event::FocusLost => {
471                self.focused = false;
472                self.selecting_drag = false;
473                crate::animation::request_draw();
474                EventResult::Ignored
475            }
476            Event::KeyDown { key, modifiers } => {
477                // Pre-default key-chord interception (e.g. the demo's Ctrl/Cmd+Y
478                // case-toggle). A `true` return consumes the event and skips
479                // built-in handling. Cloned out of `self` so the callback can
480                // freely borrow the shared edit state we also hold.
481                if let Some(cb) = self.on_key_chord.clone() {
482                    let epoch_before = self.edit.borrow().epoch;
483                    if (cb.borrow_mut())(key, modifiers) {
484                        // An interceptor that edits text advances the content
485                        // epoch (via `note_text_change`); mirror the built-in
486                        // funnels by re-wrapping, firing `on_change`, and
487                        // scrolling the (possibly moved) caret back into view.
488                        if self.edit.borrow().epoch != epoch_before {
489                            self.mark_dirty();
490                            self.notify_change();
491                            self.ensure_cursor_visible();
492                        }
493                        crate::animation::request_draw();
494                        return EventResult::Consumed;
495                    }
496                }
497                let shift = modifiers.shift;
498                let cmd = modifiers.ctrl || modifiers.meta;
499                match key {
500                    Key::ArrowLeft => {
501                        self.move_char(-1, shift);
502                    }
503                    Key::ArrowRight => {
504                        self.move_char(1, shift);
505                    }
506                    Key::ArrowUp => {
507                        self.move_line(-1, shift);
508                    }
509                    Key::ArrowDown => {
510                        self.move_line(1, shift);
511                    }
512                    Key::Home => {
513                        // Ctrl/Cmd+Home → document start; plain Home → start of
514                        // the current visual (wrapped) line.
515                        let target = if cmd {
516                            0
517                        } else {
518                            let cur = self.edit.borrow().cursor;
519                            let line = self.line_for_cursor(cur);
520                            self.cached_lines[line].start
521                        };
522                        self.move_cursor_to(target, shift);
523                    }
524                    Key::End => {
525                        // Ctrl/Cmd+End → document end; plain End → end of the
526                        // current visual (wrapped) line.
527                        let target = if cmd {
528                            self.edit.borrow().text.len()
529                        } else {
530                            let cur = self.edit.borrow().cursor;
531                            let line = self.line_for_cursor(cur);
532                            self.cached_lines[line].end
533                        };
534                        self.move_cursor_to(target, shift);
535                    }
536                    Key::PageUp => {
537                        let n = self.page_lines() as isize;
538                        self.move_lines(-n, shift);
539                    }
540                    Key::PageDown => {
541                        let n = self.page_lines() as isize;
542                        self.move_lines(n, shift);
543                    }
544                    Key::Backspace => {
545                        self.delete(-1);
546                    }
547                    Key::Delete => {
548                        self.delete(1);
549                    }
550                    Key::Enter => {
551                        self.insert_str("\n");
552                    }
553                    Key::Tab => {
554                        self.insert_str("    ");
555                    }
556                    Key::Char('a') | Key::Char('A') if cmd => {
557                        self.select_all_text();
558                    }
559                    Key::Char('c') | Key::Char('C') if cmd => {
560                        self.clipboard_copy();
561                    }
562                    Key::Char('x') | Key::Char('X') if cmd => {
563                        self.clipboard_cut();
564                    }
565                    Key::Char('v') | Key::Char('V') if cmd => {
566                        self.clipboard_paste();
567                    }
568                    Key::Char(c) if !cmd => {
569                        let mut s = [0u8; 4];
570                        self.insert_str(c.encode_utf8(&mut s));
571                    }
572                    _ => return EventResult::Ignored,
573                }
574                // Keep the caret on-screen after any edit or navigation
575                // (re-wraps if the edit dirtied the cache, then scrolls).
576                self.ensure_cursor_visible();
577                self.focus_time = Some(Instant::now());
578                crate::animation::request_draw();
579                EventResult::Consumed
580            }
581            _ => EventResult::Ignored,
582        }
583    }
584
585    fn hit_test(&self, local_pos: Point) -> bool {
586        local_pos.x >= 0.0
587            && local_pos.x <= self.bounds.width
588            && local_pos.y >= 0.0
589            && local_pos.y <= self.bounds.height
590    }
591
592    fn properties(&self) -> Vec<(&'static str, String)> {
593        let st = self.edit.borrow();
594        vec![
595            ("len", st.text.len().to_string()),
596            ("cursor", st.cursor.to_string()),
597            ("lines", self.cached_lines.len().to_string()),
598            ("focused", self.focused.to_string()),
599        ]
600    }
601
602    fn needs_draw(&self) -> bool {
603        // Scrollbar fade/expand tween is a genuine per-frame animation while it
604        // runs, so it keeps requesting frames.  The cursor blink, by contrast,
605        // is transition-scheduled: report dirty only when wall-clock time has
606        // crossed a 500 ms flip boundary since the last painted phase.  An idle
607        // focused editor therefore wakes twice a second (see
608        // `next_draw_deadline`) instead of every frame.
609        if self.scrollbar_animating() {
610            return true;
611        }
612        if !self.focused {
613            return false;
614        }
615        let Some(t) = self.focus_time else {
616            return false;
617        };
618        let current_phase = (t.elapsed().as_millis() / 500) as u64;
619        current_phase != self.blink_last_phase.get()
620    }
621
622    fn next_draw_deadline(&self) -> Option<web_time::Instant> {
623        if !self.focused {
624            return None;
625        }
626        let t = self.focus_time?;
627        let ms = t.elapsed().as_millis() as u64;
628        let next_phase = (ms / 500) + 1;
629        Some(t + std::time::Duration::from_millis(next_phase * 500))
630    }
631
632    /// While the right-click menu is open it must capture every event (clicks
633    /// outside dismiss it, Escape closes it).
634    fn has_active_modal(&self) -> bool {
635        self.context_menu.is_open()
636    }
637
638    /// The context menu paints at app level so it can overflow the editor
639    /// bounds and clamp to the viewport.
640    fn paint_global_overlay(&mut self, ctx: &mut dyn DrawCtx) {
641        let font = crate::font_settings::current_system_font()
642            .unwrap_or_else(|| Arc::clone(&self.font));
643        self.context_menu.paint(ctx, font, self.font_size);
644    }
645}