Skip to main content

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