1extern crate self as escriba_render;
10
11pub mod gpu;
12pub use escriba_ts::langs;
15pub 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 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 let chrome = state.chrome();
57 let cursor = state.cursor();
58 let world = state.world();
59 let line_count = buf.line_count();
60 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 let mark = state.results.worst_on_line(&world, state.active, ln);
78 for cell in escriba_ui::gutter::gutter_cells(ln, mark, line_count) {
79 match cell.role {
80 escriba_ui::gutter::GutterRole::Mark(sev) => {
81 push_fg(&mut out, escriba_ui::chrome::severity_color(&chrome, sev));
82 out.push_str(&cell.text);
83 out.push_str(RESET);
84 }
85 _ => out.push_str(&cell.text),
86 }
87 }
88 let visible: Vec<char> = line.chars().skip(left).take(vis_cols).collect();
93 if ln == cursor.line && cursor.column as usize >= left {
94 let rel = cursor.column as usize - left;
95 out.extend(visible.iter().take(rel));
96 out.push_str(INVERT);
97 out.push(visible.get(rel).copied().unwrap_or(' '));
98 out.push_str(RESET);
99 out.extend(visible.iter().skip(rel + 1));
100 } else {
101 out.extend(visible.iter());
102 }
103 out.push('\n');
104 }
105 out.push_str(INVERT);
106 out.push_str(" escriba · ");
107 out.push_str(&chrome.info.hex());
108 out.push_str(" · ");
109 out.push_str(&(cursor.line + 1).to_string());
110 out.push(':');
111 out.push_str(&(cursor.column + 1).to_string());
112 out.push(' ');
113 out.push_str(RESET);
114 out.push('\n');
115 out
116 }
117}
118
119const INVERT: &str = "\x1b[7m";
122const RESET: &str = "\x1b[0m";
123
124fn push_fg(out: &mut String, c: ishou_tokens::Rgb) {
130 use std::fmt::Write as _;
131 let _ = write!(out, "\x1b[38;2;{};{};{}m", c.r, c.g, c.b);
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use escriba_buffer::BufferSet;
140
141 fn editor(text: &str) -> EditorState {
147 let mut bufs = BufferSet::new();
148 let id = bufs.scratch(text);
149 let mut st = EditorState::new_with_buffer(bufs, id);
150 st.dismiss_splash();
151 if let Some(w) = st.layout.active_window_mut() {
152 w.viewport.visible_lines = 20;
153 w.viewport.visible_columns = 80;
154 }
155 st
156 }
157
158 #[test]
159 fn renders_buffer_lines() {
160 let mut st = editor("hello\nworld\nfoo");
166 st.on_key(&escriba_keymap::Key::Char('j'));
167 st.on_key(&escriba_keymap::Key::Char('j'));
168 let frame = TextRenderer.render_frame(&st);
169 assert!(frame.contains("hello"), "{frame:?}");
170 assert!(frame.contains("world"), "{frame:?}");
171 }
172
173 #[test]
174 fn cursor_is_highlighted() {
175 let frame = TextRenderer.render_frame(&editor("hello world"));
176 assert!(frame.contains(INVERT), "{frame:?}");
177 }
178
179 #[test]
180 fn the_cursor_is_drawn_where_the_editor_actually_has_it() {
181 let mut st = editor("alpha\nbravo\ncharlie\n");
186 st.on_key(&escriba_keymap::Key::Char('j'));
187 st.on_key(&escriba_keymap::Key::Char('j'));
188 assert_eq!(st.cursor().line, 2, "precondition: the cursor moved");
189 let frame = TextRenderer.render_frame(&st);
190 assert!(
191 frame.contains("3:1"),
192 "the status line must report the REAL cursor: {frame:?}",
193 );
194 let body: Vec<&str> = frame.lines().filter(|l| l.contains('\u{2502}')).collect();
198 let cursor_row = body
199 .iter()
200 .position(|l| l.contains(INVERT))
201 .expect("the cursor is painted on some line");
202 assert_eq!(cursor_row, 2, "{frame:?}");
203 }
204
205 #[test]
206 fn the_gutter_matches_the_shared_model() {
207 let st = editor("one\ntwo\n");
210 let frame = TextRenderer.render_frame(&st);
211 let first = frame.lines().next().expect("a line");
212 let rule = first
213 .chars()
214 .position(|c| c == '\u{2502}')
215 .expect("the gutter rule");
216 assert_eq!(rule + 2, escriba_ui::gutter::gutter_width(2), "{first:?}");
217 }
218
219 #[test]
220 fn the_status_line_reports_the_theme_the_editor_paints() {
221 let st = editor("x");
224 let frame = TextRenderer.render_frame(&st);
225 assert!(
226 frame.contains(&st.chrome().info.hex()),
227 "{frame:?} should carry the editor's own accent",
228 );
229 }
230}