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,41 @@
use domain::{HAlign, VAlign};
#[allow(private_bounds)]
pub fn align_offset(container: u16, content: u16, align: impl Into<AlignMode>) -> u16 {
let mode = align.into();
if content >= container {
return 0;
}
let space = container - content;
match mode {
AlignMode::Start => 0,
AlignMode::Center => space / 2,
AlignMode::End => space,
}
}
enum AlignMode {
Start,
Center,
End,
}
impl From<HAlign> for AlignMode {
fn from(a: HAlign) -> Self {
match a {
HAlign::Left => AlignMode::Start,
HAlign::Center => AlignMode::Center,
HAlign::Right => AlignMode::End,
}
}
}
impl From<VAlign> for AlignMode {
fn from(a: VAlign) -> Self {
match a {
VAlign::Top => AlignMode::Start,
VAlign::Middle => AlignMode::Center,
VAlign::Bottom => AlignMode::End,
}
}
}

View File

@@ -0,0 +1,2 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Color(pub u8, pub u8, pub u8);

View File

@@ -0,0 +1,31 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FontSize {
Small,
Large,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FontMetrics {
pub small: (u16, u16),
pub large: (u16, u16),
}
impl FontMetrics {
pub fn char_width(&self, size: FontSize) -> u16 {
match size {
FontSize::Small => self.small.0,
FontSize::Large => self.large.0,
}
}
pub fn char_height(&self, size: FontSize) -> u16 {
match size {
FontSize::Small => self.small.1,
FontSize::Large => self.large.1,
}
}
pub fn text_width(&self, text: &str, size: FontSize) -> u16 {
text.len() as u16 * self.char_width(size)
}
}

View File

@@ -1,5 +1,5 @@
use crate::{BoundingBox, RenderTree};
use domain::{ContainerNode, Direction, LayoutNode, Sizing};
use domain::{ContainerNode, Direction, JustifyContent, LayoutNode, Sizing};
use std::collections::HashMap;
pub struct LayoutEngine;
@@ -61,10 +61,10 @@ impl LayoutEngine {
})
.sum();
let mut offset = 0u16;
for child in children {
let child_size = match child.sizing {
// Compute each child's main-axis size
let child_sizes: Vec<u16> = children
.iter()
.map(|child| match child.sizing {
Sizing::Fixed(px) => px,
Sizing::Flex(w) => {
if flex_total > 0 {
@@ -73,8 +73,22 @@ impl LayoutEngine {
0
}
}
};
})
.collect();
let children_total: u16 = child_sizes.iter().sum();
let remaining = total_axis.saturating_sub(children_total + total_gap);
// Compute starting offset and gap based on justify_content
let (mut offset, justify_gap) = Self::justify(
container.justify_content,
remaining,
container.gap as u16,
children.len(),
);
for (i, child) in children.iter().enumerate() {
let child_size = child_sizes[i];
let child_bounds = if is_row {
BoundingBox::new(inner.x + offset, inner.y, child_size, inner.height)
} else {
@@ -82,7 +96,35 @@ impl LayoutEngine {
};
Self::compute_node(&child.node, child_bounds, out);
offset += child_size + container.gap as u16;
offset += child_size + justify_gap;
}
}
fn justify(
mode: JustifyContent,
remaining: u16,
explicit_gap: u16,
count: usize,
) -> (u16, u16) {
if count == 0 {
return (0, explicit_gap);
}
match mode {
JustifyContent::Start => (0, explicit_gap),
JustifyContent::Center => (remaining / 2, explicit_gap),
JustifyContent::End => (remaining, explicit_gap),
JustifyContent::SpaceBetween => {
if count <= 1 {
return (0, explicit_gap);
}
let gap = remaining / (count as u16 - 1);
(0, explicit_gap + gap)
}
JustifyContent::SpaceEvenly => {
let slots = count as u16 + 1;
let gap = remaining / slots;
(gap, explicit_gap + gap)
}
}
}
}

View File

@@ -1,9 +1,26 @@
mod alignment;
mod bounding_box;
mod color;
mod font;
mod layout_engine;
mod markup;
pub mod ports;
mod render_engine;
mod render_tree;
mod scroll;
mod text_layout;
mod theme;
pub use alignment::align_offset;
pub use domain::{AlignItems, DisplayHintKind, HAlign, JustifyContent, VAlign};
pub use bounding_box::BoundingBox;
pub use color::Color;
pub use font::{FontMetrics, FontSize};
pub use layout_engine::LayoutEngine;
pub use markup::{parse_markup, TextSpan};
pub use render_engine::{DrawCommand, RenderEngine};
pub use ports::{ClientConfig, DisplayPort, NetworkPort, StoragePort};
pub use render_tree::RenderTree;
pub use scroll::ScrollState;
pub use text_layout::wrap_lines;
pub use theme::ThemeConfig;

View File

@@ -0,0 +1,64 @@
use crate::{Color, ThemeConfig};
#[derive(Debug, Clone, PartialEq)]
pub struct TextSpan {
pub text: String,
pub color: Color,
}
pub fn parse_markup(input: &str, theme: &ThemeConfig) -> Vec<TextSpan> {
if input.is_empty() {
return Vec::new();
}
let mut spans = Vec::new();
let mut current_color = theme.text;
let mut current_text = String::new();
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '{' {
let mut tag = String::new();
for c in chars.by_ref() {
if c == '}' {
break;
}
tag.push(c);
}
if let Some(new_color) = resolve_tag(&tag, theme) {
if !current_text.is_empty() {
spans.push(TextSpan { text: current_text.clone(), color: current_color });
current_text.clear();
}
current_color = new_color;
}
} else {
current_text.push(ch);
}
}
if !current_text.is_empty() {
spans.push(TextSpan { text: current_text, color: current_color });
}
spans
}
fn resolve_tag(tag: &str, theme: &ThemeConfig) -> Option<Color> {
match tag {
"/" => Some(theme.text),
"primary" => Some(theme.primary),
"secondary" => Some(theme.secondary),
"accent" => Some(theme.accent),
s if s.starts_with('#') && s.len() == 7 => parse_hex_color(&s[1..]),
_ => None,
}
}
fn parse_hex_color(hex: &str) -> Option<Color> {
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(Color(r, g, b))
}

