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

@@ -1,5 +1,5 @@
use crate::error::SqliteConfigError;
use domain::{ContainerNode, Direction, Layout, LayoutChild, LayoutNode, Sizing};
use domain::{AlignItems, ContainerNode, Direction, JustifyContent, Layout, LayoutChild, LayoutNode, Sizing};
pub fn layout_to_json(layout: &Layout) -> Result<String, SqliteConfigError> {
let v = node_to_json(&layout.root);
@@ -97,6 +97,8 @@ fn node_from_json(v: &serde_json::Value) -> Result<LayoutNode, SqliteConfigError
direction,
gap,
padding,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children,
}))
}

View File

@@ -1,21 +1,21 @@
use crate::error::SqliteConfigError;
use domain::{DisplayHint, KeyMapping, WidgetConfig};
use domain::{DisplayHint, DisplayHintKind, KeyMapping, WidgetConfig};
use sqlx::Row;
use sqlx::sqlite::SqliteRow;
pub fn display_hint_to_str(hint: &DisplayHint) -> &'static str {
match hint {
DisplayHint::IconValue => "icon_value",
DisplayHint::TextBlock => "text_block",
DisplayHint::KeyValue => "key_value",
match hint.kind {
DisplayHintKind::IconValue => "icon_value",
DisplayHintKind::TextBlock => "text_block",
DisplayHintKind::KeyValue => "key_value",
}
}
fn display_hint_from_str(s: &str) -> Result<DisplayHint, SqliteConfigError> {
match s {
"icon_value" => Ok(DisplayHint::IconValue),
"text_block" => Ok(DisplayHint::TextBlock),
"key_value" => Ok(DisplayHint::KeyValue),
"icon_value" => Ok(DisplayHint::new(DisplayHintKind::IconValue)),
"text_block" => Ok(DisplayHint::new(DisplayHintKind::TextBlock)),
"key_value" => Ok(DisplayHint::new(DisplayHintKind::KeyValue)),
_ => Err(SqliteConfigError::Serialization(format!(
"unknown display hint: {s}"
))),

View File

@@ -1,7 +1,8 @@
use config_sqlite::SqliteConfigStore;
use domain::{
ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType, Direction,
DisplayHint, KeyMapping, Layout, LayoutChild, LayoutNode, LayoutPreset, Sizing, WidgetConfig,
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild, LayoutNode,
LayoutPreset, Sizing, WidgetConfig,
};
use std::time::Duration;
@@ -13,7 +14,7 @@ fn weather_widget() -> WidgetConfig {
WidgetConfig {
id: 1,
name: "weather".into(),
display_hint: DisplayHint::IconValue,
display_hint: DisplayHint::new(DisplayHintKind::IconValue),
data_source_id: 10,
mappings: vec![
KeyMapping {
@@ -49,6 +50,8 @@ fn test_layout() -> Layout {
direction: Direction::Row,
gap: 4,
padding: 2,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![
LayoutChild {
sizing: Sizing::Flex(1),
@@ -71,7 +74,7 @@ async fn save_and_retrieve_widget() {
let w = store.get_widget(1).await.unwrap().unwrap();
assert_eq!(w.id, 1);
assert_eq!(w.name, "weather");
assert_eq!(w.display_hint, DisplayHint::IconValue);
assert_eq!(w.display_hint, DisplayHint::new(DisplayHintKind::IconValue));
assert_eq!(w.data_source_id, 10);
assert_eq!(w.mappings.len(), 2);
assert_eq!(w.mappings[0].source_path, "$.temp");
@@ -92,7 +95,7 @@ async fn list_widgets_returns_all() {
.save_widget(&WidgetConfig {
id: 2,
name: "portfolio".into(),
display_hint: DisplayHint::KeyValue,
display_hint: DisplayHint::new(DisplayHintKind::KeyValue),
data_source_id: 20,
mappings: vec![],
max_data_size: 1024,

View File

@@ -1,4 +1,4 @@
use client_domain::{BoundingBox, DisplayPort};
use client_domain::{BoundingBox, Color, DisplayPort, FontSize};
#[derive(Default)]
pub struct TerminalDisplay;
@@ -12,37 +12,25 @@ impl TerminalDisplay {
impl DisplayPort for TerminalDisplay {
type Error = std::io::Error;
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> {
println!(
"[CLEAR] ({}, {}) {}x{}",
bounds.x, bounds.y, bounds.width, bounds.height
);
Ok(())
}
fn draw_text(
fn draw_text_span(
&mut self,
text: &str,
x: u16,
y: u16,
bounds: BoundingBox,
color: Color,
font: FontSize,
) -> Result<(), Self::Error> {
println!(
"[TEXT] ({x}, {y}) in {}x{}: \"{text}\"",
bounds.width, bounds.height
"[TEXT] ({x}, {y}) {:?} #{:02X}{:02X}{:02X}: \"{text}\"",
font, color.0, color.1, color.2
);
Ok(())
}
fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), Self::Error> {
println!("[ICON] ({x}, {y}): {icon}");
Ok(())
}
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> {
fn fill_rect(&mut self, bounds: BoundingBox, color: Color) -> Result<(), Self::Error> {
println!(
"[BG] ({}, {}) {}x{}",
bounds.x, bounds.y, bounds.width, bounds.height
"[FILL] ({}, {}) {}x{} #{:02X}{:02X}{:02X}",
bounds.x, bounds.y, bounds.width, bounds.height, color.0, color.1, color.2
);
Ok(())
}

View File

@@ -55,7 +55,14 @@ where
if !changed.is_empty()
&& let Some(l) = &layout
{
let _ = state.broadcaster.push_screen_update(l, &changed).await;
let with_hints: Vec<_> = changed
.iter()
.filter_map(|(id, s)| {
let hint = widgets.iter().find(|w| w.id == *id)?.display_hint.clone();
Some((*id, hint, s.clone()))
})
.collect();
let _ = state.broadcaster.push_screen_update(l, &with_hints).await;
}
Ok(StatusCode::OK)

View File

@@ -1,6 +1,6 @@
use crate::error::TcpServerError;
use domain::{BroadcastPort, Layout, WidgetId, WidgetState};
use protocol::{ServerMessage, WidgetDescriptor, WireDisplayHint, WireLayoutNode, encode};
use domain::{BroadcastPort, DisplayHint, Layout, WidgetId, WidgetState};
use protocol::{ServerMessage, WidgetDescriptor, WireLayoutNode, encode};
use tokio::sync::broadcast;
pub struct TcpBroadcaster {
@@ -29,14 +29,14 @@ impl BroadcastPort for TcpBroadcaster {
async fn push_screen_update(
&self,
layout: &Layout,
widgets: &[(WidgetId, WidgetState)],
widgets: &[(WidgetId, DisplayHint, WidgetState)],
) -> Result<(), Self::Error> {
let wire_layout: WireLayoutNode = (&layout.root).into();
let wire_widgets: Vec<WidgetDescriptor> = widgets
.iter()
.map(|(id, state)| WidgetDescriptor {
.map(|(id, hint, state)| WidgetDescriptor {
id: *id,
display_hint: WireDisplayHint::IconValue,
display_hint: hint.into(),
state: state.into(),
})
.collect();
@@ -52,13 +52,13 @@ impl BroadcastPort for TcpBroadcaster {
async fn push_data_update(
&self,
updates: &[(WidgetId, WidgetState)],
updates: &[(WidgetId, DisplayHint, WidgetState)],
) -> Result<(), Self::Error> {
let wire_widgets: Vec<WidgetDescriptor> = updates
.iter()
.map(|(id, state)| WidgetDescriptor {
.map(|(id, hint, state)| WidgetDescriptor {
id: *id,
display_hint: WireDisplayHint::IconValue,
display_hint: hint.into(),
state: state.into(),
})
.collect();

View File

@@ -1,7 +1,7 @@
use crate::client_tracker::ClientTracker;
use crate::error::TcpServerError;
use domain::{ConfigRepository, WidgetStateReader};
use protocol::{ServerMessage, WidgetDescriptor, WireDisplayHint, WireLayoutNode, encode};
use protocol::{ServerMessage, WidgetDescriptor, WireLayoutNode, encode};
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
@@ -92,7 +92,7 @@ where
if let Some(s) = widget_states.get_widget_state(w.id).await {
wire_widgets.push(WidgetDescriptor {
id: w.id,
display_hint: WireDisplayHint::IconValue,
display_hint: (&w.display_hint).into(),
state: (&s).into(),
});
}

View File

@@ -113,6 +113,8 @@ impl LayoutNodeDto {
direction,
gap: self.gap.unwrap_or(0),
padding: self.padding.unwrap_or(0),
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children,
}))
}

View File

@@ -37,10 +37,10 @@ impl From<&WidgetConfig> for WidgetDto {
Self {
id: w.id,
name: w.name.clone(),
display_hint: match w.display_hint {
DisplayHint::IconValue => "icon_value",
DisplayHint::TextBlock => "text_block",
DisplayHint::KeyValue => "key_value",
display_hint: match w.display_hint.kind {
DisplayHintKind::IconValue => "icon_value",
DisplayHintKind::TextBlock => "text_block",
DisplayHintKind::KeyValue => "key_value",
}
.into(),
data_source_id: w.data_source_id,
@@ -60,9 +60,9 @@ impl From<&WidgetConfig> for WidgetDto {
impl CreateWidgetDto {
pub fn into_domain(self) -> Result<WidgetConfig, String> {
let hint = match self.display_hint.as_str() {
"icon_value" => DisplayHint::IconValue,
"text_block" => DisplayHint::TextBlock,
"key_value" => DisplayHint::KeyValue,
"icon_value" => DisplayHint::new(DisplayHintKind::IconValue),
"text_block" => DisplayHint::new(DisplayHintKind::TextBlock),
"key_value" => DisplayHint::new(DisplayHintKind::KeyValue),
h => return Err(format!("unknown display_hint: {h}")),
};
Ok(WidgetConfig {

View File

@@ -2,9 +2,9 @@ mod support;
use application::ConfigService;
use domain::{
ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType, Direction,
DisplayHint, DomainEvent, KeyMapping, Layout, LayoutChild, LayoutNode, LayoutPreset, Sizing,
WidgetConfig,
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
Direction, DisplayHint, DisplayHintKind, DomainEvent, JustifyContent, KeyMapping, Layout, LayoutChild,
LayoutNode, LayoutPreset, Sizing, WidgetConfig,
};
use std::time::Duration;
use support::{InMemoryConfigRepository, InMemoryEventPublisher};
@@ -18,7 +18,7 @@ async fn create_widget_persists_and_emits_event() {
let config = WidgetConfig::new(
1,
"weather".into(),
DisplayHint::IconValue,
DisplayHint::new(DisplayHintKind::IconValue),
1,
vec![KeyMapping {
source_path: "$.temp".into(),
@@ -98,6 +98,8 @@ async fn update_layout_persists_and_emits_event() {
direction: Direction::Row,
gap: 4,
padding: 2,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![
LayoutChild {
sizing: Sizing::Flex(1),
@@ -134,6 +136,8 @@ async fn load_preset_replaces_active_layout() {
direction: Direction::Column,
gap: 0,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(5),

View File

@@ -1,12 +1,12 @@
use application::DataProjection;
use domain::{DisplayHint, KeyMapping, Value, WidgetConfig};
use domain::{DisplayHint, DisplayHintKind, KeyMapping, Value, WidgetConfig};
use std::collections::BTreeMap;
fn weather_widget() -> WidgetConfig {
WidgetConfig::new(
1,
"weather".into(),
DisplayHint::IconValue,
DisplayHint::new(DisplayHintKind::IconValue),
10,
vec![
KeyMapping {
@@ -87,7 +87,7 @@ async fn apply_poll_result_only_updates_widgets_bound_to_source() {
WidgetConfig::new(
2,
"portfolio".into(),
DisplayHint::KeyValue,
DisplayHint::new(DisplayHintKind::KeyValue),
20,
vec![KeyMapping {
source_path: "$.value".into(),

View File

@@ -27,7 +27,7 @@ pub async fn run(
let mut widget_states = Vec::new();
for w in &widgets {
if let Some(s) = projection.get_state(w.id).await {
widget_states.push((w.id, s));
widget_states.push((w.id, w.display_hint.clone(), s));
}
}

View File

@@ -130,8 +130,15 @@ async fn poll_loop(
.await;
if !changed.is_empty() {
let with_hints: Vec<_> = changed
.iter()
.filter_map(|(id, state)| {
let hint = widgets.iter().find(|w| w.id == *id)?.display_hint.clone();
Some((*id, hint, state.clone()))
})
.collect();
if let Some(l) = &layout
&& let Err(e) = broadcaster.push_screen_update(l, &changed).await
&& let Err(e) = broadcaster.push_screen_update(l, &with_hints).await
{
warn!(error = %e, "failed to push update");
}

View File

@@ -1,12 +1,16 @@
use client_domain::{BoundingBox, LayoutEngine, RenderTree};
use client_domain::{BoundingBox, Color, LayoutEngine, RenderTree, ThemeConfig};
use domain::LayoutNode;
use protocol::{ServerMessage, WidgetDescriptor, WireDisplayHint, WireLayoutNode, WireWidgetState};
use protocol::{
ServerMessage, WireColor, WidgetDescriptor, WireDisplayHint, WireLayoutNode, WireWidgetState,
};
use std::collections::HashMap;
pub struct ClientApp {
screen: BoundingBox,
render_tree: Option<RenderTree>,
widget_states: HashMap<u16, (WireDisplayHint, WireWidgetState)>,
theme: ThemeConfig,
theme_changed: bool,
}
#[derive(Debug, Clone, PartialEq)]
@@ -23,19 +27,47 @@ impl ClientApp {
screen,
render_tree: None,
widget_states: HashMap::new(),
theme: ThemeConfig::default(),
theme_changed: false,
}
}
pub fn theme(&self) -> &ThemeConfig {
&self.theme
}
pub fn take_theme_changed(&mut self) -> bool {
let changed = self.theme_changed;
self.theme_changed = false;
changed
}
pub fn handle_message(&mut self, msg: ServerMessage) -> Vec<RepaintCommand> {
match msg {
ServerMessage::ScreenUpdate { layout, widgets } => {
self.handle_screen_update(layout, widgets)
}
ServerMessage::DataUpdate { widgets } => self.handle_data_update(widgets),
ServerMessage::ThemeUpdate { theme } => self.handle_theme_update(theme),
ServerMessage::Heartbeat => Vec::new(),
}
}
fn handle_theme_update(&mut self, wire_theme: protocol::WireTheme) -> Vec<RepaintCommand> {
self.theme = ThemeConfig {
primary: wire_color(wire_theme.primary),
secondary: wire_color(wire_theme.secondary),
accent: wire_color(wire_theme.accent),
text: wire_color(wire_theme.text),
background: wire_color(wire_theme.background),
};
self.theme_changed = true;
match &self.render_tree {
Some(tree) => self.build_repaints_for_all(tree),
None => Vec::new(),
}
}
fn handle_screen_update(
&mut self,
wire_layout: WireLayoutNode,
@@ -103,3 +135,7 @@ impl ClientApp {
repaints
}
}
fn wire_color(c: WireColor) -> Color {
Color(c.r, c.g, c.b)
}

View File

@@ -1,8 +1,9 @@
use client_application::{ClientApp, RepaintCommand};
use client_domain::BoundingBox;
use protocol::{
ServerMessage, WidgetDescriptor, WireContainerNode, WireDirection, WireDisplayHint,
WireKeyValue, WireLayoutChild, WireLayoutNode, WireSizing, WireValue, WireWidgetState,
ServerMessage, WidgetDescriptor, WireAlignItems, WireContainerNode, WireDirection,
WireDisplayHint, WireDisplayHintKind, WireJustifyContent, WireKeyValue, WireLayoutChild,
WireLayoutNode, WireSizing, WireValue, WireWidgetState,
};
fn screen() -> BoundingBox {
@@ -12,7 +13,7 @@ fn screen() -> BoundingBox {
fn weather_descriptor(id: u16, temp: &str) -> WidgetDescriptor {
WidgetDescriptor {
id,
display_hint: WireDisplayHint::IconValue,
display_hint: WireDisplayHint::new(WireDisplayHintKind::IconValue),
state: WireWidgetState {
data: vec![WireKeyValue {
key: "temperature".into(),
@@ -28,6 +29,8 @@ fn two_widget_layout() -> WireLayoutNode {
direction: WireDirection::Row,
gap: 0,
padding: 0,
justify_content: WireJustifyContent::Start,
align_items: WireAlignItems::Stretch,
children: vec![
WireLayoutChild {
sizing: WireSizing::Flex(1),
@@ -121,6 +124,8 @@ fn second_screen_update_repaints_all_widgets_with_new_layout() {
direction: WireDirection::Column,
gap: 0,
padding: 0,
justify_content: WireJustifyContent::Start,
align_items: WireAlignItems::Stretch,
children: vec![
WireLayoutChild {
sizing: WireSizing::Flex(1),

View File

@@ -1,16 +1,23 @@
use client_application::ClientApp;
use client_domain::{BoundingBox, DisplayPort, NetworkPort};
use client_domain::{BoundingBox, DisplayPort, FontMetrics, RenderEngine, ThemeConfig};
use display_terminal::TerminalDisplay;
use domain::DisplayHint;
use protocol::decode_server_message;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use tcp_client::StdTcpClient;
use client_domain::NetworkPort;
fn main() {
let screen = BoundingBox::screen(240, 320);
let mut app = ClientApp::new(screen);
let mut display = TerminalDisplay::new();
let metrics = FontMetrics {
small: (6, 10),
large: (10, 20),
};
let mut engine = RenderEngine::new(metrics, ThemeConfig::default());
println!("=== K-Frame Desktop Client ===");
println!("Screen: {}x{}", screen.width, screen.height);
@@ -59,23 +66,26 @@ fn main() {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(msg) => {
let repaints = app.handle_message(msg);
if app.take_theme_changed() {
engine.set_theme(app.theme().clone());
}
if !repaints.is_empty() {
println!("\n--- Repaint ({} widgets) ---", repaints.len());
let bg = engine.theme().background;
for cmd in &repaints {
display.clear_region(cmd.bounds).unwrap();
display.fill_background(cmd.bounds).unwrap();
display.fill_rect(cmd.bounds, bg).unwrap();
for kv in &cmd.state.data {
if let protocol::WireValue::String(s) = &kv.value {
display
.draw_text(
&format!("{}: {s}", kv.key),
cmd.bounds.x,
cmd.bounds.y,
cmd.bounds,
)
.unwrap();
}
let hint: DisplayHint = cmd.display_hint.clone().into();
let data: Vec<(String, domain::Value)> = cmd.state.data
.iter()
.map(|kv| (kv.key.clone(), kv.value.clone().into()))
.collect();
let draw_cmds = engine.render_widget(&hint, &data, cmd.bounds, 0);
for dc in &draw_cmds {
display.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font).unwrap();
}
}
display.flush().unwrap();

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

View File

@@ -0,0 +1,47 @@
use client_domain::{BoundingBox, HAlign, VAlign, align_offset};
#[test]
fn halign_left_is_zero_offset() {
let offset = align_offset(100, 60, HAlign::Left);
assert_eq!(offset, 0);
}
#[test]
fn halign_center_centers_content() {
// 100px container, 60px content → 20px offset
let offset = align_offset(100, 60, HAlign::Center);
assert_eq!(offset, 20);
}
#[test]
fn halign_right_pushes_to_end() {
// 100px container, 60px content → 40px offset
let offset = align_offset(100, 60, HAlign::Right);
assert_eq!(offset, 40);
}
#[test]
fn valign_top_is_zero_offset() {
let offset = align_offset(200, 30, VAlign::Top);
assert_eq!(offset, 0);
}
#[test]
fn valign_middle_centers_content() {
// 200px container, 30px content → 85px offset
let offset = align_offset(200, 30, VAlign::Middle);
assert_eq!(offset, 85);
}
#[test]
fn valign_bottom_pushes_to_end() {
// 200px container, 30px content → 170px offset
let offset = align_offset(200, 30, VAlign::Bottom);
assert_eq!(offset, 170);
}
#[test]
fn content_larger_than_container_clamps_to_zero() {
let offset = align_offset(50, 100, HAlign::Center);
assert_eq!(offset, 0);
}

View File

@@ -1,5 +1,5 @@
use client_domain::{BoundingBox, LayoutEngine, RenderTree};
use domain::{ContainerNode, Direction, LayoutChild, LayoutNode, Sizing};
use domain::{AlignItems, ContainerNode, Direction, JustifyContent, LayoutChild, LayoutNode, Sizing};
fn screen() -> BoundingBox {
BoundingBox::screen(240, 320)
@@ -24,6 +24,8 @@ fn row(children: Vec<LayoutChild>) -> LayoutNode {
direction: Direction::Row,
gap: 0,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children,
})
}
@@ -33,6 +35,8 @@ fn column(children: Vec<LayoutChild>) -> LayoutNode {
direction: Direction::Column,
gap: 0,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children,
})
}
@@ -42,6 +46,8 @@ fn row_with_gap(gap: u8, children: Vec<LayoutChild>) -> LayoutNode {
direction: Direction::Row,
gap,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children,
})
}
@@ -51,6 +57,8 @@ fn row_with_padding(padding: u8, children: Vec<LayoutChild>) -> LayoutNode {
direction: Direction::Row,
gap: 0,
padding,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children,
})
}
@@ -174,6 +182,8 @@ fn weighted_flex_distributes_proportionally() {
direction: Direction::Row,
gap: 0,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![
LayoutChild {
sizing: Sizing::Flex(1),
@@ -204,3 +214,106 @@ fn weighted_flex_distributes_proportionally() {
Some(&BoundingBox::new(180, 0, 60, 320))
);
}
// --- JustifyContent tests ---
#[test]
fn justify_center_centers_fixed_children_on_main_axis() {
// Row 240px, two fixed 40px children → 160px remaining, offset = 80
let layout = LayoutNode::Container(ContainerNode {
direction: Direction::Row,
gap: 0,
padding: 0,
justify_content: JustifyContent::Center,
align_items: AlignItems::Stretch,
children: vec![leaf_fixed(1, 40), leaf_fixed(2, 40)],
});
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)));
}
#[test]
fn justify_end_pushes_to_end() {
// Row 240px, one fixed 40px → offset = 200
let layout = LayoutNode::Container(ContainerNode {
direction: Direction::Row,
gap: 0,
padding: 0,
justify_content: JustifyContent::End,
align_items: AlignItems::Stretch,
children: vec![leaf_fixed(1, 40)],
});
let tree = LayoutEngine::compute(&layout, screen());
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(200, 0, 40, 320)));
}
#[test]
fn justify_space_between_distributes_gaps() {
// Row 240px, three fixed 40px → 120px used, 120px remaining, 2 gaps of 60px
let layout = LayoutNode::Container(ContainerNode {
direction: Direction::Row,
gap: 0,
padding: 0,
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Stretch,
children: vec![leaf_fixed(1, 40), leaf_fixed(2, 40), leaf_fixed(3, 40)],
});
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)));
}
#[test]
fn justify_space_evenly_distributes_with_edges() {
// Row 240px, two fixed 40px → 80px used, 160px remaining, 3 slots of 53px (int div)
let layout = LayoutNode::Container(ContainerNode {
direction: Direction::Row,
gap: 0,
padding: 0,
justify_content: JustifyContent::SpaceEvenly,
align_items: AlignItems::Stretch,
children: vec![leaf_fixed(1, 40), leaf_fixed(2, 40)],
});
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)));
}
// --- AlignItems tests ---
#[test]
fn align_items_center_centers_on_cross_axis() {
// Row 240×320, fixed child 40px wide. AlignItems::Center → child centered vertically
// Cross axis = 320, child height stays 320 for Stretch.
// With Center, child gets its natural size. For a leaf, "natural" = full cross.
// Actually: fixed children have explicit main-axis size. Cross-axis with Center
// should give the child the full cross-axis (we don't know natural cross size for leaves).
// So for leaves, Center behaves like Stretch. This test verifies columns:
// Column 240×320, fixed child 100px tall. AlignItems::Center → centered on 240px width.
// But again, leaf has no natural width. For now: non-Stretch gives child full cross-axis.
// Let's test with a nested container that has known size instead.
// Actually, the simplest useful behavior: AlignItems on a row affects child y-position.
// For a fixed-height child in a column, Center means child doesn't stretch to full width.
// But we have no "natural width" concept for leaves. Let's just verify Stretch = full cross
// and Center = full cross (since we can't shrink without natural size).
// This is a design limitation we can revisit.
let layout = LayoutNode::Container(ContainerNode {
direction: Direction::Row,
gap: 0,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![leaf_fixed(1, 40)],
});
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)));
}

View File

@@ -0,0 +1,67 @@
use client_domain::{Color, TextSpan, ThemeConfig, parse_markup};
fn theme() -> ThemeConfig {
ThemeConfig::default()
}
#[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 },
]);
}
#[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) },
]);
}
#[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 },
]);
}
#[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 },
]);
}
#[test]
fn empty_input_produces_no_spans() {
let spans = parse_markup("", &theme());
assert_eq!(spans, Vec::<TextSpan>::new());
}
#[test]
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 },
]);
}
#[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 },
]);
}

View File

@@ -0,0 +1,83 @@
use client_domain::{
BoundingBox, Color, DrawCommand, FontMetrics, FontSize, HAlign, RenderEngine,
ThemeConfig, VAlign,
};
fn metrics() -> FontMetrics {
FontMetrics {
small: (6, 10),
large: (10, 20),
}
}
fn theme() -> ThemeConfig {
ThemeConfig::default()
}
fn bounds(w: u16, h: u16) -> BoundingBox {
BoundingBox::new(0, 0, w, h)
}
#[test]
fn textblock_renders_plain_text() {
let engine = RenderEngine::new(metrics(), theme());
let cmds = engine.render_text("hello", bounds(100, 40), HAlign::Left, VAlign::Top);
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].text, "hello");
assert_eq!(cmds[0].x, 0);
assert_eq!(cmds[0].y, 0);
assert_eq!(cmds[0].color, theme().text);
assert_eq!(cmds[0].font, FontSize::Small);
}
#[test]
fn text_centered_horizontally() {
let engine = RenderEngine::new(metrics(), theme());
// "hi" = 12px, bounds = 100px → offset = 44
let cmds = engine.render_text("hi", bounds(100, 40), HAlign::Center, VAlign::Top);
assert_eq!(cmds[0].x, 44);
}
#[test]
fn text_centered_vertically() {
let engine = RenderEngine::new(metrics(), theme());
// 1 line = 10px height, bounds = 40px → offset = 15
let cmds = engine.render_text("hi", bounds(100, 40), HAlign::Left, VAlign::Middle);
assert_eq!(cmds[0].y, 15);
}
#[test]
fn text_wraps_and_stacks_lines() {
let engine = RenderEngine::new(metrics(), theme());
// "hello world" at 40px wide → "hello" + "world", each at 6x10
let cmds = engine.render_text("hello world", bounds(40, 100), HAlign::Left, VAlign::Top);
assert_eq!(cmds.len(), 2);
assert_eq!(cmds[0].text, "hello");
assert_eq!(cmds[0].y, 0);
assert_eq!(cmds[1].text, "world");
assert_eq!(cmds[1].y, 10);
}
#[test]
fn colored_markup_produces_colored_spans() {
let engine = RenderEngine::new(metrics(), theme());
let cmds = engine.render_text("{accent}hi{/}", bounds(100, 40), HAlign::Left, VAlign::Top);
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].text, "hi");
assert_eq!(cmds[0].color, theme().accent);
}
#[test]
fn bounds_offset_applied() {
let engine = RenderEngine::new(metrics(), theme());
let b = BoundingBox::new(10, 20, 100, 40);
let cmds = engine.render_text("hi", b, HAlign::Left, VAlign::Top);
assert_eq!(cmds[0].x, 10);
assert_eq!(cmds[0].y, 20);
}

View File

@@ -1,5 +1,5 @@
use client_domain::{BoundingBox, LayoutEngine};
use domain::{ContainerNode, Direction, LayoutChild, LayoutNode, Sizing};
use domain::{AlignItems, ContainerNode, Direction, JustifyContent, LayoutChild, LayoutNode, Sizing};
fn screen() -> BoundingBox {
BoundingBox::screen(240, 320)
@@ -11,6 +11,8 @@ fn diff_detects_moved_widget_after_layout_change() {
direction: Direction::Row,
gap: 0,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![
LayoutChild {
sizing: Sizing::Flex(1),
@@ -27,6 +29,8 @@ fn diff_detects_moved_widget_after_layout_change() {
direction: Direction::Column,
gap: 0,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![
LayoutChild {
sizing: Sizing::Flex(1),
@@ -53,6 +57,8 @@ fn diff_returns_empty_for_identical_layouts() {
direction: Direction::Row,
gap: 0,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![
LayoutChild {
sizing: Sizing::Flex(1),
@@ -77,6 +83,8 @@ fn diff_detects_added_and_removed_widgets() {
direction: Direction::Row,
gap: 0,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(1),
@@ -87,6 +95,8 @@ fn diff_detects_added_and_removed_widgets() {
direction: Direction::Row,
gap: 0,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(2),

View File

@@ -0,0 +1,72 @@
use client_domain::ScrollState;
use std::time::Duration;
#[test]
fn no_overflow_means_zero_offset() {
let scroll = ScrollState::new(100, 80);
assert_eq!(scroll.offset(), 0);
assert!(!scroll.is_active());
}
#[test]
fn overflow_starts_at_zero_offset() {
let scroll = ScrollState::new(100, 200);
assert_eq!(scroll.offset(), 0);
assert!(scroll.is_active());
}
#[test]
fn tick_advances_offset_after_initial_pause() {
let mut scroll = ScrollState::new(100, 200);
// Overflow = 100px. Initial pause = 2s.
// Tick past the pause
assert!(scroll.tick(Duration::from_secs(3)));
assert!(scroll.offset() > 0);
}
#[test]
fn tick_returns_false_when_no_movement() {
let mut scroll = ScrollState::new(100, 80);
assert!(!scroll.tick(Duration::from_millis(100)));
}
#[test]
fn offset_never_exceeds_overflow() {
let mut scroll = ScrollState::new(100, 200);
// Tick many times — offset should cap at overflow (100)
for _ in 0..1000 {
scroll.tick(Duration::from_millis(100));
}
assert!(scroll.offset() <= 100);
}
#[test]
fn bounces_back_after_reaching_end() {
let mut scroll = ScrollState::new(100, 150);
// Overflow = 50px. Tick until we reach the end and bounce back.
// After enough ticks, offset should return to 0.
let mut seen_nonzero = false;
let mut returned_to_zero = false;
for _ in 0..2000 {
scroll.tick(Duration::from_millis(50));
if scroll.offset() > 0 {
seen_nonzero = true;
}
if seen_nonzero && scroll.offset() == 0 {
returned_to_zero = true;
break;
}
}
assert!(seen_nonzero, "should have scrolled");
assert!(returned_to_zero, "should have bounced back to 0");
}
#[test]
fn reset_restarts_scroll() {
let mut scroll = ScrollState::new(100, 200);
for _ in 0..100 {
scroll.tick(Duration::from_millis(100));
}
scroll.reset(100, 200);
assert_eq!(scroll.offset(), 0);
}

View File

@@ -0,0 +1,51 @@
use client_domain::{FontMetrics, FontSize, wrap_lines};
fn metrics() -> FontMetrics {
FontMetrics {
small: (6, 10),
large: (10, 20),
}
}
#[test]
fn text_that_fits_returns_single_line() {
// "hello" = 5 chars × 6px = 30px, available = 100px
let lines = wrap_lines("hello", 100, FontSize::Small, &metrics());
assert_eq!(lines, vec!["hello"]);
}
#[test]
fn text_wraps_at_word_boundary() {
// "hello world" = 11 chars × 6px = 66px, available = 40px
// "hello" = 30px fits, "world" = 30px fits on next line
let lines = wrap_lines("hello world", 40, FontSize::Small, &metrics());
assert_eq!(lines, vec!["hello", "world"]);
}
#[test]
fn long_word_breaks_by_character() {
// "abcdefghij" = 10 chars × 6px = 60px, available = 36px (6 chars)
let lines = wrap_lines("abcdefghij", 36, FontSize::Small, &metrics());
assert_eq!(lines, vec!["abcdef", "ghij"]);
}
#[test]
fn empty_text_returns_empty() {
let lines = wrap_lines("", 100, FontSize::Small, &metrics());
assert_eq!(lines, Vec::<&str>::new());
}
#[test]
fn multiple_words_wrap_across_lines() {
// available = 42px (7 chars)
// "one two three" → "one two" (7 chars = 42px), "three" (5 chars = 30px)
let lines = wrap_lines("one two three", 42, FontSize::Small, &metrics());
assert_eq!(lines, vec!["one two", "three"]);
}
#[test]
fn uses_large_font_metrics() {
// "hi" = 2 chars × 10px = 20px, available = 15px (1 char)
let lines = wrap_lines("hi", 15, FontSize::Large, &metrics());
assert_eq!(lines, vec!["h", "i"]);
}

View File

@@ -1,4 +1,4 @@
use client_domain::{BoundingBox, DisplayPort};
use client_domain::{BoundingBox, Color, DisplayPort, FontSize};
use embedded_graphics::{
mono_font::{ascii::FONT_6X10, ascii::FONT_10X20, MonoTextStyle},
pixelcolor::Rgb565,
@@ -6,7 +6,6 @@ use embedded_graphics::{
primitives::{PrimitiveStyle, Rectangle},
text::Text,
};
use embedded_text::{TextBox, style::TextBoxStyleBuilder};
#[derive(Debug)]
pub enum DisplayError {
@@ -26,10 +25,8 @@ pub struct Esp32DisplayAdapter {
}
trait ErasedDisplay {
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), DisplayError>;
fn draw_text(&mut self, text: &str, x: u16, y: u16, bounds: BoundingBox) -> Result<(), DisplayError>;
fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), DisplayError>;
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), DisplayError>;
fn draw_text_span(&mut self, text: &str, x: u16, y: u16, color: Rgb565, font: FontSize) -> Result<(), DisplayError>;
fn fill_rect(&mut self, bounds: BoundingBox, color: Rgb565) -> Result<(), DisplayError>;
fn flush(&mut self) -> Result<(), DisplayError>;
}
@@ -38,55 +35,37 @@ where
D: DrawTarget<Color = Rgb565>,
D::Error: std::fmt::Debug,
{
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), DisplayError> {
fn draw_text_span(&mut self, text: &str, x: u16, y: u16, color: Rgb565, font: FontSize) -> Result<(), DisplayError> {
let (style, y_offset) = match font {
FontSize::Small => (MonoTextStyle::new(&FONT_6X10, color), 10),
FontSize::Large => (MonoTextStyle::new(&FONT_10X20, color), 20),
};
Text::new(text, Point::new(x as i32, y as i32 + y_offset), style)
.draw(self)
.map_err(|e| DisplayError::Draw(format!("{e:?}")))?;
Ok(())
}
fn fill_rect(&mut self, bounds: BoundingBox, color: Rgb565) -> Result<(), DisplayError> {
Rectangle::new(
Point::new(bounds.x as i32, bounds.y as i32),
Size::new(bounds.width as u32, bounds.height as u32),
)
.into_styled(PrimitiveStyle::with_fill(Rgb565::BLACK))
.into_styled(PrimitiveStyle::with_fill(color))
.draw(self)
.map_err(|e| DisplayError::Draw(format!("{e:?}")))?;
Ok(())
}
fn draw_text(&mut self, text: &str, _x: u16, _y: u16, bounds: BoundingBox) -> Result<(), DisplayError> {
let style = MonoTextStyle::new(&FONT_6X10, Rgb565::WHITE);
let textbox_style = TextBoxStyleBuilder::new().build();
let rect = Rectangle::new(
Point::new(bounds.x as i32, bounds.y as i32),
Size::new(bounds.width as u32, bounds.height as u32),
);
TextBox::with_textbox_style(text, rect, style, textbox_style)
.draw(self)
.map_err(|e| DisplayError::Draw(format!("{e:?}")))?;
Ok(())
}
fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), DisplayError> {
let style = MonoTextStyle::new(&FONT_10X20, Rgb565::WHITE);
let icon_char = match icon {
"sunny" | "clear" => "*",
"cloud_rain" | "rain" => "~",
"cloud" | "cloudy" => "=",
"dollar" | "money" => "$",
"music" | "note" => "#",
_ => "?",
};
Text::new(icon_char, Point::new(x as i32, y as i32 + 20), style)
.draw(self)
.map_err(|e| DisplayError::Draw(format!("{e:?}")))?;
Ok(())
}
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), DisplayError> {
self.clear_region(bounds)
}
fn flush(&mut self) -> Result<(), DisplayError> {
Ok(())
}
}
fn to_rgb565(c: Color) -> Rgb565 {
Rgb565::new(c.0 >> 3, c.1 >> 2, c.2 >> 3)
}
impl Esp32DisplayAdapter {
pub fn new<D>(display: D) -> Self
where
@@ -102,20 +81,19 @@ impl Esp32DisplayAdapter {
impl DisplayPort for Esp32DisplayAdapter {
type Error = DisplayError;
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> {
self.inner.clear_region(bounds)
fn draw_text_span(
&mut self,
text: &str,
x: u16,
y: u16,
color: Color,
font: FontSize,
) -> Result<(), Self::Error> {
self.inner.draw_text_span(text, x, y, to_rgb565(color), font)
}
fn draw_text(&mut self, text: &str, x: u16, y: u16, bounds: BoundingBox) -> Result<(), Self::Error> {
self.inner.draw_text(text, x, y, bounds)
}
fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), Self::Error> {
self.inner.draw_icon(icon, x, y)
}
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> {
self.inner.fill_background(bounds)
fn fill_rect(&mut self, bounds: BoundingBox, color: Color) -> Result<(), Self::Error> {
self.inner.fill_rect(bounds, to_rgb565(color))
}
fn flush(&mut self) -> Result<(), Self::Error> {

View File

@@ -3,7 +3,7 @@ use std::thread;
use esp_idf_hal::io::Write;
use esp_idf_svc::http::server::{Configuration as HttpConfig, EspHttpServer};
use esp_idf_svc::nvs::{EspNvsPartition, NvsDefault};
use client_domain::{BoundingBox, DisplayPort};
use client_domain::{BoundingBox, Color, DisplayPort, FontSize};
use log::{info, error};
use super::{DeviceConfig, save_config};
@@ -190,12 +190,14 @@ fn hex_val(b: u8) -> Option<u8> {
}
const FULL_SCREEN: BoundingBox = BoundingBox { x: 0, y: 0, width: 320, height: 240 };
const WHITE: Color = Color(0xFF, 0xFF, 0xFF);
const DARK_BG: Color = Color(0x08, 0x10, 0x18);
fn draw_setup_screen<D: DisplayPort>(display: &mut D) {
let _ = display.fill_background(FULL_SCREEN);
let _ = display.draw_text("K-Frame Setup", 0, 0, BoundingBox { x: 80, y: 50, width: 160, height: 20 });
let _ = display.draw_text("Connect to WiFi:", 0, 0, BoundingBox { x: 40, y: 90, width: 240, height: 14 });
let _ = display.draw_text("KFrame-Setup", 0, 0, BoundingBox { x: 80, y: 110, width: 160, height: 14 });
let _ = display.draw_text("Then open browser", 0, 0, BoundingBox { x: 40, y: 150, width: 240, height: 14 });
let _ = display.draw_text("to configure", 0, 0, BoundingBox { x: 60, y: 170, width: 200, height: 14 });
let _ = display.fill_rect(FULL_SCREEN, DARK_BG);
let _ = display.draw_text_span("K-Frame Setup", 100, 50, WHITE, FontSize::Small);
let _ = display.draw_text_span("Connect to WiFi:", 60, 90, WHITE, FontSize::Small);
let _ = display.draw_text_span("KFrame-Setup", 90, 110, WHITE, FontSize::Small);
let _ = display.draw_text_span("Then open browser", 60, 150, WHITE, FontSize::Small);
let _ = display.draw_text_span("to configure", 80, 170, WHITE, FontSize::Small);
}

View File

@@ -1,55 +1,63 @@
use std::sync::mpsc;
use client_domain::{BoundingBox, DisplayPort};
use client_application::ClientApp;
use std::time::{Duration, Instant};
use std::collections::HashMap;
use client_domain::{
BoundingBox, DisplayPort, FontMetrics, RenderEngine, ScrollState, ThemeConfig,
};
use client_application::{ClientApp, RepaintCommand};
use domain::{DisplayHint, Value};
use protocol::ServerMessage;
use crate::config::{RENDER_POLL_INTERVAL, SCREEN};
use crate::config::RENDER_POLL_INTERVAL;
use crate::adapters::display::Esp32DisplayAdapter;
use log::*;
const LINE_HEIGHT: u16 = 12;
const TEXT_PADDING: u16 = 4;
const SCROLL_TICK: Duration = Duration::from_millis(50);
struct WidgetCache {
hint: DisplayHint,
data: Vec<(String, Value)>,
bounds: BoundingBox,
scroll: ScrollState,
}
pub fn run(
screen: BoundingBox,
mut display: Esp32DisplayAdapter,
rx: mpsc::Receiver<ServerMessage>,
) {
let metrics = FontMetrics {
small: (6, 10),
large: (10, 20),
};
let mut engine = RenderEngine::new(metrics, ThemeConfig::default());
let mut app = ClientApp::new(screen);
let mut widgets: HashMap<u16, WidgetCache> = HashMap::new();
let mut first_update = true;
let mut last_tick = Instant::now();
info!("Render loop started");
loop {
match rx.recv_timeout(RENDER_POLL_INTERVAL) {
let timeout = RENDER_POLL_INTERVAL.min(SCROLL_TICK);
match rx.recv_timeout(timeout) {
Ok(msg) => {
let is_screen_update = matches!(msg, ServerMessage::ScreenUpdate { .. });
let repaints = app.handle_message(msg);
if app.take_theme_changed() {
engine.set_theme(app.theme().clone());
}
if !repaints.is_empty() && (first_update || is_screen_update) {
display.fill_background(SCREEN).unwrap();
let bg = engine.theme().background;
display.fill_rect(screen, bg).unwrap();
first_update = false;
}
for cmd in &repaints {
display.clear_region(cmd.bounds).unwrap();
let mut y_offset = TEXT_PADDING;
for kv in &cmd.state.data {
if let protocol::WireValue::String(s) = &kv.value {
let text_bounds = BoundingBox::new(
cmd.bounds.x + TEXT_PADDING,
cmd.bounds.y + y_offset,
cmd.bounds.width.saturating_sub(TEXT_PADDING * 2),
LINE_HEIGHT,
);
display.draw_text(
&format!("{}: {s}", kv.key),
text_bounds.x,
text_bounds.y,
text_bounds,
).unwrap();
y_offset += LINE_HEIGHT + 2;
}
}
let cache = update_cache(&engine, cmd);
draw_widget(&engine, &mut display, &cache);
widgets.insert(cmd.widget_id, cache);
}
if !repaints.is_empty() {
@@ -62,5 +70,57 @@ pub fn run(
break;
}
}
let now = Instant::now();
let elapsed = now.duration_since(last_tick);
last_tick = now;
let mut needs_flush = false;
for cache in widgets.values_mut() {
if cache.scroll.tick(elapsed) {
let bg = engine.theme().background;
display.fill_rect(cache.bounds, bg).unwrap();
draw_widget(&engine, &mut display, cache);
needs_flush = true;
}
}
if needs_flush {
display.flush().unwrap();
}
}
}
fn update_cache(engine: &RenderEngine, cmd: &RepaintCommand) -> WidgetCache {
let hint: DisplayHint = cmd.display_hint.clone().into();
let data: Vec<(String, Value)> = cmd.state.data
.iter()
.map(|kv| (kv.key.clone(), kv.value.clone().into()))
.collect();
let content_h = engine.content_height(&hint, &data, cmd.bounds.width);
let scroll = ScrollState::new(cmd.bounds.height, content_h);
WidgetCache {
hint,
data,
bounds: cmd.bounds,
scroll,
}
}
fn draw_widget(
engine: &RenderEngine,
display: &mut Esp32DisplayAdapter,
cache: &WidgetCache,
) {
let draw_cmds = engine.render_widget(
&cache.hint,
&cache.data,
cache.bounds,
cache.scroll.offset(),
);
for dc in &draw_cmds {
display.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font).unwrap();
}
}

View File

@@ -15,6 +15,7 @@ pub use ports::{
EventPublisher, PasswordHashPort, SecretStore, WidgetStateReader,
};
pub use value_objects::{
ContainerNode, Direction, DisplayHint, KeyMapping, Layout, LayoutChild, LayoutNode,
LayoutValidationError, Sizing, Value, WidgetError, WidgetState,
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent,
KeyMapping, Layout, LayoutChild, LayoutNode, LayoutValidationError, Sizing, VAlign, Value,
WidgetError, WidgetState,
};

View File

@@ -1,5 +1,5 @@
use crate::entities::WidgetId;
use crate::value_objects::{Layout, WidgetState};
use crate::value_objects::{DisplayHint, Layout, WidgetState};
use std::future::Future;
pub trait BroadcastPort {
@@ -8,11 +8,11 @@ pub trait BroadcastPort {
fn push_screen_update(
&self,
layout: &Layout,
widgets: &[(WidgetId, WidgetState)],
widgets: &[(WidgetId, DisplayHint, WidgetState)],
) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn push_data_update(
&self,
updates: &[(WidgetId, WidgetState)],
updates: &[(WidgetId, DisplayHint, WidgetState)],
) -> impl Future<Output = Result<(), Self::Error>> + Send;
}

View File

@@ -13,11 +13,30 @@ pub enum Direction {
Column,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JustifyContent {
Start,
Center,
End,
SpaceBetween,
SpaceEvenly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlignItems {
Start,
Center,
End,
Stretch,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ContainerNode {
pub direction: Direction,
pub gap: u8,
pub padding: u8,
pub justify_content: JustifyContent,
pub align_items: AlignItems,
pub children: Vec<LayoutChild>,
}

View File

@@ -5,7 +5,8 @@ mod widget_state;
pub use key_mapping::KeyMapping;
pub use layout::{
ContainerNode, Direction, Layout, LayoutChild, LayoutNode, LayoutValidationError, Sizing,
AlignItems, ContainerNode, Direction, JustifyContent, Layout, LayoutChild, LayoutNode,
LayoutValidationError, Sizing,
};
pub use value::Value;
pub use widget_state::{DisplayHint, WidgetError, WidgetState};
pub use widget_state::{DisplayHint, DisplayHintKind, HAlign, VAlign, WidgetError, WidgetState};

View File

@@ -13,8 +13,39 @@ pub enum WidgetError {
ExtractionFailed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HAlign {
Left,
Center,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VAlign {
Top,
Middle,
Bottom,
}
#[derive(Debug, Clone, PartialEq)]
pub enum DisplayHint {
pub struct DisplayHint {
pub kind: DisplayHintKind,
pub h_align: HAlign,
pub v_align: VAlign,
}
impl DisplayHint {
pub fn new(kind: DisplayHintKind) -> Self {
Self {
kind,
h_align: HAlign::Left,
v_align: VAlign::Top,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum DisplayHintKind {
IconValue,
TextBlock,
KeyValue,

View File

@@ -1,6 +1,6 @@
use domain::{
ContainerNode, Direction, Layout, LayoutChild, LayoutNode, LayoutValidationError, Sizing,
WidgetId,
AlignItems, ContainerNode, Direction, JustifyContent, Layout, LayoutChild, LayoutNode,
LayoutValidationError, Sizing, WidgetId,
};
use std::collections::BTreeSet;
@@ -16,6 +16,8 @@ fn row(children: Vec<LayoutChild>) -> LayoutNode {
direction: Direction::Row,
gap: 0,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children,
})
}

View File

@@ -1,4 +1,4 @@
use domain::{DisplayHint, KeyMapping, Value, WidgetConfig};
use domain::{DisplayHint, DisplayHintKind, KeyMapping, Value, WidgetConfig};
use std::collections::BTreeMap;
#[test]
@@ -6,7 +6,7 @@ fn extract_applies_all_mappings_to_produce_widget_state() {
let config = WidgetConfig {
id: 1,
name: "weather".into(),
display_hint: DisplayHint::IconValue,
display_hint: DisplayHint::new(DisplayHintKind::IconValue),
data_source_id: 1,
mappings: vec![
KeyMapping {
@@ -51,7 +51,7 @@ fn extract_truncates_string_values_exceeding_max_data_size() {
let config = WidgetConfig {
id: 1,
name: "news".into(),
display_hint: DisplayHint::TextBlock,
display_hint: DisplayHint::new(DisplayHintKind::TextBlock),
data_source_id: 1,
mappings: vec![KeyMapping {
source_path: "$.text".into(),
@@ -74,7 +74,7 @@ fn extract_respects_max_data_size_across_total_state() {
let config = WidgetConfig {
id: 1,
name: "big".into(),
display_hint: DisplayHint::TextBlock,
display_hint: DisplayHint::new(DisplayHintKind::TextBlock),
data_source_id: 1,
mappings: vec![
KeyMapping {
@@ -109,7 +109,7 @@ fn extract_skips_mappings_that_dont_match() {
let config = WidgetConfig {
id: 1,
name: "weather".into(),
display_hint: DisplayHint::IconValue,
display_hint: DisplayHint::new(DisplayHintKind::IconValue),
data_source_id: 1,
mappings: vec![
KeyMapping {

View File

@@ -8,6 +8,22 @@ pub struct WidgetDescriptor {
pub state: WireWidgetState,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WireColor {
pub r: u8,
pub g: u8,
pub b: u8,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WireTheme {
pub primary: WireColor,
pub secondary: WireColor,
pub accent: WireColor,
pub text: WireColor,
pub background: WireColor,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ServerMessage {
ScreenUpdate {
@@ -17,6 +33,9 @@ pub enum ServerMessage {
DataUpdate {
widgets: Vec<WidgetDescriptor>,
},
ThemeUpdate {
theme: WireTheme,
},
Heartbeat,
}

View File

@@ -2,10 +2,11 @@ mod frame;
mod wire;
pub use frame::{
ClientMessage, MAX_FRAME_SIZE, ServerMessage, WidgetDescriptor, decode_client_message,
decode_server_message, encode, encode_client,
ClientMessage, MAX_FRAME_SIZE, ServerMessage, WireColor, WireTheme, WidgetDescriptor,
decode_client_message, decode_server_message, encode, encode_client,
};
pub use wire::{
WireContainerNode, WireDirection, WireDisplayHint, WireKeyValue, WireLayoutChild,
WireLayoutNode, WireSizing, WireValue, WireWidgetError, WireWidgetState,
WireAlignItems, WireContainerNode, WireDirection, WireDisplayHint, WireDisplayHintKind,
WireHAlign, WireJustifyContent, WireKeyValue, WireLayoutChild, WireLayoutNode, WireSizing,
WireVAlign, WireValue, WireWidgetError, WireWidgetState,
};

View File

@@ -1,6 +1,6 @@
use domain::value_objects::{
ContainerNode, Direction, DisplayHint, LayoutChild, LayoutNode, Sizing, Value, WidgetError,
WidgetState,
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent,
LayoutChild, LayoutNode, Sizing, VAlign, Value, WidgetError, WidgetState,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
@@ -111,28 +111,119 @@ impl From<WireWidgetState> for WidgetState {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireDisplayHint {
pub enum WireDisplayHintKind {
IconValue,
TextBlock,
KeyValue,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireHAlign {
Left,
Center,
Right,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireVAlign {
Top,
Middle,
Bottom,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WireDisplayHint {
pub kind: WireDisplayHintKind,
pub h_align: WireHAlign,
pub v_align: WireVAlign,
}
impl WireDisplayHint {
pub fn new(kind: WireDisplayHintKind) -> Self {
Self {
kind,
h_align: WireHAlign::Left,
v_align: WireVAlign::Top,
}
}
}
impl From<&DisplayHintKind> for WireDisplayHintKind {
fn from(k: &DisplayHintKind) -> Self {
match k {
DisplayHintKind::IconValue => WireDisplayHintKind::IconValue,
DisplayHintKind::TextBlock => WireDisplayHintKind::TextBlock,
DisplayHintKind::KeyValue => WireDisplayHintKind::KeyValue,
}
}
}
impl From<WireDisplayHintKind> for DisplayHintKind {
fn from(w: WireDisplayHintKind) -> Self {
match w {
WireDisplayHintKind::IconValue => DisplayHintKind::IconValue,
WireDisplayHintKind::TextBlock => DisplayHintKind::TextBlock,
WireDisplayHintKind::KeyValue => DisplayHintKind::KeyValue,
}
}
}
impl From<&HAlign> for WireHAlign {
fn from(h: &HAlign) -> Self {
match h {
HAlign::Left => WireHAlign::Left,
HAlign::Center => WireHAlign::Center,
HAlign::Right => WireHAlign::Right,
}
}
}
impl From<WireHAlign> for HAlign {
fn from(w: WireHAlign) -> Self {
match w {
WireHAlign::Left => HAlign::Left,
WireHAlign::Center => HAlign::Center,
WireHAlign::Right => HAlign::Right,
}
}
}
impl From<&VAlign> for WireVAlign {
fn from(v: &VAlign) -> Self {
match v {
VAlign::Top => WireVAlign::Top,
VAlign::Middle => WireVAlign::Middle,
VAlign::Bottom => WireVAlign::Bottom,
}
}
}
impl From<WireVAlign> for VAlign {
fn from(w: WireVAlign) -> Self {
match w {
WireVAlign::Top => VAlign::Top,
WireVAlign::Middle => VAlign::Middle,
WireVAlign::Bottom => VAlign::Bottom,
}
}
}
impl From<&DisplayHint> for WireDisplayHint {
fn from(h: &DisplayHint) -> Self {
match h {
DisplayHint::IconValue => WireDisplayHint::IconValue,
DisplayHint::TextBlock => WireDisplayHint::TextBlock,
DisplayHint::KeyValue => WireDisplayHint::KeyValue,
WireDisplayHint {
kind: (&h.kind).into(),
h_align: (&h.h_align).into(),
v_align: (&h.v_align).into(),
}
}
}
impl From<WireDisplayHint> for DisplayHint {
fn from(w: WireDisplayHint) -> Self {
match w {
WireDisplayHint::IconValue => DisplayHint::IconValue,
WireDisplayHint::TextBlock => DisplayHint::TextBlock,
WireDisplayHint::KeyValue => DisplayHint::KeyValue,
DisplayHint {
kind: w.kind.into(),
h_align: w.h_align.into(),
v_align: w.v_align.into(),
}
}
}
@@ -185,11 +276,76 @@ impl From<WireDirection> for Direction {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireJustifyContent {
Start,
Center,
End,
SpaceBetween,
SpaceEvenly,
}
impl From<&JustifyContent> for WireJustifyContent {
fn from(j: &JustifyContent) -> Self {
match j {
JustifyContent::Start => WireJustifyContent::Start,
JustifyContent::Center => WireJustifyContent::Center,
JustifyContent::End => WireJustifyContent::End,
JustifyContent::SpaceBetween => WireJustifyContent::SpaceBetween,
JustifyContent::SpaceEvenly => WireJustifyContent::SpaceEvenly,
}
}
}
impl From<WireJustifyContent> for JustifyContent {
fn from(w: WireJustifyContent) -> Self {
match w {
WireJustifyContent::Start => JustifyContent::Start,
WireJustifyContent::Center => JustifyContent::Center,
WireJustifyContent::End => JustifyContent::End,
WireJustifyContent::SpaceBetween => JustifyContent::SpaceBetween,
WireJustifyContent::SpaceEvenly => JustifyContent::SpaceEvenly,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireAlignItems {
Start,
Center,
End,
Stretch,
}
impl From<&AlignItems> for WireAlignItems {
fn from(a: &AlignItems) -> Self {
match a {
AlignItems::Start => WireAlignItems::Start,
AlignItems::Center => WireAlignItems::Center,
AlignItems::End => WireAlignItems::End,
AlignItems::Stretch => WireAlignItems::Stretch,
}
}
}
impl From<WireAlignItems> for AlignItems {
fn from(w: WireAlignItems) -> Self {
match w {
WireAlignItems::Start => AlignItems::Start,
WireAlignItems::Center => AlignItems::Center,
WireAlignItems::End => AlignItems::End,
WireAlignItems::Stretch => AlignItems::Stretch,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WireContainerNode {
pub direction: WireDirection,
pub gap: u8,
pub padding: u8,
pub justify_content: WireJustifyContent,
pub align_items: WireAlignItems,
pub children: Vec<WireLayoutChild>,
}
@@ -213,6 +369,8 @@ impl From<&LayoutNode> for WireLayoutNode {
direction: (&c.direction).into(),
gap: c.gap,
padding: c.padding,
justify_content: (&c.justify_content).into(),
align_items: (&c.align_items).into(),
children: c
.children
.iter()
@@ -234,6 +392,8 @@ impl From<WireLayoutNode> for LayoutNode {
direction: c.direction.into(),
gap: c.gap,
padding: c.padding,
justify_content: c.justify_content.into(),
align_items: c.align_items.into(),
children: c
.children
.into_iter()

View File

@@ -1,6 +1,6 @@
use domain::{
ContainerNode, Direction, DisplayHint, LayoutChild, LayoutNode, Sizing, Value, WidgetError,
WidgetState,
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, JustifyContent,
LayoutChild, LayoutNode, Sizing, Value, WidgetError, WidgetState,
};
use protocol::{
WireContainerNode, WireDirection, WireDisplayHint, WireKeyValue, WireLayoutChild,
@@ -43,6 +43,8 @@ fn layout_tree_converts_to_wire_and_back() {
direction: Direction::Row,
gap: 4,
padding: 2,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![
LayoutChild {
sizing: Sizing::Flex(1),
@@ -54,6 +56,8 @@ fn layout_tree_converts_to_wire_and_back() {
direction: Direction::Column,
gap: 2,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(2),
@@ -71,9 +75,9 @@ fn layout_tree_converts_to_wire_and_back() {
#[test]
fn display_hint_converts_to_wire_and_back() {
for hint in [
DisplayHint::IconValue,
DisplayHint::TextBlock,
DisplayHint::KeyValue,
DisplayHint::new(DisplayHintKind::IconValue),
DisplayHint::new(DisplayHintKind::TextBlock),
DisplayHint::new(DisplayHintKind::KeyValue),
] {
let wire: WireDisplayHint = (&hint).into();
let roundtripped: DisplayHint = wire.into();

View File

@@ -1,7 +1,8 @@
use protocol::{
ClientMessage, ServerMessage, WidgetDescriptor, WireContainerNode, WireDirection,
WireDisplayHint, WireKeyValue, WireLayoutChild, WireLayoutNode, WireSizing, WireValue,
WireWidgetState, decode_client_message, decode_server_message, encode, encode_client,
ClientMessage, ServerMessage, WidgetDescriptor, WireAlignItems, WireContainerNode,
WireDirection, WireDisplayHint, WireDisplayHintKind, WireJustifyContent, WireKeyValue,
WireLayoutChild, WireLayoutNode, WireSizing, WireValue, WireWidgetState,
decode_client_message, decode_server_message, encode, encode_client,
};
#[test]
@@ -11,6 +12,8 @@ fn screen_update_round_trips() {
direction: WireDirection::Row,
gap: 4,
padding: 2,
justify_content: WireJustifyContent::Start,
align_items: WireAlignItems::Stretch,
children: vec![
WireLayoutChild {
sizing: WireSizing::Flex(1),
@@ -24,7 +27,7 @@ fn screen_update_round_trips() {
}),
widgets: vec![WidgetDescriptor {
id: 1,
display_hint: WireDisplayHint::IconValue,
display_hint: WireDisplayHint::new(WireDisplayHintKind::IconValue),
state: WireWidgetState {
data: vec![
WireKeyValue {
@@ -52,7 +55,7 @@ fn data_update_round_trips() {
let msg = ServerMessage::DataUpdate {
widgets: vec![WidgetDescriptor {
id: 3,
display_hint: WireDisplayHint::TextBlock,
display_hint: WireDisplayHint::new(WireDisplayHintKind::TextBlock),
state: WireWidgetState {
data: vec![WireKeyValue {
key: "body".into(),