Skip to main content

beyonder_gpu/block_renderers/
mod.rs

1pub mod agent_message;
2pub mod approval;
3pub mod shell_block;
4
5use crate::pipeline::RectInstance;
6use beyonder_core::{Block, BlockContent};
7
8/// Trait implemented by each block type's renderer.
9pub trait BlockRenderer {
10    /// Compute the height this block will occupy at the given width.
11    fn measure_height(&self, block: &Block, width: f32, font_size: f32) -> f32;
12
13    /// Emit rectangles for this block at the given position.
14    fn render_rects(
15        &self,
16        block: &Block,
17        x: f32,
18        y: f32,
19        width: f32,
20        rects: &mut Vec<RectInstance>,
21    );
22}
23
24/// Dispatch to the correct renderer based on block content.
25/// `font_size` here is the physical font size (already multiplied by scale_factor).
26pub fn measure_block_height(block: &Block, width: f32, font_size: f32) -> f32 {
27    let cmd_bar_h = font_size * 2.8; // two-row command bar (meta + command)
28    let inner_gap = font_size * 0.4; // visible gap between cmd bar and output panel
29    let header_h = font_size * 1.8; // header row for non-shell blocks
30    let line_h = font_size * 1.45;
31    let v_pad = font_size * 0.6; // bottom padding for output panel
32    match &block.content {
33        BlockContent::ShellCommand { output, .. } => {
34            // Count up to the last non-blank row — avoids huge blocks from TUI snapshots
35            // that include trailing empty rows from the original terminal grid.
36            let last_content = output
37                .rows
38                .iter()
39                .rposition(|row| {
40                    row.cells.iter().any(|c| {
41                        let fc = c.grapheme.chars().next().unwrap_or('\0');
42                        fc != ' ' && fc != '\0'
43                    })
44                })
45                .map(|i| i + 1)
46                .unwrap_or(0);
47            if last_content == 0 {
48                // No output — show only the command bar.
49                cmd_bar_h
50            } else {
51                cmd_bar_h + inner_gap + last_content as f32 * line_h + v_pad
52            }
53        }
54        BlockContent::AgentMessage { content_blocks, .. } => {
55            // Estimate wrapped line count using char_w ≈ font_size * 0.6.
56            // The renderer uses content_pad * 2 ≈ font_size * 1.0 total horizontal inset.
57            let effective_w = (width - font_size * 1.0).max(1.0);
58            let chars_per_line = ((effective_w / (font_size * 0.6)).floor() as usize).max(1);
59
60            let visual_lines: f32 = content_blocks
61                .iter()
62                .map(|cb| match cb {
63                    beyonder_core::ContentBlock::Text { text } => text
64                        .lines()
65                        .map(|line| {
66                            let stripped = strip_md_markers(line);
67                            let chars = stripped.chars().count().max(1);
68                            chars.div_ceil(chars_per_line) as f32
69                        })
70                        .sum::<f32>()
71                        .max(1.0),
72                    beyonder_core::ContentBlock::Code { code, .. } => {
73                        // +1 for the fence line
74                        code.lines()
75                            .map(|line| {
76                                let chars = line.chars().count().max(1);
77                                chars.div_ceil(chars_per_line) as f32
78                            })
79                            .sum::<f32>()
80                            .max(1.0)
81                            + 1.0
82                    }
83                    _ => 1.0,
84                })
85                .sum::<f32>()
86                .max(1.0);
87            // Running blocks get an extra row reserved for the tool/spinner indicator.
88            let extra = if matches!(block.status, beyonder_core::BlockStatus::Running) {
89                line_h * 1.5
90            } else {
91                0.0
92            };
93            // No header — just top padding + text + bottom padding.
94            v_pad + visual_lines * line_h + v_pad + extra
95        }
96        BlockContent::ApprovalRequest { .. } => font_size * 10.0,
97        BlockContent::ToolCall { output, error, .. } => {
98            let text = output.as_deref().or(error.as_deref()).unwrap_or("");
99            let lines = if text.is_empty() {
100                1.0
101            } else {
102                text.lines().count() as f32
103            };
104            header_h + lines * line_h + v_pad
105        }
106        BlockContent::Text { text } => {
107            let lines = text.lines().count().max(1) as f32;
108            v_pad + lines * line_h + v_pad
109        }
110        _ => font_size * 6.0,
111    }
112}
113
114/// Strip leading markdown markers from a line so char-count reflects visible text width.
115fn strip_md_markers(line: &str) -> String {
116    let s = line.trim_start_matches('#').trim_start();
117    let s = if s.starts_with("- ") || s.starts_with("* ") {
118        &s[2..]
119    } else {
120        s
121    };
122    // Strip bold/italic markers (**, *, __) — rough, good enough for width estimation.
123    s.replace("**", "")
124        .replace('*', "")
125        .replace("__", "")
126        .replace('`', "")
127}
128
129/// Emit rectangle draw calls for a block's background and border.
130pub fn render_block_background(
131    _block: &Block,
132    x: f32,
133    y: f32,
134    width: f32,
135    height: f32,
136    rects: &mut Vec<RectInstance>,
137) {
138    // Flat terminal background — no tint, no border.
139    rects.push(
140        RectInstance::filled(x, y, width, height, [0.118, 0.118, 0.180, 1.0]).with_radius(3.0),
141    );
142}