Skip to main content

batuta/oracle/svg/renderers/
text_heavy.rs

1//! Text-Heavy Renderer
2//!
3//! Optimized for documentation diagrams and flowcharts with lots of text.
4
5use crate::oracle::svg::builder::SvgBuilder;
6use crate::oracle::svg::grid_protocol::LayoutTemplate;
7use crate::oracle::svg::layout::{Viewport, GRID_SIZE};
8use crate::oracle::svg::palette::SovereignPalette;
9// Point may be needed for future layout features
10#[allow(unused_imports)]
11use crate::oracle::svg::shapes::Point;
12// Typography types available for advanced text configuration
13#[allow(unused_imports)]
14use crate::oracle::svg::typography::{FontWeight, TextAlign, TextStyle};
15
16/// Text-heavy renderer for documentation diagrams
17#[derive(Debug)]
18pub struct TextHeavyRenderer {
19    /// SVG builder
20    builder: SvgBuilder,
21    /// Palette
22    palette: SovereignPalette,
23    /// Current y position for sequential text
24    current_y: f32,
25    /// Line height
26    line_height: f32,
27    /// Left margin
28    margin_left: f32,
29}
30
31impl TextHeavyRenderer {
32    /// Create a new text-heavy renderer
33    pub fn new() -> Self {
34        let viewport = Viewport::document();
35        Self {
36            builder: SvgBuilder::new().viewport(viewport),
37            palette: SovereignPalette::light(),
38            current_y: viewport.padding + GRID_SIZE * 4.0,
39            line_height: GRID_SIZE * 3.0, // 24px
40            margin_left: viewport.padding + GRID_SIZE * 2.0,
41        }
42    }
43
44    /// Set the viewport
45    pub fn viewport(mut self, viewport: Viewport) -> Self {
46        self.current_y = viewport.padding + GRID_SIZE * 4.0;
47        self.margin_left = viewport.padding + GRID_SIZE * 2.0;
48        self.builder = self.builder.viewport(viewport);
49        self
50    }
51
52    /// Use dark mode
53    pub fn dark_mode(mut self) -> Self {
54        self.palette = SovereignPalette::dark();
55        self.builder = self.builder.dark_mode();
56        self
57    }
58
59    /// Set line height
60    pub fn line_height(mut self, height: f32) -> Self {
61        self.line_height = height;
62        self
63    }
64
65    /// Add a title
66    pub fn title(mut self, text: &str) -> Self {
67        self.builder = self.builder.title(text);
68
69        let style = self
70            .builder
71            .get_typography()
72            .headline_large
73            .clone()
74            .with_color(self.palette.material.on_background);
75
76        self.builder = self.builder.text_styled(self.margin_left, self.current_y, text, style);
77        self.current_y += GRID_SIZE * 6.0; // Extra space after title
78
79        self
80    }
81
82    /// Add a section heading
83    pub fn heading(mut self, text: &str) -> Self {
84        self.current_y += GRID_SIZE * 2.0; // Space before heading
85
86        let style = self
87            .builder
88            .get_typography()
89            .headline_small
90            .clone()
91            .with_color(self.palette.material.on_background);
92
93        self.builder = self.builder.text_styled(self.margin_left, self.current_y, text, style);
94        self.current_y += GRID_SIZE * 4.0;
95
96        self
97    }
98
99    /// Add a paragraph of text
100    pub fn paragraph(mut self, text: &str) -> Self {
101        let style = self
102            .builder
103            .get_typography()
104            .body_medium
105            .clone()
106            .with_color(self.palette.material.on_surface);
107
108        // Simple word wrapping (basic implementation)
109        let max_width = 600.0; // Approximate max text width
110        let words: Vec<&str> = text.split_whitespace().collect();
111        let mut current_line = String::new();
112        let char_width = 8.0; // Approximate character width at 14px
113
114        for word in words {
115            let test_line = if current_line.is_empty() {
116                word.to_string()
117            } else {
118                format!("{} {}", current_line, word)
119            };
120
121            if test_line.len() as f32 * char_width > max_width && !current_line.is_empty() {
122                // Output current line and start new one
123                self.builder = self.builder.text_styled(
124                    self.margin_left,
125                    self.current_y,
126                    &current_line,
127                    style.clone(),
128                );
129                self.current_y += self.line_height;
130                current_line = word.to_string();
131            } else {
132                current_line = test_line;
133            }
134        }
135
136        // Output remaining text
137        if !current_line.is_empty() {
138            self.builder =
139                self.builder.text_styled(self.margin_left, self.current_y, &current_line, style);
140            self.current_y += self.line_height;
141        }
142
143        self.current_y += GRID_SIZE; // Extra space after paragraph
144
145        self
146    }
147
148    /// Add a bullet point
149    pub fn bullet(mut self, text: &str) -> Self {
150        let style = self
151            .builder
152            .get_typography()
153            .body_medium
154            .clone()
155            .with_color(self.palette.material.on_surface);
156
157        // Bullet character
158        self.builder =
159            self.builder.text_styled(self.margin_left, self.current_y, "•", style.clone());
160
161        // Text after bullet
162        self.builder = self.builder.text_styled(
163            self.margin_left + GRID_SIZE * 2.0,
164            self.current_y,
165            text,
166            style,
167        );
168
169        self.current_y += self.line_height;
170
171        self
172    }
173
174    /// Add a numbered item
175    pub fn numbered(mut self, number: u32, text: &str) -> Self {
176        let style = self
177            .builder
178            .get_typography()
179            .body_medium
180            .clone()
181            .with_color(self.palette.material.on_surface);
182
183        // Number
184        self.builder = self.builder.text_styled(
185            self.margin_left,
186            self.current_y,
187            &format!("{}.", number),
188            style.clone(),
189        );
190
191        // Text after number
192        self.builder = self.builder.text_styled(
193            self.margin_left + GRID_SIZE * 3.0,
194            self.current_y,
195            text,
196            style,
197        );
198
199        self.current_y += self.line_height;
200
201        self
202    }
203
204    /// Add a code block
205    pub fn code(mut self, code: &str) -> Self {
206        self.current_y += GRID_SIZE;
207
208        // Code background
209        let bg_color = self.palette.material.surface_variant;
210        let lines: Vec<&str> = code.lines().collect();
211        let code_height = (lines.len() as f32 * self.line_height) + GRID_SIZE * 2.0;
212
213        self.builder = self.builder.rect_styled(
214            "_code_bg",
215            self.margin_left,
216            self.current_y,
217            500.0,
218            code_height,
219            bg_color,
220            None,
221            GRID_SIZE / 2.0,
222        );
223
224        self.current_y += GRID_SIZE;
225
226        let style =
227            self.builder.get_typography().code.clone().with_color(self.palette.material.on_surface);
228
229        for line in lines {
230            self.builder = self.builder.text_styled(
231                self.margin_left + GRID_SIZE,
232                self.current_y,
233                line,
234                style.clone(),
235            );
236            self.current_y += self.line_height;
237        }
238
239        self.current_y += GRID_SIZE * 2.0; // Extra space after code
240
241        self
242    }
243
244    /// Add a labeled box (for key-value pairs)
245    pub fn labeled_box(mut self, label: &str, value: &str) -> Self {
246        let label_style = self
247            .builder
248            .get_typography()
249            .label_medium
250            .clone()
251            .with_color(self.palette.material.on_surface_variant);
252
253        let value_style = self
254            .builder
255            .get_typography()
256            .body_medium
257            .clone()
258            .with_color(self.palette.material.on_surface);
259
260        // Label
261        self.builder =
262            self.builder.text_styled(self.margin_left, self.current_y, label, label_style);
263
264        // Value
265        self.builder = self.builder.text_styled(
266            self.margin_left + GRID_SIZE * 15.0,
267            self.current_y,
268            value,
269            value_style,
270        );
271
272        self.current_y += self.line_height;
273
274        self
275    }
276
277    /// Add a divider line
278    pub fn divider(mut self) -> Self {
279        self.current_y += GRID_SIZE;
280
281        self.builder = self.builder.line_styled(
282            self.margin_left,
283            self.current_y,
284            self.margin_left + 600.0,
285            self.current_y,
286            self.palette.material.outline_variant,
287            1.0,
288        );
289
290        self.current_y += GRID_SIZE * 2.0;
291
292        self
293    }
294
295    /// Add vertical space
296    pub fn space(mut self, lines: u32) -> Self {
297        self.current_y += self.line_height * lines as f32;
298        self
299    }
300
301    /// Enable grid protocol mode with video palette and typography.
302    pub fn grid_protocol(mut self) -> Self {
303        self.builder = self.builder.grid_protocol().video_styles();
304        self.palette = SovereignPalette::dark();
305        self
306    }
307
308    /// Apply a layout template, allocating all regions.
309    pub fn template(mut self, template: LayoutTemplate) -> Self {
310        if !self.builder.is_grid_mode() {
311            self = self.grid_protocol();
312        }
313
314        let allocations = template.allocations();
315        for (name, span) in allocations {
316            let _ = self.builder.allocate(name, span);
317        }
318
319        self
320    }
321
322    /// Build the SVG
323    pub fn build(self) -> String {
324        self.builder.build()
325    }
326}
327
328impl Default for TextHeavyRenderer {
329    fn default() -> Self {
330        Self::new()
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_text_heavy_renderer_creation() {
340        let renderer = TextHeavyRenderer::new();
341        assert_eq!(renderer.line_height, 24.0);
342    }
343
344    #[test]
345    fn test_text_heavy_title() {
346        let svg = TextHeavyRenderer::new().title("Documentation").build();
347
348        assert!(svg.contains("<title>Documentation</title>"));
349        assert!(svg.contains("Documentation"));
350    }
351
352    #[test]
353    fn test_text_heavy_heading() {
354        let svg = TextHeavyRenderer::new().title("Doc").heading("Section 1").build();
355
356        assert!(svg.contains("Section 1"));
357    }
358
359    #[test]
360    fn test_text_heavy_paragraph() {
361        let svg = TextHeavyRenderer::new()
362            .paragraph("This is a test paragraph with some text content.")
363            .build();
364
365        assert!(svg.contains("test paragraph"));
366    }
367
368    #[test]
369    fn test_text_heavy_bullets() {
370        let svg = TextHeavyRenderer::new().bullet("First item").bullet("Second item").build();
371
372        assert!(svg.contains("First item"));
373        assert!(svg.contains("Second item"));
374        assert!(svg.contains("•"));
375    }
376
377    #[test]
378    fn test_text_heavy_numbered() {
379        let svg =
380            TextHeavyRenderer::new().numbered(1, "First step").numbered(2, "Second step").build();
381
382        assert!(svg.contains("1."));
383        assert!(svg.contains("First step"));
384    }
385
386    #[test]
387    fn test_text_heavy_code() {
388        let svg = TextHeavyRenderer::new().code("let x = 42;\nprintln!(\"{}\", x);").build();
389
390        assert!(svg.contains("let x = 42"));
391    }
392
393    #[test]
394    fn test_text_heavy_labeled_box() {
395        let svg = TextHeavyRenderer::new()
396            .labeled_box("Version:", "1.0.0")
397            .labeled_box("Author:", "John Doe")
398            .build();
399
400        assert!(svg.contains("Version:"));
401        assert!(svg.contains("1.0.0"));
402    }
403
404    #[test]
405    fn test_text_heavy_divider() {
406        let svg = TextHeavyRenderer::new().divider().build();
407
408        assert!(svg.contains("<line"));
409    }
410
411    #[test]
412    fn test_text_heavy_dark_mode() {
413        let renderer = TextHeavyRenderer::new().dark_mode();
414        let _svg = renderer.build();
415        // Just verify it doesn't panic
416    }
417
418    #[test]
419    fn test_text_heavy_complete_document() {
420        let svg = TextHeavyRenderer::new()
421            .title("API Documentation")
422            .paragraph("This document describes the API.")
423            .heading("Endpoints")
424            .bullet("GET /api/users")
425            .bullet("POST /api/users")
426            .divider()
427            .heading("Examples")
428            .code("curl https://api.example.com/users")
429            .build();
430
431        assert!(svg.contains("API Documentation"));
432        assert!(svg.contains("Endpoints"));
433        assert!(svg.contains("GET /api/users"));
434    }
435
436    #[test]
437    fn test_text_heavy_default() {
438        let renderer = TextHeavyRenderer::default();
439        assert_eq!(renderer.line_height, 24.0);
440    }
441
442    #[test]
443    fn test_text_heavy_viewport() {
444        let viewport = Viewport::new(800.0, 600.0);
445        let renderer = TextHeavyRenderer::new().viewport(viewport);
446        let svg = renderer.build();
447        // Should contain some width indicator
448        assert!(svg.contains("<svg"));
449    }
450
451    #[test]
452    fn test_text_heavy_line_height() {
453        let renderer = TextHeavyRenderer::new().line_height(32.0);
454        assert_eq!(renderer.line_height, 32.0);
455    }
456
457    #[test]
458    fn test_text_heavy_space() {
459        let initial_y = TextHeavyRenderer::new().current_y;
460        let renderer = TextHeavyRenderer::new().space(2);
461        // Space should increase current_y
462        assert!(renderer.current_y > initial_y);
463    }
464
465    #[test]
466    fn test_text_heavy_long_paragraph() {
467        // Test word wrapping with a long paragraph
468        let long_text = "This is a very long paragraph that should trigger word wrapping because it exceeds the maximum width allowed for a single line in the text renderer.";
469        let svg = TextHeavyRenderer::new().paragraph(long_text).build();
470        // Should contain multiple text elements due to wrapping
471        assert!(svg.contains("paragraph"));
472    }
473
474    #[test]
475    fn test_text_heavy_empty_paragraph() {
476        let svg = TextHeavyRenderer::new().paragraph("").build();
477        // Should not crash on empty input
478        assert!(svg.contains("<svg"));
479    }
480
481    #[test]
482    fn test_text_heavy_multiline_code() {
483        let code = "line 1\nline 2\nline 3";
484        let svg = TextHeavyRenderer::new().code(code).build();
485        assert!(svg.contains("line 1"));
486        assert!(svg.contains("line 2"));
487        assert!(svg.contains("line 3"));
488    }
489
490    #[test]
491    fn test_text_heavy_chain_methods() {
492        let svg = TextHeavyRenderer::new()
493            .line_height(28.0)
494            .title("Test")
495            .space(1)
496            .paragraph("Content")
497            .divider()
498            .bullet("Item")
499            .numbered(1, "Step")
500            .code("code")
501            .labeled_box("Key", "Value")
502            .build();
503
504        assert!(svg.contains("Test"));
505        assert!(svg.contains("Content"));
506        assert!(svg.contains("Item"));
507    }
508
509    #[test]
510    fn test_text_heavy_viewport_updates_margins() {
511        let viewport = Viewport::new(1024.0, 768.0);
512        let renderer = TextHeavyRenderer::new().viewport(viewport);
513        // Margins should be updated based on viewport padding
514        assert!(renderer.margin_left > 0.0);
515    }
516
517    #[test]
518    fn test_text_heavy_viewport_document() {
519        let viewport = Viewport::document();
520        let renderer = TextHeavyRenderer::new().viewport(viewport);
521        let svg = renderer.build();
522        assert!(svg.contains("<svg"));
523    }
524
525    #[test]
526    fn test_text_heavy_heading_increases_y() {
527        let initial = TextHeavyRenderer::new();
528        let after_heading = TextHeavyRenderer::new().heading("Section");
529        // Heading should increase y position
530        assert!(after_heading.current_y > initial.current_y);
531    }
532
533    #[test]
534    fn test_text_heavy_bullet_increases_y() {
535        let initial = TextHeavyRenderer::new();
536        let after_bullet = TextHeavyRenderer::new().bullet("Item");
537        assert!(after_bullet.current_y > initial.current_y);
538    }
539
540    #[test]
541    fn test_text_heavy_numbered_increases_y() {
542        let initial = TextHeavyRenderer::new();
543        let after_numbered = TextHeavyRenderer::new().numbered(1, "Step");
544        assert!(after_numbered.current_y > initial.current_y);
545    }
546
547    // ── Grid Protocol Renderer Tests ───────────────────────────────────
548
549    #[test]
550    fn test_text_heavy_grid_protocol() {
551        let svg = TextHeavyRenderer::new().grid_protocol().title("Grid Doc").build();
552
553        assert!(svg.contains("viewBox=\"0 0 1920 1080\""));
554        assert!(svg.contains("GRID PROTOCOL MANIFEST"));
555    }
556
557    #[test]
558    fn test_text_heavy_template() {
559        let svg = TextHeavyRenderer::new().template(LayoutTemplate::TwoColumn).build();
560
561        assert!(svg.contains("GRID PROTOCOL MANIFEST"));
562        assert!(svg.contains("\"header\""));
563        assert!(svg.contains("\"left\""));
564        assert!(svg.contains("\"right\""));
565    }
566
567    #[test]
568    fn test_text_heavy_template_auto_enables_grid() {
569        let svg = TextHeavyRenderer::new().template(LayoutTemplate::ReflectionReadings).build();
570
571        assert!(svg.contains("viewBox=\"0 0 1920 1080\""));
572    }
573}