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}