theme config, layout preview, container alignment

Server: ThemeConfig entity + CRUD (GET/PUT /theme), SQLite persistence,
ThemeUpdate broadcast to ESP32 on save and initial connect.
Client: render engine uses theme colors, full-screen redraw on theme change.
SPA: theme page with color pickers + presets, layout preview with TS port
of layout engine, justify/align controls on containers.
DisplayHint refactored to struct (kind + h_align + v_align).
This commit is contained in:
2026-06-19 03:26:18 +02:00
parent 81a4167382
commit fe59b68c37
46 changed files with 1276 additions and 118 deletions

View File

@@ -12,14 +12,14 @@ 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 domain::{AlignItems, DisplayHintKind, HAlign, JustifyContent, VAlign};
pub use font::{FontMetrics, FontSize};
pub use layout_engine::LayoutEngine;
pub use markup::{parse_markup, TextSpan};
pub use render_engine::{DrawCommand, RenderEngine};
pub use markup::{TextSpan, parse_markup};
pub use ports::{ClientConfig, DisplayPort, NetworkPort, StoragePort};
pub use render_engine::{DrawCommand, RenderEngine};
pub use render_tree::RenderTree;
pub use scroll::ScrollState;
pub use text_layout::wrap_lines;

View File

@@ -28,7 +28,10 @@ pub fn parse_markup(input: &str, theme: &ThemeConfig) -> Vec<TextSpan> {
if let Some(new_color) = resolve_tag(&tag, theme) {
if !current_text.is_empty() {
spans.push(TextSpan { text: current_text.clone(), color: current_color });
spans.push(TextSpan {
text: current_text.clone(),
color: current_color,
});
current_text.clear();
}
current_color = new_color;
@@ -39,7 +42,10 @@ pub fn parse_markup(input: &str, theme: &ThemeConfig) -> Vec<TextSpan> {
}
if !current_text.is_empty() {
spans.push(TextSpan { text: current_text, color: current_color });
spans.push(TextSpan {
text: current_text,
color: current_color,
});
}
spans

View File

@@ -1,6 +1,6 @@
use crate::{
BoundingBox, Color, FontMetrics, FontSize, ThemeConfig,
alignment::align_offset, markup::parse_markup, text_layout::wrap_lines,
BoundingBox, Color, FontMetrics, FontSize, ThemeConfig, alignment::align_offset,
markup::parse_markup, text_layout::wrap_lines,
};
use domain::{DisplayHint, DisplayHintKind, HAlign, VAlign, Value};
@@ -62,7 +62,8 @@ impl RenderEngine {
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);
let seg_offset =
(char_pos - line_start) as u16 * self.metrics.char_width(FontSize::Small);
cmds.push(DrawCommand {
text: segment.to_string(),
@@ -100,18 +101,16 @@ impl RenderEngine {
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.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 {
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()
@@ -123,35 +122,33 @@ impl RenderEngine {
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::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!("{{primary}}{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);
}
if k == "icon"
&& 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);
}
if k != "icon"
&& let Some(s) = value_to_string(v)
{
parts.push(s);
}
}
parts.join(" ")
@@ -187,7 +184,11 @@ fn value_to_string(v: &Value) -> Option<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(", ")) }
if items.is_empty() {
None
} else {
Some(items.join(", "))
}
}
Value::Object(_) => None,
}

View File

@@ -1,6 +1,11 @@
use crate::{FontMetrics, FontSize};
pub fn wrap_lines<'a>(text: &'a str, max_width: u16, font: FontSize, metrics: &FontMetrics) -> Vec<&'a str> {
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();
}
@@ -17,7 +22,9 @@ pub fn wrap_lines<'a>(text: &'a str, max_width: u16, font: FontSize, metrics: &F
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);
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
@@ -83,7 +90,11 @@ struct WordStarts<'a> {
impl<'a> WordStarts<'a> {
fn new(text: &'a str) -> Self {
Self { text, pos: 0, started: false }
Self {
text,
pos: 0,
started: false,
}
}
}