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/// What a cell is painted as. Ordered by precedence, highest last.
48///
49/// Spelling this out as a type rather than as a chain of `if`s inside the loop
50/// is what makes the precedence reviewable: there is exactly one place that
51/// decides, and adding syntax highlighting at M2.5 adds a variant here rather
52/// than another branch in the middle of a run-accumulating loop.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum Paint {
55 Plain,
56 /// The line a caret sits on.
57 CursorLine,
58 /// A bracket and its partner.
59 Bracket,
60 /// One of the non-primary selections.
61 Selection,
62 /// The selection every motion is relative to.
63 PrimarySelection,
64}
65
66impl Paint {
67 fn style(self, theme: &ThemeColors) -> Style {
68 match self {
69 Paint::Plain => Style::default().fg(theme.fg).bg(theme.bg),
70 Paint::CursorLine => Style::default().fg(theme.fg).bg(theme.cursor_line_bg),
71 Paint::Bracket => Style::default()
72 .fg(theme.bracket_match_fg)
73 .bg(theme.bracket_match_bg),
74 Paint::Selection => Style::default()
75 .fg(theme.selection_fg)
76 .bg(theme.selection_bg),
77 Paint::PrimarySelection => Style::default()
78 .fg(theme.selection_fg)
79 .bg(theme.selection_primary_bg),
80 }
81 }
82}
83
84/// Everything needed to draw one visible line.
85///
86/// A struct rather than nine positional arguments: the call site was already at
87/// six and the three effects added here would have made it a row of unlabelled
88/// values where swapping two `usize`s compiles cleanly and renders wrong.
89pub struct LineStyle<'a> {
90 pub line: usize,
91 pub left_col: usize,
92 /// Text-area width in cells, for padding the current-line highlight out to
93 /// the edge.
94 pub width: usize,
95 pub tab_width: usize,
96 pub selections: &'a [Selection],
97 pub primary: Selection,
98 /// Whether a caret sits on this line *with nothing selected*. A line
99 /// carrying a real selection does not also get the stripe — the selection
100 /// is already saying where the user is, and two answers to one question is
101 /// how a interface starts to look busy.
102 pub cursor_line: bool,
103 pub brackets: Option<(Position, Position)>,
104 pub theme: &'a ThemeColors,
105}
106
107/// Build one rendered line, splitting it into spans wherever the paint changes.
108///
109/// Spans are cut at grapheme boundaries and styled per run, so a wide
110/// character is highlighted as one unit and never half-painted.
111pub fn styled_line(text: &str, ctx: &LineStyle) -> Line<'static> {
112 // Selections are stated in grapheme columns of the whole line, so the
113 // dropped count is what keeps highlighting on the text it covers rather
114 // than sliding left with the window.
115 let (visible, skipped) = window(text, ctx.left_col, ctx.tab_width);
116
117 let mut spans: Vec<Span<'static>> = Vec::new();
118 let mut current = String::new();
119 let mut current_paint: Option<Paint> = None;
120 let mut columns = 0usize;
121
122 for (offset, grapheme) in visible.graphemes(true).enumerate() {
123 let position = Position {
124 line: ctx.line,
125 col: skipped + offset,
126 };
127 let paint = paint_for(position, ctx);
128
129 if current_paint != Some(paint) && !current.is_empty() {
130 let style = current_paint.unwrap_or(Paint::Plain).style(ctx.theme);
131 spans.push(Span::styled(std::mem::take(&mut current), style));
132 }
133 current_paint = Some(paint);
134 current.push_str(grapheme);
135 columns += display_width_with_tabs(grapheme, ctx.tab_width).max(1);
136 }
137
138 if !current.is_empty() {
139 let style = current_paint.unwrap_or(Paint::Plain).style(ctx.theme);
140 spans.push(Span::styled(current, style));
141 }
142
143 // Carry the current-line tint past the end of the text. A highlight that
144 // stops at the last character leaves a ragged right edge that reads as a
145 // rendering bug rather than as a feature.
146 if ctx.cursor_line && columns < ctx.width {
147 spans.push(Span::styled(
148 " ".repeat(ctx.width - columns),
149 Paint::CursorLine.style(ctx.theme),
150 ));
151 }
152
153 Line::from(spans)
154}
155
156fn paint_for(position: Position, ctx: &LineStyle) -> Paint {
157 if ctx.selections.iter().any(|s| s.contains(position)) {
158 // A selection outranks a bracket: both mean "where you are", and the
159 // selection is the one the next keystroke acts on.
160 if ctx.primary.contains(position) {
161 Paint::PrimarySelection
162 } else {
163 Paint::Selection
164 }
165 } else if ctx
166 .brackets
167 .is_some_and(|(open, close)| open == position || close == position)
168 {
169 Paint::Bracket
170 } else if ctx.cursor_line {
171 Paint::CursorLine
172 } else {
173 Paint::Plain
174 }
175}