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_buffer::BufferSet;
21use escriba_core::Position;
22use escriba_ui::Layout;
23use ishou_tokens::VellumPalette;
24use serde::{Deserialize, Serialize};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27pub enum RenderTarget {
28    Text,
29    Gpu,
30}
31
32pub trait Renderer {
33    fn render_frame(&mut self, layout: &Layout, buffers: &BufferSet, cursor: Position) -> String;
34}
35
36pub struct TextRenderer;
37
38impl Renderer for TextRenderer {
39    fn render_frame(&mut self, layout: &Layout, buffers: &BufferSet, cursor: Position) -> String {
40        let Some(win) = layout.active_window() else {
41            return "<no window>\n".to_string();
42        };
43        let Some(buf) = buffers.get(win.buffer_id) else {
44            return "<no buffer>\n".to_string();
45        };
46        let mut out = String::new();
47        let top = win.viewport.top_line;
48        let left = win.viewport.left_column as usize;
49        let vis_cols = win.viewport.visible_columns as usize;
50        let height = win.viewport.visible_lines.max(10);
51        for row in 0..height {
52            let ln = top + row;
53            if ln >= buf.line_count() {
54                break;
55            }
56            let line = buf.line(ln).unwrap_or_default();
57            let line = line.trim_end_matches('\n').trim_end_matches('\r');
58            // Slice the line to the visible horizontal window
59            // `[left, left + vis_cols)` — char-based so multibyte text stays
60            // aligned. The cursor's on-screen column is computed relative to
61            // `left` so the cursor glyph tracks the horizontal scroll.
62            let visible: Vec<char> = line.chars().skip(left).take(vis_cols).collect();
63            if ln == cursor.line && cursor.column as usize >= left {
64                let rel = cursor.column as usize - left;
65                let before: String = visible.iter().take(rel).collect();
66                let under = visible.get(rel).copied().unwrap_or(' ');
67                let rest: String = visible.iter().skip(rel + 1).collect();
68                out.push_str(&format!(
69                    "{:4} │ {before}\x1b[7m{under}\x1b[0m{rest}\n",
70                    ln + 1,
71                ));
72            } else {
73                let visible: String = visible.iter().collect();
74                out.push_str(&format!("{:4} │ {visible}\n", ln + 1));
75            }
76        }
77        let accent = VellumPalette::vellum().ice_cyan; // Vellum matte accent
78        out.push_str(&format!(
79            "\x1b[7m escriba · {} · {}:{} \x1b[0m\n",
80            accent.hex(),
81            cursor.line + 1,
82            cursor.column + 1,
83        ));
84        out
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use escriba_buffer::BufferSet;
92    use escriba_core::{BufferId, WindowId};
93    use escriba_ui::{Layout, Rect, Viewport, Window};
94
95    #[test]
96    fn renders_buffer_lines() {
97        let mut bufs = BufferSet::new();
98        let id = bufs.scratch("hello\nworld\nfoo");
99        let _ = BufferId::default();
100        let layout = Layout::single(Window {
101            id: WindowId(1),
102            buffer_id: id,
103            viewport: Viewport {
104                top_line: 0,
105                left_column: 0,
106                visible_lines: 20,
107                visible_columns: 80,
108            },
109            rect: Rect::default(),
110        });
111        let mut r = TextRenderer;
112        // Cursor on line 2 so other lines render without ANSI splitting "hello".
113        let frame = r.render_frame(&layout, &bufs, Position::new(2, 0));
114        assert!(frame.contains("hello"));
115        assert!(frame.contains("world"));
116    }
117
118    #[test]
119    fn cursor_is_highlighted() {
120        let mut bufs = BufferSet::new();
121        let id = bufs.scratch("hello world");
122        let layout = Layout::single(Window {
123            id: WindowId(1),
124            buffer_id: id,
125            viewport: Viewport {
126                top_line: 0,
127                left_column: 0,
128                visible_lines: 10,
129                visible_columns: 80,
130            },
131            rect: Rect::default(),
132        });
133        let mut r = TextRenderer;
134        let frame = r.render_frame(&layout, &bufs, Position::new(0, 3));
135        assert!(frame.contains("\x1b[7m"));
136    }
137}