Skip to main content

qframe/widgets/code_view/
mod.rs

1//! Highlighted code.
2
3use std::ops::{Bound, RangeBounds};
4
5use unicode_segmentation::UnicodeSegmentation;
6
7use super::cells;
8use super::highlight::{Language, Token, highlight};
9use crate::event::Event;
10use crate::geometry::{Rect, Size, clamp_u16};
11use crate::keymap::Key;
12use crate::style::CellStyle;
13use crate::text;
14use crate::theme::State;
15use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
16
17/// One visual row of code: a line number on the first row of a source line, and coloured pieces.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub(crate) struct CodeRow {
20    /// The source line the row shows part of, counted from 1.
21    pub(crate) line: usize,
22    pub(crate) number: Option<usize>,
23    pub(crate) pieces: Vec<(String, Token)>,
24}
25
26/// Lays `code` out in rows no wider than `width` cells, wrapping long lines. Continuation
27/// rows are indented by two cells.
28pub(crate) fn code_rows(code: &str, language: Language, width: u16) -> Vec<CodeRow> {
29    let tokens = highlight(code, language);
30    let mut rows = Vec::new();
31    let mut line_start = 0;
32    for (index, line) in code.split('\n').enumerate() {
33        let line_end = line_start + line.len();
34        let mut row = CodeRow { line: index + 1, number: Some(index + 1), pieces: Vec::new() };
35        let mut used = 0u16;
36        for (range, token) in &tokens {
37            let start = range.start.max(line_start);
38            let end = range.end.min(line_end);
39            if start >= end {
40                continue;
41            }
42            for grapheme in code[start..end].graphemes(true) {
43                let cell = if grapheme == "\t" { "    " } else { grapheme };
44                let w = text::width(cell);
45                if used.saturating_add(w) > width && used > 0 {
46                    rows.push(std::mem::replace(
47                        &mut row,
48                        CodeRow { line: index + 1, number: None, pieces: vec![("  ".to_owned(), Token::Plain)] },
49                    ));
50                    used = 2;
51                }
52                match row.pieces.last_mut() {
53                    Some((piece, last)) if last == token => piece.push_str(cell),
54                    _ => row.pieces.push((cell.to_owned(), *token)),
55                }
56                used = used.saturating_add(w);
57            }
58        }
59        rows.push(row);
60        line_start = line_end + 1;
61    }
62    if code.ends_with('\n') {
63        rows.pop();
64    }
65    rows
66}
67
68/// Paints `rows` in `area` using the `code-token.<kind>` and `code-line-number` styles.
69pub(crate) fn paint_rows(cx: &mut PaintCx<'_>, area: Rect, rows: &[CodeRow], gutter: u16) {
70    for (y, row) in rows.iter().enumerate() {
71        let Ok(y) = u16::try_from(y) else { break };
72        if y >= area.height {
73            break;
74        }
75        let row_y = area.y + i32::from(y);
76        // Line numbers help reading, not copying: clean copies leave the gutter out.
77        if gutter > 0 {
78            cx.decoration(Rect::new(area.x, row_y, gutter, 1));
79        }
80        if gutter > 0
81            && let Some(number) = row.number
82        {
83            let style = cx.style("code-line-number", None, &[]).text();
84            let label = format!("{number:>width$}", width = usize::from(gutter - 2));
85            cx.text(area.x, row_y, &label, style, gutter);
86        }
87        let mut x = area.x + i32::from(gutter);
88        for (piece, token) in &row.pieces {
89            let style = cx.style("code-token", Some(token.variant()), &[]).text();
90            x += i32::from(cx.text(x, row_y, piece, style, area.right().saturating_sub(x).try_into().unwrap_or(0)));
91        }
92    }
93}
94
95/// Marks the padding of a code block at `rect` around `inner` as decoration, so clean copies of
96/// a selection across the block keep only the code.
97pub(crate) fn padding_decoration(cx: &mut PaintCx<'_>, rect: Rect, inner: Rect) {
98    cx.decoration(Rect::new(rect.x, rect.y, rect.width, clamp_u16(inner.y - rect.y)));
99    cx.decoration(Rect::new(rect.x, inner.bottom(), rect.width, clamp_u16(rect.bottom() - inner.bottom())));
100    cx.decoration(Rect::new(rect.x, inner.y, clamp_u16(inner.x - rect.x), inner.height));
101    cx.decoration(Rect::new(inner.right(), inner.y, clamp_u16(rect.right() - inner.right()), inner.height));
102}
103
104/// Width of the line number column for `code`, including two cells of spacing.
105pub(crate) fn gutter_width(code: &str) -> u16 {
106    let lines = code.split('\n').count();
107    text::width(&lines.to_string()).saturating_add(2)
108}
109
110/// How a line of a diff changed; see [`CodeView::line_marks`].
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
112pub enum LineMark {
113    /// The line is in both versions; drawn as any other line.
114    #[default]
115    Unchanged,
116    /// The line is new: a success tint and a `+` sign.
117    Added,
118    /// The line is gone: a danger tint and a `−` sign.
119    Removed,
120}
121
122/// The tone of highlighted lines; see [`CodeView::highlight_lines`].
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
124pub enum LineTone {
125    /// A line to look at, such as the one a "go to line" reached: an accent tint and the pillar.
126    #[default]
127    Accent,
128    /// A line worth a careful look, such as a finding of a review: a warning tint and the
129    /// warning icon.
130    Warning,
131}
132
133impl LineTone {
134    fn variant(self) -> &'static str {
135        match self {
136            Self::Accent => "accent",
137            Self::Warning => "warning",
138        }
139    }
140}
141
142/// The sign of a marked or highlighted line.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144enum Sign {
145    /// An icon on the first row of the line.
146    Icon(&'static str),
147    /// The theme's pillar on every row of the line.
148    Pillar,
149}
150
151/// Rows of context kept around a revealed line, so it does not land on the very edge of the
152/// scroll view.
153const REVEAL_CONTEXT: u16 = 2;
154
155/// What a code view remembers between frames.
156#[derive(Debug, Default)]
157struct CodeMemory {
158    /// The line last revealed, so the view scrolls to a line once and then lets the user move.
159    revealed: Option<usize>,
160}
161
162/// Code with syntax colours, line numbers and wrapping of long lines.
163///
164/// While focused, `c` copies the code to the clipboard and flashes. The code is a text selection
165/// region: a mouse drag selects inside it (turn it off with
166/// [`NodeMut::selectable`](crate::widget::NodeMut::selectable)), and clean copies leave out the
167/// line numbers and the signs of marked lines.
168///
169/// For reviews, [`line_marks`](Self::line_marks) shows a diff and
170/// [`highlight_lines`](Self::highlight_lines) tints lines to look at; either adds a sign column
171/// at the left edge. [`reveal`](Self::reveal) scrolls the enclosing
172/// [`ScrollView`](crate::widgets::ScrollView) to a line.
173///
174/// Style keys: `code` (`bg`, `padding`) with `focus` and `pressed`; `code-line-number`;
175/// `code-token.<kind>` where kind is `keyword`, `type`, `function`, `macro`, `string`,
176/// `number`, `comment`, `attribute`, `lifetime`, `punctuation`, `table`, `key`, `variable` or
177/// `plain`; `code-line.<look>` (`bg` for the line, `fg` for its sign) where look is `added`,
178/// `removed`, `accent` or `warning`. Icons: `line-added`, `line-removed`, `warning` and the
179/// pillar.
180pub struct CodeView<Msg> {
181    code: String,
182    language: Language,
183    line_numbers: bool,
184    marks: Vec<LineMark>,
185    /// The number each source line carries, when the caller gave them outright.
186    numbers: Option<Vec<Option<usize>>>,
187    highlights: Vec<(usize, usize, LineTone)>,
188    reveal: Option<usize>,
189    reveal_number: Option<usize>,
190    on_copy: Option<Msg>,
191}
192
193impl<Msg: 'static> CodeView<Msg> {
194    /// Shows `code` in `language`.
195    #[must_use]
196    pub fn new(code: impl Into<String>, language: Language) -> Self {
197        Self {
198            code: code.into(),
199            language,
200            line_numbers: true,
201            marks: Vec::new(),
202            numbers: None,
203            highlights: Vec::new(),
204            reveal: None,
205            reveal_number: None,
206            on_copy: None,
207        }
208    }
209
210    /// Marks lines as a diff: the first mark belongs to line 1, the next to line 2, and lines
211    /// past the last mark are unchanged. Added lines are tinted with the success colour and
212    /// signed `+`, removed ones with the danger colour and `−`, in the sign column at the left
213    /// edge.
214    ///
215    /// The line numbers then follow the files rather than the text: a diff puts the lines of two
216    /// versions one after another, so counting from the top would number neither file. A removed
217    /// line carries the old file's number, an added line the new file's, and a line in both
218    /// carries the new file's, which is the one a finding such as `PKGBUILD:22` means. Give
219    /// [`line_numbers_from`](Self::line_numbers_from) instead when the diff starts part way into
220    /// the file, and reach a line by its number with [`reveal_number`](Self::reveal_number).
221    #[must_use]
222    pub fn line_marks(mut self, marks: impl IntoIterator<Item = LineMark>) -> Self {
223        self.marks = marks.into_iter().collect();
224        self
225    }
226
227    /// Tints `lines` (counted from 1, like the line numbers) in `tone`, over any diff mark, and
228    /// puts the tone's sign in the sign column. Call it again for more lines; where ranges meet,
229    /// the later call wins. The tint is separate from a text selection, which draws over it.
230    #[must_use]
231    pub fn highlight_lines(mut self, lines: impl RangeBounds<usize>, tone: LineTone) -> Self {
232        let first = match lines.start_bound() {
233            Bound::Included(&n) => n,
234            Bound::Excluded(&n) => n.saturating_add(1),
235            Bound::Unbounded => 1,
236        };
237        let last = match lines.end_bound() {
238            Bound::Included(&n) => n,
239            Bound::Excluded(&n) => n.saturating_sub(1),
240            Bound::Unbounded => usize::MAX,
241        };
242        self.highlights.push((first.max(1), last, tone));
243        self
244    }
245
246    /// Scrolls the enclosing [`ScrollView`](crate::widgets::ScrollView) just enough to show
247    /// `line` (counted from 1; past the end, the last line) with two rows of context, gliding
248    /// there unless motion is reduced. It happens when the revealed line changes, so the user
249    /// can scroll away afterwards; outside a scroll view it does nothing.
250    #[must_use]
251    pub fn reveal(mut self, line: usize) -> Self {
252        self.reveal = Some(line);
253        self
254    }
255
256    /// Shows or hides line numbers; shown by default.
257    #[must_use]
258    pub fn line_numbers(mut self, show: bool) -> Self {
259        self.line_numbers = show;
260        self
261    }
262
263    /// Gives each line its own number outright: the first number belongs to the first line of the
264    /// code, and `None` leaves that line's column blank, as a hunk header has no number of its
265    /// own. Lines past the last number are blank too.
266    ///
267    /// This is for a diff that starts part way into a file, where nothing in the text says the
268    /// hunk began at line 120. A whole-file diff needs only [`line_marks`](Self::line_marks),
269    /// which numbers the lines from the marks. Numbers given here win over that.
270    #[must_use]
271    pub fn line_numbers_from(mut self, numbers: impl IntoIterator<Item = Option<usize>>) -> Self {
272        self.numbers = Some(numbers.into_iter().collect());
273        self
274    }
275
276    /// Scrolls to the line whose number is `number`, the way [`reveal`](Self::reveal) scrolls to
277    /// a line of the text. In a diff the two are not the same line, so this is what a finding
278    /// that names a file and a line asks for.
279    ///
280    /// Two lines can carry one number — the line a version lost and the line that took its place.
281    /// The line the new file numbers that way is the one reached, because that is the file a
282    /// finding is about; a number only a removed line carries reaches that line. A number no line
283    /// carries scrolls nowhere. Given as well as [`reveal`](Self::reveal), this wins.
284    #[must_use]
285    pub fn reveal_number(mut self, number: usize) -> Self {
286        self.reveal_number = Some(number);
287        self
288    }
289
290    /// The number each source line is drawn with: the ones given outright, else the numbers the
291    /// diff marks imply, else the line's own place in the text.
292    fn numbers(&self) -> Vec<Option<usize>> {
293        let lines = self.code.split('\n').count();
294        if let Some(given) = &self.numbers {
295            return (0..lines).map(|index| given.get(index).copied().flatten()).collect();
296        }
297        if self.marks.is_empty() {
298            return (1..=lines).map(Some).collect();
299        }
300        let (mut old, mut new) = (0, 0);
301        (0..lines)
302            .map(|index| match self.marks.get(index) {
303                Some(LineMark::Removed) => {
304                    old += 1;
305                    Some(old)
306                }
307                Some(LineMark::Added) => {
308                    new += 1;
309                    Some(new)
310                }
311                Some(LineMark::Unchanged) | None => {
312                    old += 1;
313                    new += 1;
314                    Some(new)
315                }
316            })
317            .collect()
318    }
319
320    /// The source line `number` names, preferring the line the new file numbers that way over one
321    /// the old file lost.
322    fn line_of_number(&self, number: usize) -> Option<usize> {
323        let numbers = self.numbers();
324        let carries = |index: &usize| numbers.get(*index).copied().flatten() == Some(number);
325        let kept = |index: &usize| !matches!(self.marks.get(*index), Some(LineMark::Removed));
326        let index = (0..numbers.len())
327            .find(|index| carries(index) && kept(index))
328            .or_else(|| (0..numbers.len()).find(carries))?;
329        Some(index + 1)
330    }
331
332    /// Message sent after the code was copied with `c`.
333    #[must_use]
334    pub fn on_copy(mut self, message: Msg) -> Self {
335        self.on_copy = Some(message);
336        self
337    }
338
339    fn gutter(&self) -> u16 {
340        if !self.line_numbers {
341            return 0;
342        }
343        if self.numbers.is_none() && self.marks.is_empty() {
344            return gutter_width(&self.code);
345        }
346        // A diff's numbers are the files' own, which can be wider than the count of lines shown.
347        let widest = self.numbers().into_iter().flatten().max().unwrap_or(1);
348        text::width(&widest.to_string()).saturating_add(2)
349    }
350
351    /// Width of the sign column: a sign and a space when lines are marked or highlighted.
352    fn signs(&self) -> u16 {
353        if self.marks.is_empty() && self.highlights.is_empty() { 0 } else { 2 }
354    }
355
356    /// How `line` is drawn: the `code-line` variant of its tint and its sign, if any.
357    fn look(&self, line: usize) -> Option<(&'static str, Sign)> {
358        if let Some((_, _, tone)) =
359            self.highlights.iter().rev().find(|(first, last, _)| (*first..=*last).contains(&line))
360        {
361            let sign = match tone {
362                LineTone::Accent => Sign::Pillar,
363                LineTone::Warning => Sign::Icon("warning"),
364            };
365            return Some((tone.variant(), sign));
366        }
367        match self.marks.get(line.checked_sub(1)?) {
368            Some(LineMark::Added) => Some(("added", Sign::Icon("line-added"))),
369            Some(LineMark::Removed) => Some(("removed", Sign::Icon("line-removed"))),
370            Some(LineMark::Unchanged) | None => None,
371        }
372    }
373
374    /// The rows of the code, each first row of a source line carrying the number that line is
375    /// drawn with.
376    fn numbered_rows(&self, width: u16) -> Vec<CodeRow> {
377        let mut rows = code_rows(&self.code, self.language, width);
378        if self.numbers.is_none() && self.marks.is_empty() {
379            return rows;
380        }
381        let numbers = self.numbers();
382        for row in &mut rows {
383            if row.number.is_some() {
384                row.number = row.line.checked_sub(1).and_then(|index| numbers.get(index).copied().flatten());
385            }
386        }
387        rows
388    }
389
390    /// Tints marked and highlighted rows across `area` and draws their signs at `x`.
391    fn paint_looks(&self, cx: &mut PaintCx<'_>, area: Rect, x: i32, top: i32, rows: &[CodeRow]) {
392        for (index, row) in rows.iter().enumerate() {
393            let Some((variant, sign)) = self.look(row.line) else { continue };
394            // A wrapped line signs only its first row, and a line without a number of its own —
395            // a hunk header — still signs.
396            let first_row = index == 0 || rows[index - 1].line != row.line;
397            let y = top + i32::try_from(index).unwrap_or(i32::MAX);
398            let style = cx.style("code-line", Some(variant), &[]);
399            if let Some(bg) = style.color("bg") {
400                cx.fill(Rect::new(area.x, y, area.width, 1), bg);
401            }
402            let color = style.color("fg").unwrap_or_else(|| cx.color("text"));
403            match sign {
404                Sign::Pillar => cx.pillar(x, y, color),
405                Sign::Icon(icon) if first_row => {
406                    let glyph = cx.env().icons().glyph(icon).into_owned();
407                    cx.text(x, y, &glyph, CellStyle::fg(color), 1);
408                }
409                Sign::Icon(_) => {}
410            }
411        }
412    }
413
414    /// Asks the enclosing scroll view to show the revealed line, once per line.
415    fn request_reveal(&self, cx: &mut PaintCx<'_>, area: Rect, top: i32, rows: &[CodeRow]) {
416        let asked = match self.reveal_number {
417            Some(number) => self.line_of_number(number),
418            None => self.reveal,
419        };
420        let wanted = asked.map(|line| line.clamp(1, rows.last().map_or(1, |row| row.line)));
421        let memory = cx.memory::<CodeMemory>();
422        if memory.revealed == wanted {
423            return;
424        }
425        memory.revealed = wanted;
426        let Some(line) = wanted else { return };
427        let Some(first) = rows.iter().position(|row| row.line == line) else { return };
428        let count = rows[first..].iter().take_while(|row| row.line == line).count();
429        let context = i32::from(REVEAL_CONTEXT);
430        let y = (top + i32::try_from(first).unwrap_or(i32::MAX) - context).max(area.y);
431        let bottom = (top + i32::try_from(first + count).unwrap_or(i32::MAX) + context).min(area.bottom());
432        cx.reveal(Rect::new(area.x, y, area.width, clamp_u16(bottom - y)));
433    }
434}
435
436impl<Msg: Clone + 'static> Widget<Msg> for CodeView<Msg> {
437    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
438        let padding = cx.env().theme().style("code", None, &[]).pair("padding").unwrap_or((1, 2));
439        let content_width =
440            available.width.saturating_sub(cells::sum([padding.1.saturating_mul(2), self.signs(), self.gutter()]));
441        let rows = code_rows(&self.code, self.language, content_width.max(1));
442        let widest = rows
443            .iter()
444            .map(|row| cells::sum(row.pieces.iter().map(|(piece, _)| text::width(piece))))
445            .max()
446            .unwrap_or(0);
447        Size::new(
448            cells::sum([widest, self.signs(), self.gutter(), padding.1.saturating_mul(2)]),
449            clamp_u16(i32::try_from(rows.len()).unwrap_or(i32::MAX)).saturating_add(padding.0.saturating_mul(2)),
450        )
451        .min(available)
452    }
453
454    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
455        let mut states = cx.states();
456        states.retain(|state| *state != State::Hover);
457        let style = cx.style("code", None, &states);
458        if let Some(bg) = style.text().bg {
459            cx.clear(area, bg);
460        }
461        cx.register_hit(area);
462        let inner = area.inset(style.padding());
463        // The padding is surface, not code: a selection starts and stays inside it.
464        cx.selectable(inner);
465        let (signs, gutter) = (self.signs(), self.gutter());
466        let width = inner.width.saturating_sub(signs).saturating_sub(gutter).max(1);
467        let rows = self.numbered_rows(width);
468        if signs > 0 {
469            // Signs say how a line changed; copies of the code leave them out like line numbers.
470            cx.decoration(Rect::new(inner.x, inner.y, signs, inner.height));
471            self.paint_looks(cx, area, inner.x, inner.y, &rows);
472        }
473        paint_rows(
474            cx,
475            Rect::new(inner.x + i32::from(signs), inner.y, inner.width.saturating_sub(signs), inner.height),
476            &rows,
477            gutter,
478        );
479        self.request_reveal(cx, area, inner.y, &rows);
480    }
481
482    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
483        let Event::Key(key) = event else {
484            return false;
485        };
486        if !key.is_plain(Key::Char('c')) {
487            return false;
488        }
489        cx.copy(self.code.clone());
490        cx.flash();
491        if let Some(message) = &self.on_copy {
492            cx.emit(message.clone());
493        }
494        true
495    }
496
497    fn focusable(&self) -> bool {
498        true
499    }
500}
501
502#[cfg(test)]
503mod tests;