1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT
//! Diagnostic underlines and messages.
//!
//! Split out of `render.rs` (principle #9). The entry point stays in the
//! parent; these are the paint helpers it calls.
use super::*;
impl CodeEditor {
/// Paints the diagnostic underlines and, on the caret's line only, the message.
///
/// # Why the message moved
///
/// The label used to be drawn at `row_y + row_height`, i.e. **on the next
/// line's text** — the demo screenshot showed `warning: deep recursion` sitting
/// on top of `let limit = 10;`. Messages now go at the end of the offending
/// line, on that line's own baseline, and only when they fit before the
/// scrollbar; otherwise the underline alone states the fact and the message is
/// not crammed in. Restricting the message to the caret's line keeps a file
/// with a thousand warnings from painting a thousand overlapping labels.
pub(super) fn draw_diagnostics(
&self,
context: &mut RenderContext,
visible: &[usize],
chrome: &EditorChrome,
) {
let inline = self.inline_diagnostics();
if inline.is_empty() {
return;
}
let row_height = self.line_height();
let rect = self.geometry();
let left = rect.x + self.text_origin_x();
let right = rect.x + rect.width as i32 - self.scrollbar_width() - self.minimap_width();
let map = self.coordinate_map();
let cell = self.cell_width();
let active_line = self.cursor.head.line;
let font = self.chrome_font();
let label_height = context.measure_text("M", font).height as i32;
let wrapped = self.config.word_wrap;
let columns = self.text_columns().max(1);
for (_, marker) in inline {
let line = marker.line;
// `visible` is in paint order (top to bottom), so membership is a
// binary search rather than a scan of every painted row per marker.
if visible.binary_search(&line).is_err() {
continue;
}
let text = self.line_text(line).unwrap_or_default();
let grid = LineGrid::new(&text, self.config.tab_width);
// The marker may sit on a wrapped continuation row. The underline belongs
// on the row that actually shows those characters, and its x is measured
// from that row's first column — the same rule the caret and the text use,
// or the underline is drawn a segment away from the code it names.
let (segment, segment_start) =
if wrapped { grid.segment_of_char(marker.start_column(), columns) } else { (0, 0) };
let row = self.line_to_visual_row(line) + segment;
if row < self.scroll_visual_row {
continue;
}
let (segment_end, indent) = if wrapped {
(
grid.segment_range(segment, columns).1,
if segment > 0 {
(grid.leading_indent_columns() as f32 * cell)
.min(columns.saturating_sub(4) as f32 * cell)
} else {
0.0
},
)
} else {
(grid.char_len(), 0.0)
};
let from_index =
grid.column_of_char(marker.start_column().max(segment_start).min(segment_end));
let to_index =
grid.column_of_char(marker.finish_column().min(segment_end)).max(from_index + 1);
let first_column = grid.column_of_char(segment_start);
// Measured where available, so the underline sits under the glyphs it
// names rather than under the grid cells they would have occupied. Wrapped
// rows are not measured per character, so they use the row's grid columns.
let x_of = |index: usize| -> f32 {
if wrapped {
indent + grid.column_of_char(index).saturating_sub(first_column) as f32 * cell
} else {
self.cached_x(line, index)
.unwrap_or_else(|| grid.column_of_char(index) as f32 * cell)
}
};
let y = map.row_y(row);
let x = self.screen_x(left, x_of(from_index));
let width = (x_of(to_index) - x_of(from_index)).round().max(1.0) as u32;
// A 2 px rule along the row's bottom edge: a squiggle would need a
// per-character wave that costs more than it tells the reader.
let underline_y = y + row_height.round() as i32 - 2;
context.draw_line(
Point::new(x, underline_y),
Point::new(x + width as i32, underline_y),
marker.severity.color(),
);
// The full message for the hovered line. The caret's line keeps its inline
// label below; every other line gets its message on hover, so no diagnostic is
// readable only by moving the caret (ED-03).
if self.hovered_diagnostic == Some(line) {
let hover = format!("{} {}", marker.severity.badge(), marker.message);
let available = (right - left).max(40) as u32;
let text_width = context.measure_text(&hover, font).width as i32;
let bubble_width = (text_width.min(available as i32 - 16).max(24) + 16) as u32;
let bubble_height = (label_height.max(12) + 12) as u32;
let bubble_x = x.min(right - bubble_width as i32).max(left);
let above = y - bubble_height as i32 - 4;
let bubble_y = if above >= rect.y + self.text_origin_y() {
above
} else {
y + row_height.round() as i32 + 2
};
let bubble = Rect::new(bubble_x, bubble_y, bubble_width, bubble_height);
context.fill_rect(bubble, chrome.chrome_background);
context.draw_rect(bubble, marker.severity.color());
context.draw_text_fitted_cow(
Rect::new(
bubble.x + 8,
bubble.y + 6,
(bubble.width.saturating_sub(16)).max(1),
label_height as u32,
),
&hover,
font,
marker.severity.color().legible_on(chrome.chrome_background, 4.5),
HorizontalAlignment::Left,
);
}
if line != active_line {
continue;
}
let label = format!("{} {}", marker.severity.badge(), marker.message);
let label_width = context.measure_text(&label, font).width as i32;
// After the underlined text, not after the whole line: on a long or wrapped
// line the end of the line is off screen, so the message would never be
// shown; anchored to the marker it is always beside what it describes.
let label_x = self.screen_x(left, x_of(to_index)) + 12;
if label_x + label_width <= right {
context.draw_text(
Point::new(label_x, y + (row_height as i32 - label_height) / 2),
&label,
font,
marker.severity.color().legible_on(chrome.surface, 4.5),
HorizontalAlignment::Left,
);
}
}
}
}