View File

@@ -1,17 +1,18 @@
use crate::BoundingBox;
use crate::{BoundingBox, Color, FontSize};
pub trait DisplayPort {
type Error;
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), Self::Error>;
fn draw_text(
fn draw_text_span(
&mut self,
text: &str,
x: u16,
y: u16,
bounds: BoundingBox,
color: Color,
font: FontSize,
) -> Result<(), Self::Error>;
fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), Self::Error>;
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), Self::Error>;
fn fill_rect(&mut self, bounds: BoundingBox, color: Color) -> Result<(), Self::Error>;
fn flush(&mut self) -> Result<(), Self::Error>;
}

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,
}
}

View File

@@ -0,0 +1,82 @@
use std::time::Duration;
const PAUSE_DURATION: Duration = Duration::from_secs(2);
const SCROLL_SPEED_PX_PER_SEC: f32 = 30.0;
#[derive(Debug)]
pub struct ScrollState {
overflow: u16,
offset: f32,
direction: ScrollDirection,
pause_elapsed: Duration,
pausing: bool,
}
#[derive(Debug, PartialEq)]
enum ScrollDirection {
Forward,
Backward,
}
impl ScrollState {
pub fn new(container: u16, content: u16) -> Self {
Self {
overflow: content.saturating_sub(container),
offset: 0.0,
direction: ScrollDirection::Forward,
pause_elapsed: Duration::ZERO,
pausing: true,
}
}
pub fn is_active(&self) -> bool {
self.overflow > 0
}
pub fn offset(&self) -> u16 {
self.offset as u16
}
pub fn reset(&mut self, container: u16, content: u16) {
*self = Self::new(container, content);
}
pub fn tick(&mut self, elapsed: Duration) -> bool {
if !self.is_active() {
return false;
}
if self.pausing {
self.pause_elapsed += elapsed;
if self.pause_elapsed < PAUSE_DURATION {
return false;
}
self.pausing = false;
self.pause_elapsed = Duration::ZERO;
}
let prev_offset = self.offset as u16;
let delta = SCROLL_SPEED_PX_PER_SEC * elapsed.as_secs_f32();
match self.direction {
ScrollDirection::Forward => {
self.offset += delta;
if self.offset >= self.overflow as f32 {
self.offset = self.overflow as f32;
self.direction = ScrollDirection::Backward;
self.pausing = true;
}
}
ScrollDirection::Backward => {
self.offset -= delta;
if self.offset <= 0.0 {
self.offset = 0.0;
self.direction = ScrollDirection::Forward;
self.pausing = true;
}
}
}
self.offset as u16 != prev_offset
}
}

