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