ailloli_ui_editor/engine.rs
1//! Cached frame construction, hit testing, caret geometry, and scroll metrics.
2
3use ailloli_ui_core::scroll::{
4 ScrollMetrics, ScrollState, ScrollbarAxis, ScrollbarGeometry, ScrollbarGeometrySpec,
5};
6use ailloli_ui_core::{Offset, Point, Rect, Size};
7use ailloli_ui_text::TextSystem;
8use std::time::Instant;
9
10use crate::code::CodeEditorSession;
11use crate::input::caret::caret_rect_for_runs;
12use crate::input::hit_test::{byte_at_point, zone_byte_at_point, EditorHitTest, EditorZoneHitTest};
13use crate::input::ime::display_buffer_for_edit;
14use crate::layout::{
15 build_visible_text_layout, build_visible_text_layout_filtered, LayoutCache,
16 ParagraphMetricsCache,
17};
18use crate::paint::active_line_painter::{active_line_index_for_caret, active_line_item_for_caret};
19use crate::paint::caret_painter::{caret_item, caret_visible};
20use crate::paint::code_decorations_painter::{
21 diagnostic_underline_items_for_run, search_highlight_items_for_run,
22};
23use crate::paint::folding_painter::fold_placeholder_item_for_run;
24use crate::paint::gutter_painter::{
25 diagnostic_gutter_marker_items, fold_gutter_marker_items, gutter_background_item,
26 line_number_items,
27};
28use crate::paint::selection_painter::selection_items_for_run;
29use crate::paint::syntax_painter::syntax_text_items_for_run;
30use crate::paint::text_painter::text_item;
31use crate::{
32 CodeTheme, EditorFrame, EditorFrameDebugMetrics, EditorPaintItem, EditorScrollbarConfig,
33 EditorScrollbarStyle, EditorSession, EditorViewport, EditorWrapMode,
34};
35
36/// UI-agnostic editor engine for cached layout, frame production, and hit tests.
37///
38/// One engine retains unbounded per-paragraph layout/metrics maps, the last
39/// complete frame, and a wrapping frame counter. It is not synchronized for
40/// concurrent use and should normally be owned by one editor view.
41///
42/// # Examples
43///
44/// ```
45/// use ailloli_ui_editor::EditorEngine;
46/// let mut engine = EditorEngine::new();
47/// engine.clear_caches();
48/// ```
49#[derive(Debug, Clone, Default)]
50pub struct EditorEngine {
51 /// Prepared paragraph layouts keyed by geometry-affecting inputs.
52 layout_cache: LayoutCache,
53 /// Paragraph dimensions and aggregate revision metadata.
54 metrics_cache: ParagraphMetricsCache,
55 /// Most recently returned frame, including a cloned set of layout handles.
56 last_frame: Option<EditorFrame>,
57 /// Wrapping counter incremented before each completed frame.
58 frame_id: u64,
59}
60
61/// Optional code-editor layers supplied to the shared frame pipeline.
62struct FrameExtras<'a> {
63 /// Code theme and whether line numbers should be painted.
64 code_theme: Option<(CodeTheme, bool)>,
65 /// Optional editor-owned scrollbar configuration.
66 scrollbars: Option<EditorScrollbarConfig>,
67 /// Search, diagnostic, and active-diagnostic paint inputs.
68 code_decorations: Option<(
69 &'a crate::code::SearchState,
70 &'a [crate::code::Diagnostic],
71 Option<usize>,
72 )>,
73 /// Optional source-buffer syntax tokens.
74 syntax_tokens: Option<&'a [crate::code::SyntaxToken]>,
75 /// Optional fold ranges used for hiding and gutter paint.
76 fold_regions: Option<&'a [crate::code::FoldRegion]>,
77}
78
79/// Constructs frame extras for a generic editor.
80impl<'a> FrameExtras<'a> {
81 /// Returns an extras bundle with every code layer disabled.
82 fn none() -> Self {
83 Self {
84 code_theme: None,
85 scrollbars: None,
86 code_decorations: None,
87 syntax_tokens: None,
88 fold_regions: None,
89 }
90 }
91}
92
93/// Produces frames and queries their retained geometry.
94impl EditorEngine {
95 /// Creates an empty engine with frame counter zero.
96 ///
97 /// # Examples
98 ///
99 /// ```
100 /// use ailloli_ui_editor::EditorEngine;
101 /// let engine = EditorEngine::new();
102 /// assert_eq!(format!("{engine:?}").contains("frame_id: 0"), true);
103 /// ```
104 pub fn new() -> Self {
105 Self::default()
106 }
107
108 /// Clears paragraph layout and metric caches.
109 ///
110 /// The retained last frame, wrapping frame ID, and the caller-owned
111 /// [`TextSystem`] cache are not cleared.
112 ///
113 /// # Examples
114 ///
115 /// ```
116 /// use ailloli_ui_editor::EditorEngine;
117 /// let mut engine = EditorEngine::new();
118 /// engine.clear_caches();
119 /// ```
120 pub fn clear_caches(&mut self) {
121 self.layout_cache.clear();
122 self.metrics_cache.clear();
123 }
124
125 /// Builds a generic editor frame at blink time zero.
126 ///
127 /// A focused caret is therefore in its visible phase. The returned frame is
128 /// cloned into the engine as its only cached frame, and its ID increments
129 /// with wrapping arithmetic.
130 ///
131 /// # Examples
132 ///
133 /// ```
134 /// use ailloli_ui_core::Rect;
135 /// use ailloli_ui_editor::{EditorEngine, EditorSession};
136 /// use ailloli_ui_text::{TextBuffer, TextSystem};
137 /// let session = EditorSession::new(TextBuffer::from_string("hello"));
138 /// let bounds = Rect::new(0.0, 0.0, 100.0, 50.0);
139 /// let frame = EditorEngine::new().frame(&session, bounds, true, &mut TextSystem::new());
140 /// assert_eq!(frame.viewport.bounds, bounds);
141 /// assert_eq!(frame.debug_metrics.frame_id, 1);
142 /// ```
143 pub fn frame(
144 &mut self,
145 session: &EditorSession,
146 bounds: Rect,
147 focused: bool,
148 text_system: &mut TextSystem,
149 ) -> EditorFrame {
150 self.frame_at(session, bounds, focused, 0, text_system)
151 }
152
153 /// Builds a generic frame at an explicit millisecond blink time.
154 ///
155 /// `frame_time_ms` controls only caret blink phase. Layout timing is measured
156 /// independently in microseconds and saturates at [`u64::MAX`].
157 ///
158 /// # Examples
159 ///
160 /// ```
161 /// use ailloli_ui_core::Rect;
162 /// use ailloli_ui_editor::{EditorEngine, EditorPaintItem, EditorSession};
163 /// use ailloli_ui_text::{TextBuffer, TextSystem};
164 /// let session = EditorSession::new(TextBuffer::from_string("x"));
165 /// let frame = EditorEngine::new().frame_at(&session, Rect::new(0.0, 0.0, 80.0, 40.0), true, 500, &mut TextSystem::new());
166 /// assert!(!frame.paint_items.iter().any(|item| matches!(item, EditorPaintItem::Caret { .. })));
167 /// ```
168 pub fn frame_at(
169 &mut self,
170 session: &EditorSession,
171 bounds: Rect,
172 focused: bool,
173 frame_time_ms: u128,
174 text_system: &mut TextSystem,
175 ) -> EditorFrame {
176 let viewport = EditorViewport::new(bounds, session.config, &session.edit);
177 self.frame_for_viewport(
178 session,
179 viewport,
180 focused,
181 frame_time_ms,
182 FrameExtras::none(),
183 text_system,
184 )
185 }
186
187 /// Builds a code-editor frame at blink time zero.
188 ///
189 /// The engine consumes the session's current tokens, folds, search results,
190 /// and diagnostics without refreshing them. It adds the configured gutter,
191 /// code layers, and enabled scrollbars to the generic paint model.
192 ///
193 /// # Examples
194 ///
195 /// ```
196 /// use ailloli_ui_core::Rect;
197 /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId, EditorEngine};
198 /// use ailloli_ui_text::{TextBuffer, TextSystem};
199 /// let session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("code")), CodeEditorConfig::default());
200 /// let frame = EditorEngine::new().code_frame(&session, Rect::new(0.0, 0.0, 140.0, 60.0), false, &mut TextSystem::new());
201 /// assert!(frame.viewport.gutter_rect.is_some());
202 /// ```
203 pub fn code_frame(
204 &mut self,
205 session: &CodeEditorSession,
206 bounds: Rect,
207 focused: bool,
208 text_system: &mut TextSystem,
209 ) -> EditorFrame {
210 self.code_frame_at(session, bounds, focused, 0, text_system)
211 }
212
213 /// Builds a code-editor frame at an explicit millisecond blink time.
214 ///
215 /// Gutter fold markers control whether folded lines are filtered at all;
216 /// other [`crate::CodeEditorFeatureFlags`] are descriptive configuration and
217 /// are not consulted by this frame builder.
218 ///
219 /// # Examples
220 ///
221 /// ```
222 /// use ailloli_ui_core::Rect;
223 /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId, EditorEngine};
224 /// use ailloli_ui_text::{TextBuffer, TextSystem};
225 /// let session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("x")), CodeEditorConfig::default());
226 /// let frame = EditorEngine::new().code_frame_at(&session, Rect::new(0.0, 0.0, 140.0, 60.0), true, 0, &mut TextSystem::new());
227 /// assert_eq!(frame.debug_metrics.frame_id, 1);
228 /// ```
229 pub fn code_frame_at(
230 &mut self,
231 session: &CodeEditorSession,
232 bounds: Rect,
233 focused: bool,
234 frame_time_ms: u128,
235 text_system: &mut TextSystem,
236 ) -> EditorFrame {
237 let viewport = EditorViewport::with_gutter(
238 bounds,
239 session.editor.config,
240 &session.editor.edit,
241 Some(session.config.gutter),
242 );
243 self.frame_for_viewport(
244 &session.editor,
245 viewport,
246 focused,
247 frame_time_ms,
248 FrameExtras {
249 code_theme: Some((session.config.theme, session.config.gutter.line_numbers)),
250 scrollbars: Some(session.config.scrollbars),
251 code_decorations: Some((
252 &session.search,
253 &session.diagnostics,
254 session.active_diagnostic_index,
255 )),
256 syntax_tokens: Some(&session.syntax_tokens),
257 fold_regions: session
258 .config
259 .gutter
260 .fold_markers
261 .then_some(session.fold_regions.as_slice()),
262 },
263 text_system,
264 )
265 }
266
267 /// Runs the common layout and ordered paint-layer pipeline.
268 ///
269 /// IME preedit text is projected into a temporary display buffer. Collapsed
270 /// folds remove hidden logical lines before hit testing and paint. The frame
271 /// always begins with a background item; subsequent gutter, active line,
272 /// selection, decorations, text, fold labels, carets, and scrollbars follow
273 /// deterministic layer order.
274 fn frame_for_viewport(
275 &mut self,
276 session: &EditorSession,
277 viewport: EditorViewport,
278 focused: bool,
279 frame_time_ms: u128,
280 extras: FrameExtras<'_>,
281 text_system: &mut TextSystem,
282 ) -> EditorFrame {
283 let display = display_buffer_for_edit(&session.buffer, &session.edit);
284 // Folding can reuse logical line indices from visible runs for now:
285 // hidden lines are filtered before paint/hit-test, and gutter markers
286 // carry the fold region index directly. A separate VisualLineMap is
287 // intentionally deferred until folded placeholders need independent
288 // cursor navigation or multi-viewport mappings.
289 let layout_start = Instant::now();
290 let visible =
291 if let Some(fold_regions) = extras.fold_regions.filter(|regions| !regions.is_empty()) {
292 build_visible_text_layout_filtered(
293 &display.buffer,
294 viewport,
295 session.config.style,
296 &mut self.layout_cache,
297 &mut self.metrics_cache,
298 text_system,
299 &|line| fold_regions.iter().any(|region| region.hides_line(line)),
300 )
301 } else {
302 build_visible_text_layout(
303 &display.buffer,
304 viewport,
305 session.config.style,
306 &mut self.layout_cache,
307 &mut self.metrics_cache,
308 text_system,
309 )
310 };
311 let visible_layout_us = layout_start.elapsed().as_micros().min(u128::from(u64::MAX)) as u64;
312 self.frame_id = self.frame_id.wrapping_add(1);
313 let runs = visible.runs;
314 let mut paint_items = Vec::new();
315 let style = session.config.style;
316 let active_line_index = focused
317 .then(|| active_line_index_for_caret(&runs, display.caret_byte))
318 .flatten();
319 paint_items.push(EditorPaintItem::Background {
320 rect: viewport.bounds,
321 color: style.bg,
322 });
323 if let Some((theme, line_numbers)) = extras.code_theme {
324 if let Some(gutter) = gutter_background_item(viewport, theme) {
325 paint_items.push(gutter);
326 }
327 if line_numbers {
328 paint_items.extend(line_number_items(
329 viewport,
330 &runs,
331 style,
332 theme,
333 active_line_index,
334 text_system,
335 ));
336 }
337 if let Some((_, diagnostics, _)) = extras.code_decorations {
338 paint_items.extend(diagnostic_gutter_marker_items(
339 viewport,
340 &runs,
341 diagnostics,
342 theme,
343 ));
344 }
345 if let Some(fold_regions) = extras.fold_regions {
346 paint_items.extend(fold_gutter_marker_items(
347 viewport,
348 &runs,
349 fold_regions,
350 style,
351 theme,
352 ));
353 }
354 }
355
356 let content_x = viewport.text_origin_x();
357 let content_y = viewport.text_origin_y();
358 let mut painted_paragraphs = Vec::new();
359 for run in &runs {
360 if focused && Some(run.index) == active_line_index {
361 if let Some((theme, _)) = extras.code_theme {
362 if let Some(item) = active_line_item_for_caret(
363 viewport,
364 &runs,
365 display.caret_byte,
366 style,
367 theme,
368 ) {
369 paint_items.push(item);
370 }
371 }
372 }
373
374 if let Some(selection) = session.edit.selection {
375 let (lo, hi) = selection.normalized();
376 if hi > run.byte_range.start && lo < run.byte_range.end {
377 let text_len = run.layout.text().len();
378 let lo_local = lo.saturating_sub(run.byte_range.start).min(text_len);
379 let hi_local = hi.saturating_sub(run.byte_range.start).min(text_len);
380 if hi_local > lo_local {
381 paint_items.extend(selection_items_for_run(
382 content_x, content_y, run, lo_local, hi_local, style,
383 ));
384 }
385 }
386 }
387
388 if let Some((search, diagnostics, active_diagnostic_index)) = extras.code_decorations {
389 let theme = extras
390 .code_theme
391 .map(|(theme, _)| theme)
392 .unwrap_or_default();
393 paint_items.extend(search_highlight_items_for_run(
394 content_x, content_y, run, search, style, theme,
395 ));
396 paint_items.extend(diagnostic_underline_items_for_run(
397 content_x,
398 content_y,
399 run,
400 diagnostics,
401 active_diagnostic_index,
402 style,
403 theme,
404 ));
405 }
406
407 if let Some(tokens) = extras.syntax_tokens.filter(|tokens| !tokens.is_empty()) {
408 paint_items.extend(syntax_text_items_for_run(
409 content_x,
410 content_y,
411 run,
412 tokens,
413 style,
414 extras
415 .code_theme
416 .map(|(theme, _)| theme)
417 .unwrap_or_default(),
418 text_system,
419 ));
420 } else {
421 paint_items.push(text_item(content_x, content_y, run, style));
422 }
423 if let (Some(fold_regions), Some((theme, _))) = (extras.fold_regions, extras.code_theme)
424 {
425 if let Some(item) = fold_placeholder_item_for_run(
426 content_x,
427 content_y,
428 run,
429 fold_regions,
430 style,
431 theme,
432 text_system,
433 ) {
434 paint_items.push(item);
435 }
436 }
437 painted_paragraphs.push(run.index);
438
439 if caret_visible(focused, frame_time_ms, style.caret_blink_ms)
440 && run.byte_range.start <= display.caret_byte
441 && display.caret_byte <= run.byte_range.end
442 {
443 let local = display
444 .caret_byte
445 .saturating_sub(run.byte_range.start)
446 .min(run.layout.text().len());
447 paint_items.push(caret_item(content_x, content_y, run, local, style));
448 }
449 }
450
451 if let Some(scrollbars) = extras.scrollbars.filter(|scrollbars| scrollbars.enabled) {
452 paint_items.extend(scrollbar_items_for_viewport(
453 viewport,
454 visible.content_size,
455 scrollbars,
456 ));
457 }
458
459 let paint_item_count = paint_items.len();
460 let glyph_upload_count = runs
461 .iter()
462 .map(|run| run.layout.glyphs().len())
463 .sum::<usize>();
464 let frame = EditorFrame {
465 viewport,
466 content_size: visible.content_size,
467 runs,
468 paint_items,
469 painted_paragraphs,
470 debug_metrics: EditorFrameDebugMetrics {
471 frame_id: self.frame_id,
472 visible_layout_us,
473 paragraph_cache_hits: visible.metrics_stats.hits,
474 paragraph_cache_misses: visible.metrics_stats.misses,
475 syntax_parse_us: 0,
476 symbol_index_us: 0,
477 paint_item_count,
478 glyph_upload_count,
479 used_fast_path: visible.used_fast_path,
480 },
481 };
482 self.last_frame = Some(frame.clone());
483 frame
484 }
485
486 /// Builds a fresh unfocused frame and hit-tests a screen point.
487 ///
488 /// The new frame replaces `last_frame`. The returned byte is clamped to the
489 /// source buffer and follows shaped caret geometry; points outside visible
490 /// runs fall back according to [`crate::input::hit_test::byte_at_point`].
491 ///
492 /// # Examples
493 ///
494 /// ```
495 /// use ailloli_ui_core::{Point, Rect};
496 /// use ailloli_ui_editor::{EditorEngine, EditorSession};
497 /// use ailloli_ui_text::{TextBuffer, TextSystem};
498 /// let session = EditorSession::new(TextBuffer::from_string("abc"));
499 /// let hit = EditorEngine::new().hit_test(&session, Rect::new(0.0, 0.0, 100.0, 50.0), Point::new(12.0, 12.0), &mut TextSystem::new());
500 /// assert!(hit.byte <= 3);
501 /// ```
502 pub fn hit_test(
503 &mut self,
504 session: &EditorSession,
505 bounds: Rect,
506 pos: Point,
507 text_system: &mut TextSystem,
508 ) -> EditorHitTest {
509 let frame = self.frame(session, bounds, false, text_system);
510 byte_at_point(
511 frame.viewport,
512 &frame.runs,
513 session.config.style,
514 session.buffer.len_bytes(),
515 pos,
516 )
517 }
518
519 /// Hit-tests fold markers in the retained frame.
520 ///
521 /// A cached frame is eligible when every bounds component differs by less
522 /// than `0.5` logical pixel. The first marker rectangle containing the point
523 /// returns its fold-region slice index. No frame, mismatched bounds, or no
524 /// marker returns `None`; session identity is not part of the cache key.
525 ///
526 /// # Examples
527 ///
528 /// ```
529 /// use ailloli_ui_core::{Point, Rect};
530 /// use ailloli_ui_editor::EditorEngine;
531 /// assert_eq!(EditorEngine::new().fold_region_hit_test_cached(Rect::new(0.0, 0.0, 10.0, 10.0), Point::new(1.0, 1.0)), None);
532 /// ```
533 pub fn fold_region_hit_test_cached(&self, bounds: Rect, pos: Point) -> Option<usize> {
534 let frame = self.last_frame_for_bounds(bounds)?;
535 frame.paint_items.iter().find_map(|item| match item {
536 EditorPaintItem::FoldGutterMarker {
537 rect, region_index, ..
538 } if rect.contains(pos.x, pos.y) => Some(*region_index),
539 _ => None,
540 })
541 }
542
543 /// Hit-tests text using the retained frame when bounds approximately match.
544 ///
545 /// Cache matching ignores session identity and configuration. If no eligible
546 /// frame exists, the fallback byte is exactly `session.buffer.len_bytes()`.
547 ///
548 /// # Examples
549 ///
550 /// ```
551 /// use ailloli_ui_core::{Point, Rect};
552 /// use ailloli_ui_editor::{EditorEngine, EditorSession};
553 /// use ailloli_ui_text::TextBuffer;
554 /// let session = EditorSession::new(TextBuffer::from_string("abc"));
555 /// let hit = EditorEngine::new().hit_test_cached(&session, Rect::new(0.0, 0.0, 100.0, 50.0), Point::new(0.0, 0.0));
556 /// assert_eq!(hit.byte, 3);
557 /// ```
558 pub fn hit_test_cached(
559 &self,
560 session: &EditorSession,
561 bounds: Rect,
562 pos: Point,
563 ) -> EditorHitTest {
564 let Some(frame) = self.last_frame_for_bounds(bounds) else {
565 return EditorHitTest {
566 byte: session.buffer.len_bytes(),
567 };
568 };
569 byte_at_point(
570 frame.viewport,
571 &frame.runs,
572 session.config.style,
573 session.buffer.len_bytes(),
574 pos,
575 )
576 }
577
578 /// Hit-tests gutter/text/outside zone using an approximately matching frame.
579 ///
580 /// Without one, returns [`crate::EditorHitZone::Outside`] at the source-buffer
581 /// end. Cache matching ignores session identity and configuration.
582 ///
583 /// # Examples
584 ///
585 /// ```
586 /// use ailloli_ui_core::{Point, Rect};
587 /// use ailloli_ui_editor::{EditorEngine, EditorHitZone, EditorSession};
588 /// use ailloli_ui_text::TextBuffer;
589 /// let session = EditorSession::new(TextBuffer::from_string("abc"));
590 /// let hit = EditorEngine::new().hit_test_zone_cached(&session, Rect::new(0.0, 0.0, 100.0, 50.0), Point::new(0.0, 0.0));
591 /// assert_eq!((hit.zone, hit.byte), (EditorHitZone::Outside, 3));
592 /// ```
593 pub fn hit_test_zone_cached(
594 &self,
595 session: &EditorSession,
596 bounds: Rect,
597 pos: Point,
598 ) -> EditorZoneHitTest {
599 let Some(frame) = self.last_frame_for_bounds(bounds) else {
600 return EditorZoneHitTest {
601 zone: crate::input::hit_test::EditorHitZone::Outside,
602 byte: session.buffer.len_bytes(),
603 };
604 };
605 zone_byte_at_point(
606 frame.viewport,
607 &frame.runs,
608 session.config.style,
609 session.buffer.len_bytes(),
610 pos,
611 )
612 }
613
614 /// Builds a fresh focused frame and returns display-buffer caret geometry.
615 ///
616 /// IME preedit projection is included. The new frame replaces `last_frame`;
617 /// the returned rectangle is in screen-space logical pixels.
618 ///
619 /// # Examples
620 ///
621 /// ```
622 /// use ailloli_ui_core::Rect;
623 /// use ailloli_ui_editor::{EditorEngine, EditorSession};
624 /// use ailloli_ui_text::{TextBuffer, TextSystem};
625 /// let session = EditorSession::new(TextBuffer::from_string("abc"));
626 /// let caret = EditorEngine::new().caret_rect(&session, Rect::new(0.0, 0.0, 100.0, 50.0), &mut TextSystem::new());
627 /// assert_eq!(caret.w, 1.0);
628 /// assert!(caret.h > 0.0);
629 /// ```
630 pub fn caret_rect(
631 &mut self,
632 session: &EditorSession,
633 bounds: Rect,
634 text_system: &mut TextSystem,
635 ) -> Rect {
636 let frame = self.frame(session, bounds, true, text_system);
637 let display = display_buffer_for_edit(&session.buffer, &session.edit);
638 caret_rect_for_runs(
639 frame.viewport,
640 &frame.runs,
641 display.caret_byte,
642 session.config.style,
643 )
644 }
645
646 /// Returns generic caret geometry from an approximately matching frame.
647 ///
648 /// Without a frame, returns a one-logical-pixel caret at the text origin with
649 /// height `px_size + 2`. Cache matching ignores session identity/configuration
650 /// beyond the supplied bounds.
651 ///
652 /// # Examples
653 ///
654 /// ```
655 /// use ailloli_ui_core::Rect;
656 /// use ailloli_ui_editor::{EditorEngine, EditorSession};
657 /// use ailloli_ui_text::TextBuffer;
658 /// let session = EditorSession::new(TextBuffer::new());
659 /// let caret = EditorEngine::new().caret_rect_cached(&session, Rect::new(0.0, 0.0, 100.0, 50.0));
660 /// assert_eq!((caret.x, caret.y, caret.w, caret.h), (10.0, 10.0, 1.0, 15.0));
661 /// ```
662 pub fn caret_rect_cached(&self, session: &EditorSession, bounds: Rect) -> Rect {
663 let viewport = crate::EditorViewport::new(bounds, session.config, &session.edit);
664 let Some(frame) = self.last_frame_for_bounds(bounds) else {
665 return Rect::new(
666 viewport.text_origin_x(),
667 viewport.text_origin_y(),
668 crate::input::caret::EDITOR_CARET_WIDTH,
669 session.config.style.px_size as f32 + 2.0,
670 );
671 };
672 let display = display_buffer_for_edit(&session.buffer, &session.edit);
673 caret_rect_for_runs(
674 frame.viewport,
675 &frame.runs,
676 display.caret_byte,
677 session.config.style,
678 )
679 }
680
681 /// Returns code-editor caret geometry from an approximately matching frame.
682 ///
683 /// The no-frame fallback accounts for the configured gutter and otherwise
684 /// uses the generic one-pixel, `px_size + 2` caret. Cached frame matching uses
685 /// only approximate bounds, not session identity.
686 ///
687 /// # Examples
688 ///
689 /// ```
690 /// use ailloli_ui_core::Rect;
691 /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId, EditorEngine};
692 /// use ailloli_ui_text::TextBuffer;
693 /// let session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::new()), CodeEditorConfig::default());
694 /// let caret = EditorEngine::new().code_caret_rect_cached(&session, Rect::new(0.0, 0.0, 100.0, 50.0));
695 /// assert_eq!((caret.x, caret.y, caret.w, caret.h), (58.0, 10.0, 1.0, 15.0));
696 /// ```
697 pub fn code_caret_rect_cached(&self, session: &CodeEditorSession, bounds: Rect) -> Rect {
698 let viewport = crate::EditorViewport::with_gutter(
699 bounds,
700 session.editor.config,
701 &session.editor.edit,
702 Some(session.config.gutter),
703 );
704 let Some(frame) = self.last_frame_for_bounds(bounds) else {
705 return Rect::new(
706 viewport.text_origin_x(),
707 viewport.text_origin_y(),
708 crate::input::caret::EDITOR_CARET_WIDTH,
709 session.editor.config.style.px_size as f32 + 2.0,
710 );
711 };
712 let display = display_buffer_for_edit(&session.editor.buffer, &session.editor.edit);
713 caret_rect_for_runs(
714 frame.viewport,
715 &frame.runs,
716 display.caret_byte,
717 session.editor.config.style,
718 )
719 }
720
721 /// Returns generic text-viewport/content scroll metrics from the cached frame.
722 ///
723 /// With no approximately matching frame, content equals viewport and the
724 /// maximum offset is zero. The viewport excludes editor padding.
725 ///
726 /// # Examples
727 ///
728 /// ```
729 /// use ailloli_ui_core::{Offset, Rect, Size};
730 /// use ailloli_ui_editor::{EditorEngine, EditorSession};
731 /// use ailloli_ui_text::TextBuffer;
732 /// let session = EditorSession::new(TextBuffer::new());
733 /// let metrics = EditorEngine::new().scroll_metrics_cached(&session, Rect::new(0.0, 0.0, 100.0, 50.0));
734 /// assert_eq!(metrics.viewport, Size::new(80.0, 30.0));
735 /// assert_eq!(metrics.max_offset(), Offset::new(0.0, 0.0));
736 /// ```
737 pub fn scroll_metrics_cached(&self, session: &EditorSession, bounds: Rect) -> ScrollMetrics {
738 let viewport = crate::EditorViewport::new(bounds, session.config, &session.edit);
739 let viewport_size = Size::new(viewport.text_rect.w, viewport.text_rect.h);
740 let content_size = self
741 .last_frame_for_bounds(bounds)
742 .map(|frame| frame.content_size)
743 .unwrap_or(viewport_size);
744 ScrollMetrics::new(viewport_size, content_size)
745 }
746
747 /// Returns code text-viewport/content metrics from the cached frame.
748 ///
749 /// The viewport excludes both padding and the configured gutter. With no
750 /// approximately matching frame, content equals viewport.
751 ///
752 /// # Examples
753 ///
754 /// ```
755 /// use ailloli_ui_core::{Rect, Size};
756 /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId, EditorEngine};
757 /// use ailloli_ui_text::TextBuffer;
758 /// let session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::new()), CodeEditorConfig::default());
759 /// let metrics = EditorEngine::new().code_scroll_metrics_cached(&session, Rect::new(0.0, 0.0, 100.0, 50.0));
760 /// assert_eq!(metrics.viewport, Size::new(32.0, 30.0));
761 /// assert_eq!(metrics.content, metrics.viewport);
762 /// ```
763 pub fn code_scroll_metrics_cached(
764 &self,
765 session: &CodeEditorSession,
766 bounds: Rect,
767 ) -> ScrollMetrics {
768 let viewport = crate::EditorViewport::with_gutter(
769 bounds,
770 session.editor.config,
771 &session.editor.edit,
772 Some(session.config.gutter),
773 );
774 let viewport_size = Size::new(viewport.text_rect.w, viewport.text_rect.h);
775 let content_size = self
776 .last_frame_for_bounds(bounds)
777 .map(|frame| frame.content_size)
778 .unwrap_or(viewport_size);
779 ScrollMetrics::new(viewport_size, content_size)
780 }
781
782 /// Returns the retained frame when bounds components differ by under 0.5.
783 fn last_frame_for_bounds(&self, bounds: Rect) -> Option<&EditorFrame> {
784 let frame = self.last_frame.as_ref()?;
785 if same_rect(frame.viewport.bounds, bounds) {
786 Some(frame)
787 } else {
788 None
789 }
790 }
791}
792
793/// Compares rectangle components with a strict half-logical-pixel tolerance.
794fn same_rect(a: Rect, b: Rect) -> bool {
795 (a.x - b.x).abs() < 0.5
796 && (a.y - b.y).abs() < 0.5
797 && (a.w - b.w).abs() < 0.5
798 && (a.h - b.h).abs() < 0.5
799}
800
801/// Resolves the code editor's visible scrollbar geometry.
802///
803/// The returned rectangles are the single source of truth used by adapters for
804/// paint and pointer interaction. Horizontal geometry is omitted while wrapping
805/// is enabled, and either axis is omitted when it has no usable overflow.
806///
807/// # Examples
808///
809/// ```
810/// use ailloli_ui_core::{Rect, Size, ScrollbarAxis};
811/// use ailloli_ui_editor::{code_scrollbar_geometries, CodeEditorConfig, EditorViewport};
812/// use ailloli_ui_text::TextEditState;
813/// let config = CodeEditorConfig::default();
814/// let viewport = EditorViewport::with_gutter(
815/// Rect::new(0.0, 0.0, 240.0, 120.0),
816/// config.editor,
817/// &TextEditState::new(),
818/// Some(config.gutter),
819/// );
820/// let bars = code_scrollbar_geometries(viewport, Size::new(600.0, 400.0), config.scrollbars);
821/// assert!(bars.iter().any(|bar| bar.axis == ScrollbarAxis::Horizontal));
822/// assert!(bars.iter().any(|bar| bar.axis == ScrollbarAxis::Vertical));
823/// ```
824pub fn code_scrollbar_geometries(
825 viewport: EditorViewport,
826 content_size: Size,
827 config: EditorScrollbarConfig,
828) -> Vec<ScrollbarGeometry> {
829 if !config.enabled {
830 return Vec::new();
831 }
832 let metrics = ScrollMetrics::new(
833 Size::new(viewport.text_rect.w, viewport.text_rect.h),
834 content_size,
835 );
836 let max_offset = metrics.max_offset();
837 let show_vertical = max_offset.y > 0.5;
838 let show_horizontal = viewport.wrap_mode == EditorWrapMode::NoWrap && max_offset.x > 0.5;
839 let state = ScrollState::with_offset(Offset::new(viewport.scroll_x, viewport.scroll_y));
840 let style = config.style;
841 let mut geometries = Vec::with_capacity(2);
842
843 if show_vertical {
844 let bottom_reserve = if show_horizontal {
845 style.thickness + style.inset
846 } else {
847 0.0
848 };
849 if let Some(geometry) =
850 ScrollbarGeometrySpec::new(ScrollbarAxis::Vertical, viewport.text_rect, metrics, state)
851 .with_paint_metrics(style.thickness, style.min_thumb_len, style.inset)
852 .with_end_reserve(bottom_reserve)
853 .resolve()
854 {
855 geometries.push(geometry);
856 }
857 }
858
859 if show_horizontal {
860 let right_reserve = if show_vertical {
861 style.thickness + style.inset
862 } else {
863 0.0
864 };
865 if let Some(geometry) = ScrollbarGeometrySpec::new(
866 ScrollbarAxis::Horizontal,
867 viewport.text_rect,
868 metrics,
869 state,
870 )
871 .with_paint_metrics(style.thickness, style.min_thumb_len, style.inset)
872 .with_end_reserve(right_reserve)
873 .resolve()
874 {
875 geometries.push(geometry);
876 }
877 }
878
879 geometries
880}
881
882/// Builds vertical then horizontal scrollbar items for overflowing axes.
883fn scrollbar_items_for_viewport(
884 viewport: EditorViewport,
885 content_size: Size,
886 config: EditorScrollbarConfig,
887) -> Vec<EditorPaintItem> {
888 code_scrollbar_geometries(viewport, content_size, config)
889 .into_iter()
890 .map(|geometry| scrollbar_item(geometry.track, geometry.thumb, config.style))
891 .collect()
892}
893
894/// Converts resolved scrollbar geometry into a neutral paint item.
895fn scrollbar_item(
896 track_rect: Rect,
897 thumb_rect: Rect,
898 style: EditorScrollbarStyle,
899) -> EditorPaintItem {
900 EditorPaintItem::Scrollbar {
901 track_rect,
902 thumb_rect,
903 track_color: style.track_color,
904 thumb_color: style.thumb_color,
905 radius: style.radius,
906 }
907}