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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use client_domain::{BoundingBox, LayoutEngine, RenderTree};
|
||||
use domain::{AlignItems, ContainerNode, Direction, JustifyContent, LayoutChild, LayoutNode, Sizing};
|
||||
use domain::{
|
||||
AlignItems, ContainerNode, Direction, JustifyContent, LayoutChild, LayoutNode, Sizing,
|
||||
};
|
||||
|
||||
fn screen() -> BoundingBox {
|
||||
BoundingBox::screen(240, 320)
|
||||
@@ -230,8 +232,14 @@ fn justify_center_centers_fixed_children_on_main_axis() {
|
||||
});
|
||||
let tree = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(80, 0, 40, 320)));
|
||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(120, 0, 40, 320)));
|
||||
assert_eq!(
|
||||
tree.get_widget_bounds(1),
|
||||
Some(&BoundingBox::new(80, 0, 40, 320))
|
||||
);
|
||||
assert_eq!(
|
||||
tree.get_widget_bounds(2),
|
||||
Some(&BoundingBox::new(120, 0, 40, 320))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -247,7 +255,10 @@ fn justify_end_pushes_to_end() {
|
||||
});
|
||||
let tree = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(200, 0, 40, 320)));
|
||||
assert_eq!(
|
||||
tree.get_widget_bounds(1),
|
||||
Some(&BoundingBox::new(200, 0, 40, 320))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -263,9 +274,18 @@ fn justify_space_between_distributes_gaps() {
|
||||
});
|
||||
let tree = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 40, 320)));
|
||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(100, 0, 40, 320)));
|
||||
assert_eq!(tree.get_widget_bounds(3), Some(&BoundingBox::new(200, 0, 40, 320)));
|
||||
assert_eq!(
|
||||
tree.get_widget_bounds(1),
|
||||
Some(&BoundingBox::new(0, 0, 40, 320))
|
||||
);
|
||||
assert_eq!(
|
||||
tree.get_widget_bounds(2),
|
||||
Some(&BoundingBox::new(100, 0, 40, 320))
|
||||
);
|
||||
assert_eq!(
|
||||
tree.get_widget_bounds(3),
|
||||
Some(&BoundingBox::new(200, 0, 40, 320))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -282,8 +302,14 @@ fn justify_space_evenly_distributes_with_edges() {
|
||||
let tree = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
// 160 / 3 = 53px per slot
|
||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(53, 0, 40, 320)));
|
||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(146, 0, 40, 320)));
|
||||
assert_eq!(
|
||||
tree.get_widget_bounds(1),
|
||||
Some(&BoundingBox::new(53, 0, 40, 320))
|
||||
);
|
||||
assert_eq!(
|
||||
tree.get_widget_bounds(2),
|
||||
Some(&BoundingBox::new(146, 0, 40, 320))
|
||||
);
|
||||
}
|
||||
|
||||
// --- AlignItems tests ---
|
||||
@@ -315,5 +341,8 @@ fn align_items_center_centers_on_cross_axis() {
|
||||
let tree = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
// Stretch: child gets full cross-axis height
|
||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 40, 320)));
|
||||
assert_eq!(
|
||||
tree.get_widget_bounds(1),
|
||||
Some(&BoundingBox::new(0, 0, 40, 320))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,39 +7,73 @@ fn theme() -> ThemeConfig {
|
||||
#[test]
|
||||
fn plain_text_produces_single_span() {
|
||||
let spans = parse_markup("hello world", &theme());
|
||||
assert_eq!(spans, vec![
|
||||
TextSpan { text: "hello world".into(), color: theme().text },
|
||||
]);
|
||||
assert_eq!(
|
||||
spans,
|
||||
vec![TextSpan {
|
||||
text: "hello world".into(),
|
||||
color: theme().text
|
||||
},]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_color_span() {
|
||||
let spans = parse_markup("temp: {#FF0000}72°F{/}", &theme());
|
||||
assert_eq!(spans, vec![
|
||||
TextSpan { text: "temp: ".into(), color: theme().text },
|
||||
TextSpan { text: "72°F".into(), color: Color(0xFF, 0, 0) },
|
||||
]);
|
||||
assert_eq!(
|
||||
spans,
|
||||
vec![
|
||||
TextSpan {
|
||||
text: "temp: ".into(),
|
||||
color: theme().text
|
||||
},
|
||||
TextSpan {
|
||||
text: "72°F".into(),
|
||||
color: Color(0xFF, 0, 0)
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_color_spans() {
|
||||
let t = theme();
|
||||
let spans = parse_markup("{primary}hello{/} {accent}world{/}", &t);
|
||||
assert_eq!(spans, vec![
|
||||
TextSpan { text: "hello".into(), color: t.primary },
|
||||
TextSpan { text: " ".into(), color: t.text },
|
||||
TextSpan { text: "world".into(), color: t.accent },
|
||||
]);
|
||||
assert_eq!(
|
||||
spans,
|
||||
vec![
|
||||
TextSpan {
|
||||
text: "hello".into(),
|
||||
color: t.primary
|
||||
},
|
||||
TextSpan {
|
||||
text: " ".into(),
|
||||
color: t.text
|
||||
},
|
||||
TextSpan {
|
||||
text: "world".into(),
|
||||
color: t.accent
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_returns_to_text_color() {
|
||||
let t = theme();
|
||||
let spans = parse_markup("{accent}hi{/}bye", &t);
|
||||
assert_eq!(spans, vec![
|
||||
TextSpan { text: "hi".into(), color: t.accent },
|
||||
TextSpan { text: "bye".into(), color: t.text },
|
||||
]);
|
||||
assert_eq!(
|
||||
spans,
|
||||
vec![
|
||||
TextSpan {
|
||||
text: "hi".into(),
|
||||
color: t.accent
|
||||
},
|
||||
TextSpan {
|
||||
text: "bye".into(),
|
||||
color: t.text
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -52,16 +86,29 @@ fn empty_input_produces_no_spans() {
|
||||
fn adjacent_color_spans_no_text_between() {
|
||||
let t = theme();
|
||||
let spans = parse_markup("{primary}a{secondary}b{/}", &t);
|
||||
assert_eq!(spans, vec![
|
||||
TextSpan { text: "a".into(), color: t.primary },
|
||||
TextSpan { text: "b".into(), color: t.secondary },
|
||||
]);
|
||||
assert_eq!(
|
||||
spans,
|
||||
vec![
|
||||
TextSpan {
|
||||
text: "a".into(),
|
||||
color: t.primary
|
||||
},
|
||||
TextSpan {
|
||||
text: "b".into(),
|
||||
color: t.secondary
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tag_treated_as_literal() {
|
||||
let spans = parse_markup("{unknown}text", &theme());
|
||||
assert_eq!(spans, vec![
|
||||
TextSpan { text: "text".into(), color: theme().text },
|
||||
]);
|
||||
assert_eq!(
|
||||
spans,
|
||||
vec![TextSpan {
|
||||
text: "text".into(),
|
||||
color: theme().text
|
||||
},]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use client_domain::{
|
||||
BoundingBox, Color, DrawCommand, FontMetrics, FontSize, HAlign, RenderEngine,
|
||||
ThemeConfig, VAlign,
|
||||
BoundingBox, Color, DrawCommand, FontMetrics, FontSize, HAlign, RenderEngine, ThemeConfig,
|
||||
VAlign,
|
||||
};
|
||||
|
||||
fn metrics() -> FontMetrics {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use client_domain::{BoundingBox, LayoutEngine};
|
||||
use domain::{AlignItems, ContainerNode, Direction, JustifyContent, LayoutChild, LayoutNode, Sizing};
|
||||
use domain::{
|
||||
AlignItems, ContainerNode, Direction, JustifyContent, LayoutChild, LayoutNode, Sizing,
|
||||
};
|
||||
|
||||
fn screen() -> BoundingBox {
|
||||
BoundingBox::screen(240, 320)
|
||||
|
||||
Reference in New Issue
Block a user