add all crates: domain, protocol, application, client, adapters, ESP32 firmware
Server: domain (entities, value objects, ports), protocol (postcard wire types), application (config service, data projection), adapters (config-memory, tcp-server), bootstrap (composition root with fake data). Client: client-domain (layout engine, render tree, HAL ports), client-application (message handling, repaint commands), adapters (tcp-client, display-terminal), client-desktop (end-to-end working). ESP32: client-esp32 firmware with ILI9341 display over SPI, WiFi networking. Display test verified on hardware — landscape orientation, text rendering works. 60 workspace tests, all passing.
This commit is contained in:
11
crates/client-application/Cargo.toml
Normal file
11
crates/client-application/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "client-application"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
client-domain.workspace = true
|
||||
protocol.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
110
crates/client-application/src/client_app.rs
Normal file
110
crates/client-application/src/client_app.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use std::collections::HashMap;
|
||||
use domain::LayoutNode;
|
||||
use client_domain::{BoundingBox, LayoutEngine, RenderTree};
|
||||
use protocol::{
|
||||
ServerMessage, WidgetDescriptor, WireDisplayHint, WireWidgetState, WireLayoutNode,
|
||||
};
|
||||
|
||||
pub struct ClientApp {
|
||||
screen: BoundingBox,
|
||||
render_tree: Option<RenderTree>,
|
||||
widget_states: HashMap<u16, (WireDisplayHint, WireWidgetState)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RepaintCommand {
|
||||
pub widget_id: u16,
|
||||
pub bounds: BoundingBox,
|
||||
pub display_hint: WireDisplayHint,
|
||||
pub state: WireWidgetState,
|
||||
}
|
||||
|
||||
impl ClientApp {
|
||||
pub fn new(screen: BoundingBox) -> Self {
|
||||
Self {
|
||||
screen,
|
||||
render_tree: None,
|
||||
widget_states: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
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::Heartbeat => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_screen_update(
|
||||
&mut self,
|
||||
wire_layout: WireLayoutNode,
|
||||
widgets: Vec<WidgetDescriptor>,
|
||||
) -> Vec<RepaintCommand> {
|
||||
let layout: LayoutNode = wire_layout.into();
|
||||
let new_tree = LayoutEngine::compute(&layout, self.screen);
|
||||
|
||||
self.widget_states.clear();
|
||||
for w in &widgets {
|
||||
self.widget_states.insert(w.id, (w.display_hint.clone(), w.state.clone()));
|
||||
}
|
||||
|
||||
let repaints = self.build_repaints_for_all(&new_tree);
|
||||
self.render_tree = Some(new_tree);
|
||||
repaints
|
||||
}
|
||||
|
||||
fn handle_data_update(
|
||||
&mut self,
|
||||
widgets: Vec<WidgetDescriptor>,
|
||||
) -> Vec<RepaintCommand> {
|
||||
let tree = match &self.render_tree {
|
||||
Some(t) => t,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut repaints = Vec::new();
|
||||
|
||||
for w in widgets {
|
||||
let changed = self.widget_states
|
||||
.get(&w.id)
|
||||
.map_or(true, |(_, prev_state)| *prev_state != w.state);
|
||||
|
||||
if changed {
|
||||
if let Some(bounds) = tree.get_widget_bounds(w.id) {
|
||||
repaints.push(RepaintCommand {
|
||||
widget_id: w.id,
|
||||
bounds: *bounds,
|
||||
display_hint: w.display_hint.clone(),
|
||||
state: w.state.clone(),
|
||||
});
|
||||
}
|
||||
self.widget_states.insert(w.id, (w.display_hint, w.state));
|
||||
}
|
||||
}
|
||||
|
||||
repaints
|
||||
}
|
||||
|
||||
fn build_repaints_for_all(&self, tree: &RenderTree) -> Vec<RepaintCommand> {
|
||||
let mut repaints = Vec::new();
|
||||
|
||||
for (id, (hint, state)) in &self.widget_states {
|
||||
if let Some(bounds) = tree.get_widget_bounds(*id) {
|
||||
repaints.push(RepaintCommand {
|
||||
widget_id: *id,
|
||||
bounds: *bounds,
|
||||
display_hint: hint.clone(),
|
||||
state: state.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
repaints.sort_by_key(|r| r.widget_id);
|
||||
repaints
|
||||
}
|
||||
}
|
||||
3
crates/client-application/src/lib.rs
Normal file
3
crates/client-application/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod client_app;
|
||||
|
||||
pub use client_app::{ClientApp, RepaintCommand};
|
||||
154
crates/client-application/tests/client_app_tests.rs
Normal file
154
crates/client-application/tests/client_app_tests.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use client_application::{ClientApp, RepaintCommand};
|
||||
use client_domain::BoundingBox;
|
||||
use protocol::{
|
||||
ServerMessage, WidgetDescriptor,
|
||||
WireDisplayHint, WireLayoutNode, WireContainerNode, WireLayoutChild,
|
||||
WireDirection, WireSizing, WireWidgetState, WireKeyValue, WireValue,
|
||||
};
|
||||
|
||||
fn screen() -> BoundingBox {
|
||||
BoundingBox::screen(240, 320)
|
||||
}
|
||||
|
||||
fn weather_descriptor(id: u16, temp: &str) -> WidgetDescriptor {
|
||||
WidgetDescriptor {
|
||||
id,
|
||||
display_hint: WireDisplayHint::IconValue,
|
||||
state: WireWidgetState {
|
||||
data: vec![
|
||||
WireKeyValue { key: "temperature".into(), value: WireValue::String(temp.into()) },
|
||||
],
|
||||
error: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn two_widget_layout() -> WireLayoutNode {
|
||||
WireLayoutNode::Container(WireContainerNode {
|
||||
direction: WireDirection::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
children: vec![
|
||||
WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(1) },
|
||||
WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(2) },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_update_repaints_all_widgets() {
|
||||
let mut app = ClientApp::new(screen());
|
||||
|
||||
let msg = ServerMessage::ScreenUpdate {
|
||||
layout: two_widget_layout(),
|
||||
widgets: vec![
|
||||
weather_descriptor(1, "5.4°C"),
|
||||
weather_descriptor(2, "20°C"),
|
||||
],
|
||||
};
|
||||
|
||||
let repaints = app.handle_message(msg);
|
||||
|
||||
assert_eq!(repaints.len(), 2);
|
||||
assert_eq!(repaints[0].widget_id, 1);
|
||||
assert_eq!(repaints[0].bounds, BoundingBox::new(0, 0, 120, 320));
|
||||
assert_eq!(repaints[1].widget_id, 2);
|
||||
assert_eq!(repaints[1].bounds, BoundingBox::new(120, 0, 120, 320));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_update_only_repaints_changed_widgets() {
|
||||
let mut app = ClientApp::new(screen());
|
||||
|
||||
app.handle_message(ServerMessage::ScreenUpdate {
|
||||
layout: two_widget_layout(),
|
||||
widgets: vec![
|
||||
weather_descriptor(1, "5.4°C"),
|
||||
weather_descriptor(2, "20°C"),
|
||||
],
|
||||
});
|
||||
|
||||
let repaints = app.handle_message(ServerMessage::DataUpdate {
|
||||
widgets: vec![weather_descriptor(1, "6.1°C")],
|
||||
});
|
||||
|
||||
assert_eq!(repaints.len(), 1);
|
||||
assert_eq!(repaints[0].widget_id, 1);
|
||||
assert_eq!(
|
||||
repaints[0].state.data[0].value,
|
||||
WireValue::String("6.1°C".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_update_with_unchanged_data_produces_no_repaints() {
|
||||
let mut app = ClientApp::new(screen());
|
||||
|
||||
app.handle_message(ServerMessage::ScreenUpdate {
|
||||
layout: two_widget_layout(),
|
||||
widgets: vec![
|
||||
weather_descriptor(1, "5.4°C"),
|
||||
weather_descriptor(2, "20°C"),
|
||||
],
|
||||
});
|
||||
|
||||
let repaints = app.handle_message(ServerMessage::DataUpdate {
|
||||
widgets: vec![weather_descriptor(1, "5.4°C")],
|
||||
});
|
||||
|
||||
assert!(repaints.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_screen_update_repaints_all_widgets_with_new_layout() {
|
||||
let mut app = ClientApp::new(screen());
|
||||
|
||||
app.handle_message(ServerMessage::ScreenUpdate {
|
||||
layout: two_widget_layout(),
|
||||
widgets: vec![
|
||||
weather_descriptor(1, "5.4°C"),
|
||||
weather_descriptor(2, "20°C"),
|
||||
],
|
||||
});
|
||||
|
||||
let column_layout = WireLayoutNode::Container(WireContainerNode {
|
||||
direction: WireDirection::Column,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
children: vec![
|
||||
WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(1) },
|
||||
WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(2) },
|
||||
],
|
||||
});
|
||||
|
||||
let repaints = app.handle_message(ServerMessage::ScreenUpdate {
|
||||
layout: column_layout,
|
||||
widgets: vec![
|
||||
weather_descriptor(1, "5.4°C"),
|
||||
weather_descriptor(2, "20°C"),
|
||||
],
|
||||
});
|
||||
|
||||
assert_eq!(repaints.len(), 2);
|
||||
assert_eq!(repaints[0].bounds, BoundingBox::new(0, 0, 240, 160));
|
||||
assert_eq!(repaints[1].bounds, BoundingBox::new(0, 160, 240, 160));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_update_before_screen_update_produces_no_repaints() {
|
||||
let mut app = ClientApp::new(screen());
|
||||
|
||||
let repaints = app.handle_message(ServerMessage::DataUpdate {
|
||||
widgets: vec![weather_descriptor(1, "5.4°C")],
|
||||
});
|
||||
|
||||
assert!(repaints.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heartbeat_produces_no_repaints() {
|
||||
let mut app = ClientApp::new(screen());
|
||||
|
||||
let repaints = app.handle_message(ServerMessage::Heartbeat);
|
||||
assert!(repaints.is_empty());
|
||||
}
|
||||
Reference in New Issue
Block a user