1extern crate self as escriba_render;
10
11pub mod gpu;
12pub mod langs;
13pub 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 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 let chrome = state.chrome();
55 let cursor = state.cursor();
56 let world = state.world();
57 let line_count = buf.line_count();
58 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 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
117const INVERT: &str = "\x1b[7m";
120const RESET: &str = "\x1b[0m";
121
122fn push_fg(out: &mut String, c: ishou_tokens::Rgb) {
128 use std::fmt::Write as _;
129 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 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 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 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 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 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 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}