ailloli_ui_editor/paint/
gutter_painter.rs

1//! Code gutter backgrounds, line numbers, folds, and diagnostic markers.
2
3use ailloli_ui_core::{Rect, TextStyle};
4use ailloli_ui_text::{TextLayoutParams, TextSystem, WrapMode};
5
6use crate::code::FoldRegion;
7use crate::code::{Diagnostic, DiagnosticSeverity};
8use crate::layout::EditorTextRun;
9use crate::paint::code_decorations_painter::diagnostic_color;
10use crate::paint::EditorPaintItem;
11use crate::{CodeTheme, EditorStyle, EditorViewport};
12
13/// Fold-marker hit-box side length in logical pixels.
14const FOLD_MARKER_HIT_SIZE: f32 = 14.0;
15/// Fold-marker inset from the gutter's right edge in logical pixels.
16const FOLD_MARKER_RIGHT_PAD: f32 = 3.0;
17/// Maximum horizontal gutter space reserved beside line numbers.
18const FOLD_LINE_NUMBER_RESERVE: f32 = 22.0;
19/// Fold-guide thickness in logical pixels.
20const FOLD_GUIDE_WIDTH: f32 = 1.0;
21
22/// Builds the gutter background item when a viewport has a gutter.
23///
24/// # Examples
25///
26/// ```
27/// use ailloli_ui_core::Rect;
28/// use ailloli_ui_editor::{paint::gutter_painter::gutter_background_item, CodeEditorConfig, CodeTheme, EditorConfig, EditorViewport};
29/// use ailloli_ui_text::TextEditState;
30/// let viewport = EditorViewport::with_gutter(Rect::new(0.0, 0.0, 120.0, 60.0), EditorConfig::default(), &TextEditState::new(), Some(CodeEditorConfig::default().gutter));
31/// assert!(gutter_background_item(viewport, CodeTheme::default()).is_some());
32/// ```
33pub fn gutter_background_item(
34    viewport: EditorViewport,
35    theme: CodeTheme,
36) -> Option<EditorPaintItem> {
37    Some(EditorPaintItem::GutterBackground {
38        rect: viewport.gutter_rect?,
39        color: theme.gutter_bg,
40    })
41}
42
43/// Builds one guide and one marker for each visible fold header.
44///
45/// Empty/reversed fold ranges are skipped. Marker `region_index` refers to the
46/// input slice, color reflects collapsed state, and geometry is not additionally
47/// clipped to viewport bounds. Without a gutter, the result is empty.
48///
49/// # Examples
50///
51/// ```
52/// use ailloli_ui_core::{Color, FontId, Rect, TextStyle};
53/// use ailloli_ui_editor::{layout::EditorTextRun, paint::gutter_painter::fold_gutter_marker_items, CodeEditorConfig, CodeTheme, EditorConfig, EditorStyle, EditorViewport, FoldRegion};
54/// use ailloli_ui_text::{TextEditState, TextLayoutParams, TextSystem};
55/// let mut system = TextSystem::new();
56/// let layout = system.layout_cached(TextLayoutParams::new("header", TextStyle::new(FontId::Mono, 13, Color::WHITE)));
57/// let run = EditorTextRun { index: 0, byte_range: 0..6, baseline_y: 12.0, layout };
58/// let viewport = EditorViewport::with_gutter(Rect::new(0.0, 0.0, 120.0, 60.0), EditorConfig::default(), &TextEditState::new(), Some(CodeEditorConfig::default().gutter));
59/// assert_eq!(fold_gutter_marker_items(viewport, &[run], &[FoldRegion::new(0, 2)], EditorStyle::default(), CodeTheme::default()).len(), 2);
60/// ```
61pub fn fold_gutter_marker_items(
62    viewport: EditorViewport,
63    runs: &[EditorTextRun],
64    fold_regions: &[FoldRegion],
65    style: EditorStyle,
66    theme: CodeTheme,
67) -> Vec<EditorPaintItem> {
68    let Some(gutter_rect) = viewport.gutter_rect else {
69        return Vec::new();
70    };
71    let mut items = Vec::new();
72    for run in runs {
73        for (idx, region) in fold_regions.iter().enumerate() {
74            if region.start_line != run.index || region.end_line <= region.start_line {
75                continue;
76            }
77            let marker_x =
78                gutter_rect.x + gutter_rect.w - FOLD_MARKER_HIT_SIZE - FOLD_MARKER_RIGHT_PAD;
79            let marker_y = viewport.text_origin_y() + run.baseline_y - FOLD_MARKER_HIT_SIZE * 0.5;
80            let guide_h = ((region.end_line.saturating_sub(region.start_line)) as f32
81                * style.line_height.max(1.0))
82            .max(FOLD_MARKER_HIT_SIZE);
83            items.push(EditorPaintItem::FoldGutterGuide {
84                rect: Rect::new(
85                    marker_x + FOLD_MARKER_HIT_SIZE * 0.5 - FOLD_GUIDE_WIDTH * 0.5,
86                    marker_y + FOLD_MARKER_HIT_SIZE + 1.0,
87                    FOLD_GUIDE_WIDTH,
88                    (guide_h - FOLD_MARKER_HIT_SIZE).max(0.0),
89                ),
90                color: theme.fold_guide,
91            });
92            items.push(EditorPaintItem::FoldGutterMarker {
93                rect: Rect::new(
94                    marker_x,
95                    marker_y,
96                    FOLD_MARKER_HIT_SIZE,
97                    FOLD_MARKER_HIT_SIZE,
98                ),
99                color: if region.collapsed {
100                    theme.fold_marker_active
101                } else {
102                    theme.fold_marker
103                },
104                region_index: idx,
105                collapsed: region.collapsed,
106            });
107        }
108    }
109    items
110}
111
112/// Builds shaped, one-based line numbers for visible runs.
113///
114/// Numbers are right-aligned before a fold-marker reserve and clamped to the
115/// gutter's left edge. The active logical index uses the active theme color.
116/// Without a gutter, the result is empty.
117///
118/// # Panics
119///
120/// Panics in overflow-checking builds if a run index is [`usize::MAX`].
121///
122/// # Examples
123///
124/// ```
125/// use ailloli_ui_core::{Color, FontId, Rect, TextStyle};
126/// use ailloli_ui_editor::{layout::EditorTextRun, paint::gutter_painter::line_number_items, CodeEditorConfig, CodeTheme, EditorConfig, EditorStyle, EditorViewport};
127/// use ailloli_ui_text::{TextEditState, TextLayoutParams, TextSystem};
128/// let mut system = TextSystem::new();
129/// let layout = system.layout_cached(TextLayoutParams::new("x", TextStyle::new(FontId::Mono, 13, Color::WHITE)));
130/// let run = EditorTextRun { index: 0, byte_range: 0..1, baseline_y: 12.0, layout };
131/// let viewport = EditorViewport::with_gutter(Rect::new(0.0, 0.0, 120.0, 60.0), EditorConfig::default(), &TextEditState::new(), Some(CodeEditorConfig::default().gutter));
132/// assert_eq!(line_number_items(viewport, &[run], EditorStyle::default(), CodeTheme::default(), Some(0), &mut system).len(), 1);
133/// ```
134pub fn line_number_items(
135    viewport: EditorViewport,
136    runs: &[EditorTextRun],
137    style: EditorStyle,
138    theme: CodeTheme,
139    active_line_index: Option<usize>,
140    text_system: &mut TextSystem,
141) -> Vec<EditorPaintItem> {
142    let Some(gutter_rect) = viewport.gutter_rect else {
143        return Vec::new();
144    };
145    let text_style = TextStyle::new(style.font, style.px_size, theme.line_number);
146    let mut items = Vec::with_capacity(runs.len());
147    for run in runs {
148        let label = (run.index + 1).to_string();
149        let color = if Some(run.index) == active_line_index {
150            theme.active_line_number
151        } else {
152            theme.line_number
153        };
154        let layout = text_system.layout_cached(TextLayoutParams {
155            text: &label,
156            style: text_style,
157            max_width: None,
158            wrap_mode: WrapMode::NoWrap,
159        });
160        let reserve = FOLD_LINE_NUMBER_RESERVE.min((gutter_rect.w * 0.5).max(0.0));
161        let x = (gutter_rect.x + gutter_rect.w - reserve - layout.width() - 4.0).max(gutter_rect.x);
162        items.push(EditorPaintItem::LineNumber {
163            pos: [x.round(), viewport.text_origin_y() + run.baseline_y],
164            color,
165            layout,
166        });
167    }
168    items
169}
170
171/// Builds one gutter marker per visible run having diagnostics.
172///
173/// Half-open diagnostic ranges are intersected with source run ranges. When
174/// several diagnostics overlap a run, the strongest severity wins in the order
175/// error, warning, info, hint. Without a gutter, the result is empty.
176///
177/// # Examples
178///
179/// ```
180/// use ailloli_ui_core::{Color, FontId, Rect, TextStyle};
181/// use ailloli_ui_editor::{layout::EditorTextRun, paint::gutter_painter::diagnostic_gutter_marker_items, CodeEditorConfig, CodeTheme, Diagnostic, DiagnosticSeverity, EditorConfig, EditorViewport};
182/// use ailloli_ui_text::{TextEditState, TextLayoutParams, TextSystem};
183/// let mut system = TextSystem::new();
184/// let layout = system.layout_cached(TextLayoutParams::new("abc", TextStyle::new(FontId::Mono, 13, Color::WHITE)));
185/// let run = EditorTextRun { index: 0, byte_range: 0..3, baseline_y: 12.0, layout };
186/// let viewport = EditorViewport::with_gutter(Rect::new(0.0, 0.0, 120.0, 60.0), EditorConfig::default(), &TextEditState::new(), Some(CodeEditorConfig::default().gutter));
187/// let diagnostics = [Diagnostic::new(1..2, DiagnosticSeverity::Hint, "hint")];
188/// assert_eq!(diagnostic_gutter_marker_items(viewport, &[run], &diagnostics, CodeTheme::default()).len(), 1);
189/// ```
190pub fn diagnostic_gutter_marker_items(
191    viewport: EditorViewport,
192    runs: &[EditorTextRun],
193    diagnostics: &[Diagnostic],
194    theme: CodeTheme,
195) -> Vec<EditorPaintItem> {
196    let Some(gutter_rect) = viewport.gutter_rect else {
197        return Vec::new();
198    };
199    let mut items = Vec::new();
200    for run in runs {
201        let Some(severity) = strongest_diagnostic_for_run(run, diagnostics) else {
202            continue;
203        };
204        let y = viewport.text_origin_y() + run.baseline_y - 8.0;
205        items.push(EditorPaintItem::DiagnosticGutterMarker {
206            rect: Rect::new(gutter_rect.x + 6.0, y, 4.0, 10.0),
207            color: diagnostic_color(theme, severity),
208        });
209    }
210    items
211}
212
213/// Returns the strongest diagnostic severity intersecting a run.
214fn strongest_diagnostic_for_run(
215    run: &EditorTextRun,
216    diagnostics: &[Diagnostic],
217) -> Option<DiagnosticSeverity> {
218    diagnostics
219        .iter()
220        .filter(|diagnostic| {
221            diagnostic.range.end > run.byte_range.start
222                && diagnostic.range.start < run.byte_range.end
223        })
224        .map(|diagnostic| diagnostic.severity)
225        .min_by_key(|severity| diagnostic_severity_rank(*severity))
226}
227
228/// Maps diagnostic severity to ascending strength rank.
229fn diagnostic_severity_rank(severity: DiagnosticSeverity) -> u8 {
230    match severity {
231        DiagnosticSeverity::Error => 0,
232        DiagnosticSeverity::Warning => 1,
233        DiagnosticSeverity::Info => 2,
234        DiagnosticSeverity::Hint => 3,
235    }
236}