new rendering engine
This commit is contained in:
194
crates/client-domain/src/render_engine.rs
Normal file
194
crates/client-domain/src/render_engine.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user