new rendering engine

This commit is contained in:
2026-06-19 02:55:33 +02:00
parent 0a90d6a5d7
commit 81a4167382
53 changed files with 1668 additions and 378 deletions

View File

@@ -0,0 +1,194 @@
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<DrawCommand> {
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<DrawCommand> {
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::<Vec<_>>()
.join("\n")
}
DisplayHintKind::KeyValue => {
data.iter()
.filter_map(|(k, v)| {
let val = value_to_string(v)?;
Some(format!("{{secondary}}{k}{{/}}: {val}"))
})
.collect::<Vec<_>>()
.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<String> {
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<String> = arr.iter().filter_map(value_to_string).collect();
if items.is_empty() { None } else { Some(items.join(", ")) }
}
Value::Object(_) => None,
}
}