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}