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    highlights: Vec<(usize, usize, LineTone)>,
186    reveal: Option<usize>,
187    on_copy: Option<Msg>,
188}
189
190impl<Msg: 'static> CodeView<Msg> {
191    /// Shows `code` in `language`.
192    #[must_use]
193    pub fn new(code: impl Into<String>, language: Language) -> Self {
194        Self {
195            code: code.into(),
196            language,
197            line_numbers: true,
198            marks: Vec::new(),
199            highlights: Vec::new(),
200            reveal: None,
201            on_copy: None,
202        }
203    }
204
205    /// Marks lines as a diff: the first mark belongs to line 1, the next to line 2, and lines
206    /// past the last mark are unchanged. Added lines are tinted with the success colour and
207    /// signed `+`, removed ones with the danger colour and `−`, in the sign column at the left
208    /// edge.
209    #[must_use]
210    pub fn line_marks(mut self, marks: impl IntoIterator<Item = LineMark>) -> Self {
211        self.marks = marks.into_iter().collect();
212        self
213    }
214
215    /// Tints `lines` (counted from 1, like the line numbers) in `tone`, over any diff mark, and
216    /// puts the tone's sign in the sign column. Call it again for more lines; where ranges meet,
217    /// the later call wins. The tint is separate from a text selection, which draws over it.
218    #[must_use]
219    pub fn highlight_lines(mut self, lines: impl RangeBounds<usize>, tone: LineTone) -> Self {
220        let first = match lines.start_bound() {
221            Bound::Included(&n) => n,
222            Bound::Excluded(&n) => n.saturating_add(1),
223            Bound::Unbounded => 1,
224        };
225        let last = match lines.end_bound() {
226            Bound::Included(&n) => n,
227            Bound::Excluded(&n) => n.saturating_sub(1),
228            Bound::Unbounded => usize::MAX,
229        };
230        self.highlights.push((first.max(1), last, tone));
231        self
232    }
233
234    /// Scrolls the enclosing [`ScrollView`](crate::widgets::ScrollView) just enough to show
235    /// `line` (counted from 1; past the end, the last line) with two rows of context, gliding
236    /// there unless motion is reduced. It happens when the revealed line changes, so the user
237    /// can scroll away afterwards; outside a scroll view it does nothing.
238    #[must_use]
239    pub fn reveal(mut self, line: usize) -> Self {
240        self.reveal = Some(line);
241        self
242    }
243
244    /// Shows or hides line numbers; shown by default.
245    #[must_use]
246    pub fn line_numbers(mut self, show: bool) -> Self {
247        self.line_numbers = show;
248        self
249    }
250
251    /// Message sent after the code was copied with `c`.
252    #[must_use]
253    pub fn on_copy(mut self, message: Msg) -> Self {
254        self.on_copy = Some(message);
255        self
256    }
257
258    fn gutter(&self) -> u16 {
259        if self.line_numbers { gutter_width(&self.code) } else { 0 }
260    }
261
262    /// Width of the sign column: a sign and a space when lines are marked or highlighted.
263    fn signs(&self) -> u16 {
264        if self.marks.is_empty() && self.highlights.is_empty() { 0 } else { 2 }
265    }
266
267    /// How `line` is drawn: the `code-line` variant of its tint and its sign, if any.
268    fn look(&self, line: usize) -> Option<(&'static str, Sign)> {
269        if let Some((_, _, tone)) =
270            self.highlights.iter().rev().find(|(first, last, _)| (*first..=*last).contains(&line))
271        {
272            let sign = match tone {
273                LineTone::Accent => Sign::Pillar,
274                LineTone::Warning => Sign::Icon("warning"),
275            };
276            return Some((tone.variant(), sign));
277        }
278        match self.marks.get(line.checked_sub(1)?) {
279            Some(LineMark::Added) => Some(("added", Sign::Icon("line-added"))),
280            Some(LineMark::Removed) => Some(("removed", Sign::Icon("line-removed"))),
281            Some(LineMark::Unchanged) | None => None,
282        }
283    }
284
285    /// Tints marked and highlighted rows across `area` and draws their signs at `x`.
286    fn paint_looks(&self, cx: &mut PaintCx<'_>, area: Rect, x: i32, top: i32, rows: &[CodeRow]) {
287        for (index, row) in rows.iter().enumerate() {
288            let Some((variant, sign)) = self.look(row.line) else { continue };
289            let y = top + i32::try_from(index).unwrap_or(i32::MAX);
290            let style = cx.style("code-line", Some(variant), &[]);
291            if let Some(bg) = style.color("bg") {
292                cx.fill(Rect::new(area.x, y, area.width, 1), bg);
293            }
294            let color = style.color("fg").unwrap_or_else(|| cx.color("text"));
295            match sign {
296                Sign::Pillar => cx.pillar(x, y, color),
297                Sign::Icon(icon) if row.number.is_some() => {
298                    let glyph = cx.env().icons().glyph(icon).into_owned();
299                    cx.text(x, y, &glyph, CellStyle::fg(color), 1);
300                }
301                Sign::Icon(_) => {}
302            }
303        }
304    }
305
306    /// Asks the enclosing scroll view to show the revealed line, once per line.
307    fn request_reveal(&self, cx: &mut PaintCx<'_>, area: Rect, top: i32, rows: &[CodeRow]) {
308        let wanted = self.reveal.map(|line| line.clamp(1, rows.last().map_or(1, |row| row.line)));
309        let memory = cx.memory::<CodeMemory>();
310        if memory.revealed == wanted {
311            return;
312        }
313        memory.revealed = wanted;
314        let Some(line) = wanted else { return };
315        let Some(first) = rows.iter().position(|row| row.line == line) else { return };
316        let count = rows[first..].iter().take_while(|row| row.line == line).count();
317        let context = i32::from(REVEAL_CONTEXT);
318        let y = (top + i32::try_from(first).unwrap_or(i32::MAX) - context).max(area.y);
319        let bottom = (top + i32::try_from(first + count).unwrap_or(i32::MAX) + context).min(area.bottom());
320        cx.reveal(Rect::new(area.x, y, area.width, clamp_u16(bottom - y)));
321    }
322}
323
324impl<Msg: Clone + 'static> Widget<Msg> for CodeView<Msg> {
325    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
326        let padding = cx.env().theme().style("code", None, &[]).pair("padding").unwrap_or((1, 2));
327        let content_width =
328            available.width.saturating_sub(cells::sum([padding.1.saturating_mul(2), self.signs(), self.gutter()]));
329        let rows = code_rows(&self.code, self.language, content_width.max(1));
330        let widest = rows
331            .iter()
332            .map(|row| cells::sum(row.pieces.iter().map(|(piece, _)| text::width(piece))))
333            .max()
334            .unwrap_or(0);
335        Size::new(
336            cells::sum([widest, self.signs(), self.gutter(), padding.1.saturating_mul(2)]),
337            clamp_u16(i32::try_from(rows.len()).unwrap_or(i32::MAX)).saturating_add(padding.0.saturating_mul(2)),
338        )
339        .min(available)
340    }
341
342    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
343        let mut states = cx.states();
344        states.retain(|state| *state != State::Hover);
345        let style = cx.style("code", None, &states);
346        if let Some(bg) = style.text().bg {
347            cx.clear(area, bg);
348        }
349        cx.register_hit(area);
350        let inner = area.inset(style.padding());
351        // The padding is surface, not code: a selection starts and stays inside it.
352        cx.selectable(inner);
353        let (signs, gutter) = (self.signs(), self.gutter());
354        let width = inner.width.saturating_sub(signs).saturating_sub(gutter).max(1);
355        let rows = code_rows(&self.code, self.language, width);
356        if signs > 0 {
357            // Signs say how a line changed; copies of the code leave them out like line numbers.
358            cx.decoration(Rect::new(inner.x, inner.y, signs, inner.height));
359            self.paint_looks(cx, area, inner.x, inner.y, &rows);
360        }
361        paint_rows(
362            cx,
363            Rect::new(inner.x + i32::from(signs), inner.y, inner.width.saturating_sub(signs), inner.height),
364            &rows,
365            gutter,
366        );
367        self.request_reveal(cx, area, inner.y, &rows);
368    }
369
370    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
371        let Event::Key(key) = event else {
372            return false;
373        };
374        if !key.is_plain(Key::Char('c')) {
375            return false;
376        }
377        cx.copy(self.code.clone());
378        cx.flash();
379        if let Some(message) = &self.on_copy {
380            cx.emit(message.clone());
381        }
382        true
383    }
384
385    fn focusable(&self) -> bool {
386        true
387    }
388}
389
390#[cfg(test)]
391mod tests;