View File

@@ -0,0 +1,115 @@
use crate::{FontMetrics, FontSize};
pub fn wrap_lines<'a>(text: &'a str, max_width: u16, font: FontSize, metrics: &FontMetrics) -> Vec<&'a str> {
if text.is_empty() {
return Vec::new();
}
let char_w = metrics.char_width(font);
let max_chars = (max_width / char_w) as usize;
if max_chars == 0 {
return Vec::new();
}
let mut lines = Vec::new();
let mut line_start = 0;
let mut line_end = 0;
for word_start in WordStarts::new(text) {
let word_end = text[word_start..].find(' ').map_or(text.len(), |i| word_start + i);
if line_start == line_end {
// First word on this line
if word_end - word_start > max_chars {
// Word itself doesn't fit — character break
let mut pos = word_start;
while pos < word_end {
let end = (pos + max_chars).min(word_end);
lines.push(&text[pos..end]);
pos = end;
}
line_start = word_end;
line_end = word_end;
// Skip trailing space
if line_start < text.len() && text.as_bytes()[line_start] == b' ' {
line_start += 1;
line_end = line_start;
}
} else {
line_end = word_end;
}
} else {
// Adding word to existing line: line_end + " " + word
let new_len = word_end - line_start;
if new_len <= max_chars {
line_end = word_end;
} else {
// Flush current line, start new one with this word
lines.push(&text[line_start..line_end]);
if word_end - word_start > max_chars {
let mut pos = word_start;
while pos < word_end {
let end = (pos + max_chars).min(word_end);
lines.push(&text[pos..end]);
pos = end;
}
line_start = word_end;
line_end = word_end;
if line_start < text.len() && text.as_bytes()[line_start] == b' ' {
line_start += 1;
line_end = line_start;
}
} else {
line_start = word_start;
line_end = word_end;
}
}
}
}
if line_end > line_start {
lines.push(&text[line_start..line_end]);
}
lines
}
struct WordStarts<'a> {
text: &'a str,
pos: usize,
started: bool,
}
impl<'a> WordStarts<'a> {
fn new(text: &'a str) -> Self {
Self { text, pos: 0, started: false }
}
}
impl Iterator for WordStarts<'_> {
type Item = usize;
fn next(&mut self) -> Option<usize> {
if !self.started {
self.started = true;
if self.pos < self.text.len() {
return Some(0);
}
return None;
}
while self.pos < self.text.len() {
if self.text.as_bytes()[self.pos] == b' ' {
self.pos += 1;
if self.pos < self.text.len() {
let start = self.pos;
return Some(start);
}
} else {
self.pos += 1;
}
}
None
}
}

View File

@@ -0,0 +1,22 @@
use crate::Color;
#[derive(Debug, Clone, PartialEq)]
pub struct ThemeConfig {
pub primary: Color,
pub secondary: Color,
pub accent: Color,
pub text: Color,
pub background: Color,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
primary: Color(0x00, 0x7A, 0xCC),
secondary: Color(0x88, 0x88, 0x88),
accent: Color(0xE9, 0x45, 0x60),
text: Color(0xFF, 0xFF, 0xFF),
background: Color(0x00, 0x00, 0x00),
}
}
}