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}