use crate::{ BoundingBox, Color, FontMetrics, FontSize, ThemeConfig, alignment::align_offset, markup::parse_markup, text_layout::wrap_lines, }; use domain::{DisplayHint, DisplayHintKind, HAlign, VAlign, Value}; #[derive(Debug, Clone, PartialEq)] pub struct DrawCommand { pub text: String, pub x: u16, pub y: u16, pub color: Color, pub font: FontSize, } pub struct RenderEngine { metrics: FontMetrics, theme: ThemeConfig, } impl RenderEngine { pub fn new(metrics: FontMetrics, theme: ThemeConfig) -> Self { Self { metrics, theme } } pub fn theme(&self) -> &ThemeConfig { &self.theme } pub fn set_theme(&mut self, theme: ThemeConfig) { self.theme = theme; } pub fn render_text( &self, text: &str, bounds: BoundingBox, h_align: HAlign, v_align: VAlign, ) -> Vec { let spans = parse_markup(text, &self.theme); let plain: String = spans.iter().map(|s| s.text.as_str()).collect(); let lines = wrap_lines(&plain, bounds.width, FontSize::Small, &self.metrics); let line_h = self.metrics.char_height(FontSize::Small); let total_h = lines.len() as u16 * line_h; let y_offset = align_offset(bounds.height, total_h, v_align); let mut cmds = Vec::new(); let mut plain_pos = 0usize; for (line_idx, line) in lines.iter().enumerate() { let line_w = self.metrics.text_width(line, FontSize::Small); let x_offset = align_offset(bounds.width, line_w, h_align); let y = bounds.y + y_offset + line_idx as u16 * line_h; let line_start = plain_pos; let line_end = line_start + line.len(); // Map line characters back to colored spans let mut char_pos = line_start; while char_pos < line_end { let (color, span_end) = self.color_at(&spans, char_pos, line_end, &plain); let segment = &plain[char_pos..span_end]; let seg_offset = (char_pos - line_start) as u16 * self.metrics.char_width(FontSize::Small); cmds.push(DrawCommand { text: segment.to_string(), x: bounds.x + x_offset + seg_offset, y, color, font: FontSize::Small, }); char_pos = span_end; } plain_pos = line_end; // Skip whitespace between lines (the space that caused the wrap) if plain_pos < plain.len() && plain.as_bytes()[plain_pos] == b' ' { plain_pos += 1; } } cmds } pub fn render_widget( &self, hint: &DisplayHint, data: &[(String, Value)], bounds: BoundingBox, scroll_offset: u16, ) -> Vec { let text = self.format_widget(hint, data); let mut cmds = self.render_text(&text, bounds, hint.h_align, hint.v_align); if scroll_offset > 0 { for cmd in &mut cmds { cmd.y = cmd.y.saturating_sub(scroll_offset); } // Drop commands that scrolled above bounds cmds.retain(|cmd| cmd.y + self.metrics.char_height(cmd.font) > bounds.y && cmd.y < bounds.y + bounds.height); } cmds } pub fn content_height( &self, hint: &DisplayHint, data: &[(String, Value)], width: u16, ) -> u16 { let text = self.format_widget(hint, data); let plain: String = parse_markup(&text, &self.theme) .iter() .map(|s| s.text.as_str()) .collect(); let lines = wrap_lines(&plain, width, FontSize::Small, &self.metrics); lines.len() as u16 * self.metrics.char_height(FontSize::Small) } fn format_widget(&self, hint: &DisplayHint, data: &[(String, Value)]) -> String { match hint.kind { DisplayHintKind::TextBlock => { data.iter() .filter_map(|(_, v)| value_to_string(v)) .collect::>() .join("\n") } DisplayHintKind::KeyValue => { data.iter() .filter_map(|(k, v)| { let val = value_to_string(v)?; Some(format!("{{secondary}}{k}{{/}}: {val}")) }) .collect::>() .join("\n") } DisplayHintKind::IconValue => { let mut parts = Vec::new(); for (k, v) in data { if k == "icon" { if let Some(s) = value_to_string(v) { parts.push(s); } } } for (k, v) in data { if k != "icon" { if let Some(s) = value_to_string(v) { parts.push(s); } } } parts.join(" ") } } } fn color_at( &self, spans: &[crate::markup::TextSpan], pos: usize, line_end: usize, _plain: &str, ) -> (Color, usize) { let mut offset = 0usize; for span in spans { let span_end = offset + span.text.len(); if pos >= offset && pos < span_end { let end = span_end.min(line_end); return (span.color, end); } offset = span_end; } (self.theme.text, line_end) } } fn value_to_string(v: &Value) -> Option { match v { Value::String(s) => Some(s.clone()), Value::Number(n) => Some(n.to_string()), Value::Bool(b) => Some(b.to_string()), Value::Null => None, Value::Array(arr) => { let items: Vec = arr.iter().filter_map(value_to_string).collect(); if items.is_empty() { None } else { Some(items.join(", ")) } } Value::Object(_) => None, } }