escriba_render/lib.rs
1//! `escriba-render` — rendering trait + implementations.
2//!
3//! Two backends, same trait surface:
4//! - [`TextRenderer`] — ANSI-in-stdout for CI / headless runs.
5//! - [`gpu::GpuRenderer`] — madori + garasu + glyphon real GPU window.
6//! Implements [`madori::RenderCallback`]; the escriba binary pairs it
7//! with an `on_event` handler that shares an `Arc<Mutex<EditorState>>`.
8
9extern crate self as escriba_render;
10
11pub mod gpu;
12/// Escriba-local language tables moved to `escriba-ts` with the ecosystem
13/// they register into. Re-exported so existing paths keep working.
14pub use escriba_ts::langs;
15/// The start screen, painted as ANSI. Layout comes from
16/// `escriba_ui::splash`; this face only colors it.
17pub mod splash;
18
19pub use gpu::{GpuRenderer, SharedState};
20pub use splash::render_splash_ansi;
21
22use escriba_runtime::EditorState;
23use serde::{Deserialize, Serialize};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26pub enum RenderTarget {
27 Text,
28 Gpu,
29}
30
31pub trait Renderer {
32 /// Paint one frame of `state`.
33 ///
34 /// Takes the whole editor rather than `(layout, buffers, cursor)`. Three
35 /// parameters meant three chances to hand this face something that did
36 /// not match the others — and it happened: the binary passed a literal
37 /// `Position::ZERO`, so every `--render=text` dump drew the cursor at 1:1
38 /// no matter where the cursor actually was.
39 fn render_frame(&mut self, state: &EditorState) -> String;
40}
41
42pub struct TextRenderer;
43
44impl Renderer for TextRenderer {
45 fn render_frame(&mut self, state: &EditorState) -> String {
46 let Some(win) = state.layout.active_window() else {
47 return "<no window>\n".to_string();
48 };
49 let Some(buf) = state.buffers.get(win.buffer_id) else {
50 return "<no buffer>\n".to_string();
51 };
52 // The operator's theme, through the ONE seam. This face used to reach
53 // for `VellumPalette::vellum()` directly — the exact hardwiring
54 // `ChromePalette` exists to remove, missed because escriba's notes
55 // named only two faces and there are three.
56 let chrome = state.chrome();
57 let cursor = state.cursor();
58 let world = state.world();
59 let line_count = buf.line_count();
60 // The shared gutter model, so this face's columns match the ratatui
61 // and GPU faces. It had its own seven-column spelling with no mark
62 // cell, which is how a third face drifts without anyone deciding to.
63 let gutter_cols = escriba_ui::gutter::gutter_width(line_count);
64
65 let mut out = String::new();
66 let top = win.viewport.top_line;
67 let left = win.viewport.left_column as usize;
68 let vis_cols = (win.viewport.visible_columns as usize).saturating_sub(gutter_cols);
69 let height = win.viewport.visible_lines.max(10);
70 for row in 0..height {
71 let ln = top + row;
72 if ln >= line_count {
73 break;
74 }
75 let line = buf.line(ln).unwrap_or_default();
76 let line = line.trim_end_matches('\n').trim_end_matches('\r');
77 // Both gutter planes, asked of the STATE — see
78 // `EditorState::gutter_marks`. Reading `worst_on_line` here (what
79 // this face used to do) is how a second plane lands on two faces
80 // out of three.
81 let marks = state.gutter_marks(&world, state.active, ln);
82 for cell in escriba_ui::gutter::gutter_cells(ln, marks, line_count) {
83 match cell.role {
84 escriba_ui::gutter::GutterRole::Mark(sev) => {
85 push_fg(&mut out, escriba_ui::chrome::severity_color(&chrome, sev));
86 out.push_str(&cell.text);
87 out.push_str(RESET);
88 }
89 escriba_ui::gutter::GutterRole::Breakpoint => {
90 push_fg(&mut out, escriba_ui::chrome::breakpoint_color(&chrome));
91 out.push_str(&cell.text);
92 out.push_str(RESET);
93 }
94 _ => out.push_str(&cell.text),
95 }
96 }
97 // Slice the line to the visible horizontal window
98 // `[left, left + vis_cols)` — char-based so multibyte text stays
99 // aligned. The cursor's on-screen column is computed relative to
100 // `left` so the cursor glyph tracks the horizontal scroll.
101 let visible: Vec<char> = line.chars().skip(left).take(vis_cols).collect();
102 if ln == cursor.line && cursor.column as usize >= left {
103 let rel = cursor.column as usize - left;
104 out.extend(visible.iter().take(rel));
105 out.push_str(INVERT);
106 out.push(visible.get(rel).copied().unwrap_or(' '));
107 out.push_str(RESET);
108 out.extend(visible.iter().skip(rel + 1));
109 } else {
110 out.extend(visible.iter());
111 }
112 out.push('\n');
113 }
114 out.push_str(INVERT);
115 out.push_str(" escriba · ");
116 out.push_str(&chrome.info.hex());
117 out.push_str(" · ");
118 out.push_str(&(cursor.line + 1).to_string());
119 out.push(':');
120 out.push_str(&(cursor.column + 1).to_string());
121 out.push(' ');
122 out.push_str(RESET);
123 out.push('\n');
124 out
125 }
126}
127
128/// Reverse video on / all attributes off. Named rather than inlined so the
129/// escape sequences appear once each.
130const INVERT: &str = "\x1b[7m";
131const RESET: &str = "\x1b[0m";
132
133/// Append an SGR truecolor foreground set for `c`.
134///
135/// `write!` into a `String`, not `format!` — ★★ TYPED EMISSION. The
136/// `Display` impls of the three `u8`s are the typed surface; the alternative
137/// this replaces built ANSI by string interpolation.
138fn push_fg(out: &mut String, c: ishou_tokens::Rgb) {
139 use std::fmt::Write as _;
140 // Writing into a String is infallible; the Result exists only because
141 // `fmt::Write` is shared with fallible sinks.
142 let _ = write!(out, "\x1b[38;2;{};{};{}m", c.r, c.g, c.b);
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use escriba_buffer::BufferSet;
149
150 /// An editor holding `text`, with a viewport wide enough that nothing is
151 /// clipped. Built through `EditorState` rather than a hand-assembled
152 /// `Layout` — the point of the signature change is that this face reads
153 /// ONE object, so a test that constructed a second one would be testing
154 /// a configuration the binary can never produce.
155 fn editor(text: &str) -> EditorState {
156 let mut bufs = BufferSet::new();
157 let id = bufs.scratch(text);
158 let mut st = EditorState::new_with_buffer(bufs, id);
159 st.dismiss_splash();
160 if let Some(w) = st.layout.active_window_mut() {
161 w.viewport.visible_lines = 20;
162 w.viewport.visible_columns = 80;
163 }
164 st
165 }
166
167 #[test]
168 fn renders_buffer_lines() {
169 // The cursor sits on line 3, so lines 1 and 2 render unbroken. On
170 // the line it occupies, the cursor cell is wrapped in an SGR pair,
171 // which splits the word — that is correct output, not a defect, and
172 // asserting on a word under the cursor tests the escape sequence
173 // rather than the text.
174 let mut st = editor("hello\nworld\nfoo");
175 st.on_key(&escriba_keymap::Key::Char('j'));
176 st.on_key(&escriba_keymap::Key::Char('j'));
177 let frame = TextRenderer.render_frame(&st);
178 assert!(frame.contains("hello"), "{frame:?}");
179 assert!(frame.contains("world"), "{frame:?}");
180 }
181
182 #[test]
183 fn cursor_is_highlighted() {
184 let frame = TextRenderer.render_frame(&editor("hello world"));
185 assert!(frame.contains(INVERT), "{frame:?}");
186 }
187
188 #[test]
189 fn the_cursor_is_drawn_where_the_editor_actually_has_it() {
190 // The bug the signature change fixed: the binary passed a literal
191 // `Position::ZERO`, so this face drew the cursor at 1:1 for every
192 // dump regardless of the real position — and the status line printed
193 // "1:1" to match, which made the report self-consistent and wrong.
194 let mut st = editor("alpha\nbravo\ncharlie\n");
195 st.on_key(&escriba_keymap::Key::Char('j'));
196 st.on_key(&escriba_keymap::Key::Char('j'));
197 assert_eq!(st.cursor().line, 2, "precondition: the cursor moved");
198 let frame = TextRenderer.render_frame(&st);
199 assert!(
200 frame.contains("3:1"),
201 "the status line must report the REAL cursor: {frame:?}",
202 );
203 // And the inverted cell must sit on the third BODY line, not the
204 // first. Counted over body lines only — the status line is itself
205 // inverted, so including it would make any cursor position pass.
206 let body: Vec<&str> = frame.lines().filter(|l| l.contains('\u{2502}')).collect();
207 let cursor_row = body
208 .iter()
209 .position(|l| l.contains(INVERT))
210 .expect("the cursor is painted on some line");
211 assert_eq!(cursor_row, 2, "{frame:?}");
212 }
213
214 #[test]
215 fn the_gutter_matches_the_shared_model() {
216 // Three faces, one gutter. This face used to spell its own seven
217 // columns with no mark cell.
218 let st = editor("one\ntwo\n");
219 let frame = TextRenderer.render_frame(&st);
220 let first = frame.lines().next().expect("a line");
221 let rule = first
222 .chars()
223 .position(|c| c == '\u{2502}')
224 .expect("the gutter rule");
225 assert_eq!(rule + 2, escriba_ui::gutter::gutter_width(2), "{first:?}");
226 }
227
228 #[test]
229 fn the_status_line_reports_the_theme_the_editor_paints() {
230 // Not `VellumPalette::vellum()`, which is what this face read before
231 // and which is a theme escriba no longer defaults to.
232 let st = editor("x");
233 let frame = TextRenderer.render_frame(&st);
234 assert!(
235 frame.contains(&st.chrome().info.hex()),
236 "{frame:?} should carry the editor's own accent",
237 );
238 }
239}