ailloli_ui_editor/paint/active_line_painter.rs
1//! Active caret-line fill and clipped focus-ring paint construction.
2
3use ailloli_ui_core::Rect;
4
5use crate::layout::first_layout_baseline;
6use crate::layout::EditorTextRun;
7use crate::paint::EditorPaintItem;
8use crate::{CodeTheme, EditorStyle, EditorViewport};
9
10/// Logical-pixel gap between an active-line fill and its ring.
11const ACTIVE_LINE_RING_OFFSET: f32 = 1.0;
12/// Active-line ring stroke width in logical pixels.
13const ACTIVE_LINE_RING_WIDTH: f32 = 1.0;
14
15/// Builds the active-line fill and ring for a visible caret.
16///
17/// Returns `None` when no run contains the caret or the caret's visual line is
18/// fully clipped outside the text viewport. Run containment includes both byte
19/// endpoints, and the first overlapping run wins.
20///
21/// # Examples
22///
23/// ```
24/// use ailloli_ui_core::{Color, FontId, Rect, TextStyle};
25/// use ailloli_ui_editor::{layout::EditorTextRun, paint::active_line_painter::active_line_item_for_caret, CodeTheme, EditorConfig, EditorStyle, EditorViewport};
26/// use ailloli_ui_text::{TextEditState, TextLayoutParams, TextSystem};
27/// let mut system = TextSystem::new();
28/// let layout = system.layout_cached(TextLayoutParams::new("abc", TextStyle::new(FontId::Mono, 13, Color::WHITE)));
29/// let run = EditorTextRun { index: 0, byte_range: 0..3, baseline_y: 12.0, layout };
30/// let viewport = EditorViewport::new(Rect::new(0.0, 0.0, 100.0, 50.0), EditorConfig::default(), &TextEditState::new());
31/// assert!(active_line_item_for_caret(viewport, &[run], 1, EditorStyle::default(), CodeTheme::default()).is_some());
32/// ```
33pub fn active_line_item_for_caret(
34 viewport: EditorViewport,
35 runs: &[EditorTextRun],
36 caret_byte: usize,
37 style: EditorStyle,
38 theme: CodeTheme,
39) -> Option<EditorPaintItem> {
40 let run = active_line_run(runs, caret_byte)?;
41 let fill_rect = active_line_fill_rect(viewport, run, caret_byte, style)?;
42 let ring_rect = active_line_ring_rect(viewport, fill_rect)?;
43 Some(EditorPaintItem::ActiveLine {
44 fill_rect,
45 ring_rect,
46 fill: theme.active_line_bg,
47 ring: theme.active_line_ring,
48 })
49}
50
51/// Returns the logical paragraph index containing a visible caret byte.
52///
53/// Both range endpoints count as contained; overlapping ranges are resolved by
54/// input order. An absent run returns `None`.
55///
56/// # Examples
57///
58/// ```
59/// use ailloli_ui_core::{Color, FontId, TextStyle};
60/// use ailloli_ui_editor::{layout::EditorTextRun, paint::active_line_painter::active_line_index_for_caret};
61/// use ailloli_ui_text::{TextLayoutParams, TextSystem};
62/// let mut system = TextSystem::new();
63/// let layout = system.layout_cached(TextLayoutParams::new("abc", TextStyle::new(FontId::Mono, 13, Color::WHITE)));
64/// let run = EditorTextRun { index: 4, byte_range: 10..13, baseline_y: 12.0, layout };
65/// assert_eq!(active_line_index_for_caret(&[run], 13), Some(4));
66/// ```
67pub fn active_line_index_for_caret(runs: &[EditorTextRun], caret_byte: usize) -> Option<usize> {
68 active_line_run(runs, caret_byte).map(|run| run.index)
69}
70
71/// Finds the first run whose inclusive source range contains the caret.
72fn active_line_run(runs: &[EditorTextRun], caret_byte: usize) -> Option<&EditorTextRun> {
73 runs.iter()
74 .find(|run| run.byte_range.start <= caret_byte && caret_byte <= run.byte_range.end)
75}
76
77/// Computes and vertically clips the fill rectangle for the caret's visual line.
78fn active_line_fill_rect(
79 viewport: EditorViewport,
80 run: &EditorTextRun,
81 caret_byte: usize,
82 style: EditorStyle,
83) -> Option<Rect> {
84 let text_rect = viewport.text_rect;
85 let local = caret_byte
86 .saturating_sub(run.byte_range.start)
87 .min(run.layout.text().len());
88 let caret = run.layout.caret_rect_at(local, 0.0);
89 let text_origin_y = run.baseline_y - first_layout_baseline(&run.layout);
90 let top = viewport.text_origin_y() + text_origin_y + caret.y;
91 let height = caret.h.max(style.px_size as f32 + 2.0);
92 let y0 = top.max(text_rect.y);
93 let y1 = (top + height).min(text_rect.y + text_rect.h);
94 (y1 > y0).then(|| {
95 Rect::new(
96 text_rect.x,
97 y0.round(),
98 text_rect.w,
99 (y1 - y0).round().max(1.0),
100 )
101 })
102}
103
104/// Expands and clips an active-line fill to form its ring rectangle.
105fn active_line_ring_rect(viewport: EditorViewport, fill_rect: Rect) -> Option<Rect> {
106 let text_rect = viewport.text_rect;
107 let outset = ACTIVE_LINE_RING_OFFSET + ACTIVE_LINE_RING_WIDTH;
108 let x0 = (fill_rect.x - outset).max(text_rect.x);
109 let y0 = (fill_rect.y - outset).max(text_rect.y);
110 let x1 = (fill_rect.x + fill_rect.w + outset).min(text_rect.x + text_rect.w);
111 let y1 = (fill_rect.y + fill_rect.h + outset).min(text_rect.y + text_rect.h);
112 (x1 > x0 && y1 > y0).then(|| Rect::new(x0, y0, x1 - x0, y1 - y0))
113}