195 lines
6.1 KiB
Rust
195 lines
6.1 KiB
Rust
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,
|
|
}
|
|
}
|