typ_panel_editor/render.rs
1//! Turning a line of text plus the selections covering it into styled spans.
2//!
3//! Split out of `lib.rs` because this is where display-column arithmetic
4//! lives, and it is the part most likely to grow: highlighting arrives in M2.5
5//! and has to compose with selection styling rather than fight it.
6
7use ratatui::style::Style;
8use ratatui::text::{Line, Span};
9use typ_buffer::{Position, Selection, display_width_with_tabs};
10use typ_core::ThemeColors;
11use unicode_segmentation::UnicodeSegmentation;
12
13/// Drop `left_col` display columns from the front of a line.
14///
15/// Returns the remaining text and how many graphemes were dropped, because the
16/// caller still has to line the result up against selections that are stated in
17/// grapheme columns.
18///
19/// A wide grapheme straddling the boundary is dropped entirely rather than
20/// half-drawn: a terminal cannot render half a cell, so the alternatives are a
21/// dropped character or a row one column out of alignment with every other row.
22/// Slicing by display column rather than by grapheme is the whole point — a line
23/// of CJK scrolls by cells the way it is drawn.
24pub fn window(text: &str, left_col: usize, tab_width: usize) -> (&str, usize) {
25 if left_col == 0 {
26 return (text, 0);
27 }
28
29 let mut column = 0usize;
30 for (skipped, (byte, grapheme)) in text.grapheme_indices(true).enumerate() {
31 if column >= left_col {
32 return (&text[byte..], skipped);
33 }
34 // `.max(1)` so a zero-width grapheme cannot stall the walk. Tabs are
35 // measured from their real column, which is why this tracks `column`
36 // rather than summing widths in isolation.
37 column += if grapheme == "\t" {
38 tab_width - (column % tab_width)
39 } else {
40 display_width_with_tabs(grapheme, tab_width).max(1)
41 };
42 }
43 // Scrolled entirely past the end of this line.
44 ("", text.graphemes(true).count())
45}
46
47/// Build one rendered line, splitting it into spans wherever the selection
48/// state changes.
49///
50/// Spans are cut at grapheme boundaries and styled per run, so a wide
51/// character is highlighted as one unit and never half-painted.
52pub fn styled_line(
53 text: &str,
54 line_index: usize,
55 left_col: usize,
56 tab_width: usize,
57 selections: &[Selection],
58 theme: &ThemeColors,
59) -> Line<'static> {
60 let plain = Style::default().fg(theme.fg).bg(theme.bg);
61 let selected = Style::default()
62 .fg(theme.selection_fg)
63 .bg(theme.selection_bg);
64
65 // Selections are stated in grapheme columns of the whole line, so the
66 // dropped count is what keeps highlighting on the text it covers rather
67 // than sliding left with the window.
68 let (visible, skipped) = window(text, left_col, tab_width);
69
70 let mut spans: Vec<Span<'static>> = Vec::new();
71 let mut current = String::new();
72 let mut current_selected: Option<bool> = None;
73
74 for (offset, grapheme) in visible.graphemes(true).enumerate() {
75 let position = Position {
76 line: line_index,
77 col: skipped + offset,
78 };
79 let is_selected = selections.iter().any(|s| s.contains(position));
80
81 if current_selected != Some(is_selected) && !current.is_empty() {
82 let style = if current_selected == Some(true) {
83 selected
84 } else {
85 plain
86 };
87 spans.push(Span::styled(std::mem::take(&mut current), style));
88 }
89 current_selected = Some(is_selected);
90 current.push_str(grapheme);
91 }
92
93 if !current.is_empty() {
94 let style = if current_selected == Some(true) {
95 selected
96 } else {
97 plain
98 };
99 spans.push(Span::styled(current, style));
100 }
101
102 Line::from(spans)
103}