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}