new rendering engine
This commit is contained in:
22
CONTEXT.md
22
CONTEXT.md
@@ -18,7 +18,7 @@ Value objects have no identity. They are defined entirely by their content and a
|
||||
|
||||
- **Layout** — the single active layout tree. A recursive structure of LayoutNodes. Always singular — clients display exactly one layout at a time. Replaced wholesale when user reconfigures via the web UI.
|
||||
|
||||
- **LayoutNode** — a node in the layout tree. Either a Container (row or column with ordered children) or a Leaf (references a Widget by ID). Each child in a Container has a sizing mode: Fixed(pixels) or Flex(weight). Layout engine sums fixed sizes, distributes remaining space among flex children by integer weight ratio. All integer math, no floats. Containers have an optional `gap: u8` (uniform spacing between children in pixels) and an optional `padding: u8` (uniform inset on all sides, typically used on root container to keep content off screen edges).
|
||||
- **LayoutNode** — a node in the layout tree. Either a Container (row or column with ordered children) or a Leaf (references a Widget by ID). Each child in a Container has a sizing mode: Fixed(pixels) or Flex(weight). Layout engine sums fixed sizes, distributes remaining space among flex children by integer weight ratio. All integer math, no floats. Containers have an optional `gap: u8` (uniform spacing between children in pixels), an optional `padding: u8` (uniform inset on all sides), a `JustifyContent` (main axis distribution: Start, Center, End, SpaceBetween, SpaceEvenly), and an `AlignItems` (cross axis alignment: Start, Center, End, Stretch). Both default to Start.
|
||||
|
||||
- **KeyMapping** — a rule inside WidgetConfig that extracts a value from a DataSource's raw response using a JSON path expression and maps it to a named key in the WidgetState. E.g. `"$.main.temp" → "value"`. Decouples widget data shape from API response shape. Keeps adapters dumb — they return raw responses, WidgetConfig defines extraction.
|
||||
|
||||
@@ -42,13 +42,27 @@ The client has its own thin domain — hexagonal, chip-agnostic, display-agnosti
|
||||
|
||||
- **BoundingBox** — a rectangular region on screen (x, y, width, height) computed by the layout engine for each node.
|
||||
|
||||
- **WidgetView** — the rendered projection of a WidgetState. Contains resolved display data (text strings, icon references) within a BoundingBox. The client domain decides overflow behavior, truncation, and marquee.
|
||||
- **WidgetView** — the rendered projection of a WidgetState. Contains resolved display data (text spans with colors), scroll state, and computed positions within a BoundingBox. The client domain formats raw WidgetState data according to DisplayHint rules (e.g. KeyValue renders keys in secondary color), applies text alignment (HAlign, VAlign), detects overflow, and manages bounce-scroll animation. Scroll triggers only on overflow — static text stays static. Bounce speed auto-derives from overflow amount for consistent reading pace.
|
||||
|
||||
- **Layout Engine** — pure domain logic. Given a LayoutNode tree and screen dimensions, computes BoundingBoxes for all nodes. Containers split space among children (row = horizontal, column = vertical). Leaves get their final bounds.
|
||||
- **Layout Engine** — pure domain logic. Given a LayoutNode tree and screen dimensions, computes BoundingBoxes for all nodes. Containers split space among children (row = horizontal, column = vertical), applying JustifyContent and AlignItems for positioning. Leaves get their final bounds.
|
||||
|
||||
- **Render Engine** — pure domain logic. Given a WidgetView and FontMetrics, parses inline color markup, performs word-based line wrapping (character fallback for long words), computes alignment offsets, applies scroll position, and emits positioned text spans for the DisplayPort to draw. Owns all text intelligence — the DisplayPort is a thin pixel-pusher.
|
||||
|
||||
- **FontMetrics** — character dimensions for each font size (Small, Large), injected into the domain at init by the adapter. Enables pure-math text measurement (monospace: width = char_count × char_width) without hardware coupling.
|
||||
|
||||
- **Color** — domain-owned RGB color value (u8, u8, u8). Used on all domain and port boundaries. Adapters convert to native format (e.g. Rgb565 on ESP32) at the last mile.
|
||||
|
||||
- **ThemeConfig** — a set of five named Colors that define the visual appearance of the client: `primary`, `secondary`, `accent`, `text` (default for all unmarked content), and `background` (used for all fills/clears). Pushed from server to client independently of layout via a ThemeUpdate message. Client stores current theme and falls back to sensible defaults if no theme has been received. Configured from the web UI.
|
||||
|
||||
- **DisplayPort** — the client's rendering abstraction. A thin pixel-pusher with three methods: `draw_text_span(text, x, y, color, font_size)`, `fill_rect(bounds, color)`, `flush()`. All text intelligence (wrapping, alignment, scrolling, markup parsing) lives in the domain's render engine, not in the port. Adapters convert domain Color to native format and delegate to hardware.
|
||||
|
||||
- **FontSize** — enum selecting which bitmap font to use: Small (body text) or Large (icons). Passed with each draw_text_span call. The domain picks the font; the adapter maps it to a concrete bitmap font.
|
||||
|
||||
## Shared Concepts
|
||||
|
||||
- **DisplayHint** — a domain enum on WidgetConfig indicating how the client should render the widget. Closed set: IconValue, TextBlock, KeyValue, etc. Client handles each variant explicitly, with a fallback to plain text for unknown/unsupported hints. Gives type safety and validation on both sides while remaining forward-compatible. Lives in the protocol crate.
|
||||
- **DisplayHint** — a domain enum on WidgetConfig indicating how the client should render the widget. Closed set: IconValue, TextBlock, KeyValue, etc. Each variant is a rendering recipe — the client-domain's render engine formats raw WidgetState data into styled text spans according to the variant's rules (e.g. KeyValue renders keys in secondary color, values in text color). Carries content-level alignment: HAlign (Left, Center, Right) and VAlign (Top, Middle, Bottom). Client handles each variant explicitly with exhaustive match; fallback to plain text for unknown hints. Lives in the protocol crate.
|
||||
|
||||
- **Inline Color Markup** — lightweight syntax embedded in Value strings for coloring text spans. Syntax: `{#RRGGBB}text{/}` for hex colors, `{primary}text{/}`, `{secondary}text{/}`, `{accent}text{/}` for theme colors. `{/}` resets to the theme's `text` color. Parsed by the client-domain render engine. No bold/italic — hardware constraint on current bitmap fonts.
|
||||
|
||||
- **Value** — domain-owned value type representing structured data. An enum: String, Number, Bool, Array, Object, Null. Adapters convert their native formats (JSON, XML, etc.) into Value. KeyMapping and WidgetState operate on Value exclusively. Zero coupling to serialization libraries. Lives in the protocol crate.
|
||||
|
||||
|
||||
5
Makefile
5
Makefile
@@ -35,19 +35,16 @@ server:
|
||||
desktop:
|
||||
cargo run --bin client-desktop
|
||||
|
||||
# Build ESP32 firmware. Requires env: KFRAME_WIFI_SSID, KFRAME_WIFI_PASS, KFRAME_SERVER_ADDR
|
||||
# Build ESP32 firmware.
|
||||
esp-build:
|
||||
@test -n "$(KFRAME_WIFI_SSID)" || (echo "Set KFRAME_WIFI_SSID, KFRAME_WIFI_PASS, KFRAME_SERVER_ADDR" && exit 1)
|
||||
cd crates/client-esp32 && cargo build --release
|
||||
|
||||
# Flash ESP32 firmware.
|
||||
esp-flash:
|
||||
@test -n "$(KFRAME_WIFI_SSID)" || (echo "Set KFRAME_WIFI_SSID, KFRAME_WIFI_PASS, KFRAME_SERVER_ADDR" && exit 1)
|
||||
cd crates/client-esp32 && cargo espflash flash --port $(ESP_PORT) --release
|
||||
|
||||
# Flash and monitor ESP32.
|
||||
esp-run:
|
||||
@test -n "$(KFRAME_WIFI_SSID)" || (echo "Set KFRAME_WIFI_SSID, KFRAME_WIFI_PASS, KFRAME_SERVER_ADDR" && exit 1)
|
||||
cd crates/client-esp32 && cargo espflash flash --port $(ESP_PORT) --release --monitor
|
||||
|
||||
# Monitor ESP32 serial output.
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
))),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
41
crates/client-domain/src/alignment.rs
Normal file
41
crates/client-domain/src/alignment.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
2
crates/client-domain/src/color.rs
Normal file
2
crates/client-domain/src/color.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Color(pub u8, pub u8, pub u8);
|
||||
31
crates/client-domain/src/font.rs
Normal file
31
crates/client-domain/src/font.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
64
crates/client-domain/src/markup.rs
Normal file
64
crates/client-domain/src/markup.rs
Normal 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))
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
194
crates/client-domain/src/render_engine.rs
Normal file
194
crates/client-domain/src/render_engine.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
82
crates/client-domain/src/scroll.rs
Normal file
82
crates/client-domain/src/scroll.rs
Normal 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
|
||||
}
|
||||
}
|
||||
115
crates/client-domain/src/text_layout.rs
Normal file
115
crates/client-domain/src/text_layout.rs
Normal 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
|
||||
}
|
||||
}
|
||||
22
crates/client-domain/src/theme.rs
Normal file
22
crates/client-domain/src/theme.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
47
crates/client-domain/tests/alignment_tests.rs
Normal file
47
crates/client-domain/tests/alignment_tests.rs
Normal 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);
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
67
crates/client-domain/tests/markup_tests.rs
Normal file
67
crates/client-domain/tests/markup_tests.rs
Normal 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 },
|
||||
]);
|
||||
}
|
||||
83
crates/client-domain/tests/render_engine_tests.rs
Normal file
83
crates/client-domain/tests/render_engine_tests.rs
Normal 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);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
72
crates/client-domain/tests/scroll_tests.rs
Normal file
72
crates/client-domain/tests/scroll_tests.rs
Normal 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);
|
||||
}
|
||||
51
crates/client-domain/tests/text_layout_tests.rs
Normal file
51
crates/client-domain/tests/text_layout_tests.rs
Normal 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"]);
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
27
docs/adr/0004-domain-owned-rendering.md
Normal file
27
docs/adr/0004-domain-owned-rendering.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 0004 — Domain-owned rendering, thin DisplayPort
|
||||
|
||||
**Status:** accepted
|
||||
**Date:** 2026-06-19
|
||||
|
||||
## Context
|
||||
|
||||
Adding text alignment, overflow scrolling, color markup, and theming to the client. These features require text measurement, line wrapping, scroll state management, and markup parsing. The question is whether this logic lives in the domain (client-domain crate) or in each display adapter (ESP32, desktop, terminal).
|
||||
|
||||
## Decision
|
||||
|
||||
All rendering intelligence lives in client-domain. The DisplayPort trait becomes a thin pixel-pusher with three methods: `draw_text_span`, `fill_rect`, `flush`. The domain's render engine parses markup, wraps text, computes alignment, manages scroll offsets, and emits positioned spans. Adapters only convert `Color(u8,u8,u8)` to native format and draw.
|
||||
|
||||
Font metrics are injected into the domain at init as a `FontMetrics` struct (char width/height per FontSize), enabling pure-math text measurement with no hardware callbacks.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Enriched DisplayPort** — `draw_rich_text(spans, bounds, alignment)` with each adapter handling wrapping, alignment, and markup internally. Duplicates complex logic across every adapter. Harder to test — requires hardware or mocks.
|
||||
|
||||
**Split ownership** — domain handles markup/scroll, adapter handles wrapping/alignment. Splits a single concern across layers, making behavior inconsistent across clients.
|
||||
|
||||
## Consequences
|
||||
|
||||
- All text behavior is testable without hardware — pure functions on structs.
|
||||
- New display adapters are trivial: implement three methods, provide font metrics.
|
||||
- Domain is coupled to monospace font assumption (width = char_count × char_width). Proportional fonts would require a measurement callback. Acceptable — all current targets use bitmap monospace fonts.
|
||||
- DisplayPort is a breaking change — existing adapters must be rewritten.
|
||||
@@ -1,147 +0,0 @@
|
||||
# K-Frame SPA Handoff
|
||||
|
||||
## What is K-Frame
|
||||
|
||||
IoT dashboard system. Server aggregates data from configurable sources and pushes to ESP32 display clients via TCP. The server is fully functional — SQLite config, REST API, TCP broadcasting, data source polling. ESP32 firmware works end-to-end (display renders widgets). Now needs a config UI.
|
||||
|
||||
## What this session should build
|
||||
|
||||
A React SPA (config/admin UI) for the K-Frame server. The SPA is at `/mnt/drive/dev/k-frame/spa/` — fresh Vite + React 19 + shadcn/ui + TanStack Router + TanStack Query setup. Currently shows a placeholder page.
|
||||
|
||||
## Existing artifacts to read first
|
||||
|
||||
- **Design spec**: `docs/superpowers/specs/2026-06-18-k-frame-design.md`
|
||||
- **Domain glossary**: `CONTEXT.md`
|
||||
- **ADRs**: `docs/adr/0001-event-driven-cqrs.md`, `0002-static-dispatch-over-trait-objects.md`, `0003-postcard-over-flatbuffers.md`
|
||||
- **API types (DTO definitions)**: `crates/api-types/src/` — widget.rs, data_source.rs, layout.rs, preset.rs. These define the exact JSON shapes the API accepts/returns.
|
||||
|
||||
## REST API (server runs on :3000)
|
||||
|
||||
All endpoints return/accept JSON.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | /api/widgets | List all widgets |
|
||||
| POST | /api/widgets | Create widget |
|
||||
| GET | /api/widgets/{id} | Get widget |
|
||||
| PUT | /api/widgets/{id} | Update widget |
|
||||
| DELETE | /api/widgets/{id} | Delete widget |
|
||||
| GET | /api/data-sources | List all data sources |
|
||||
| POST | /api/data-sources | Create data source |
|
||||
| GET | /api/data-sources/{id} | Get data source |
|
||||
| PUT | /api/data-sources/{id} | Update data source |
|
||||
| DELETE | /api/data-sources/{id} | Delete data source |
|
||||
| GET | /api/layout | Get current layout (nullable) |
|
||||
| PUT | /api/layout | Update layout |
|
||||
| GET | /api/presets | List presets |
|
||||
| POST | /api/presets | Create preset |
|
||||
| GET | /api/presets/{id} | Get preset |
|
||||
| DELETE | /api/presets/{id} | Delete preset |
|
||||
| POST | /api/presets/{id}/load | Load preset as active layout |
|
||||
|
||||
### Key JSON shapes
|
||||
|
||||
**Widget** (create/update):
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "weather",
|
||||
"display_hint": "icon_value",
|
||||
"data_source_id": 10,
|
||||
"mappings": [
|
||||
{"source_path": "$.main.temp", "target_key": "temperature"},
|
||||
{"source_path": "$.weather[0].icon", "target_key": "icon"}
|
||||
],
|
||||
"max_data_size": 2048
|
||||
}
|
||||
```
|
||||
`display_hint` values: `"icon_value"`, `"text_block"`, `"key_value"`
|
||||
|
||||
**Data Source** (create/update):
|
||||
```json
|
||||
{
|
||||
"id": 10,
|
||||
"name": "weather",
|
||||
"source_type": "weather",
|
||||
"poll_interval_secs": 300,
|
||||
"url": "https://api.openweathermap.org/...",
|
||||
"api_key": "xxx",
|
||||
"headers": []
|
||||
}
|
||||
```
|
||||
`source_type` values: `"weather"`, `"media"`, `"rss"`, `"http_json"`, `"webhook"`
|
||||
|
||||
**Layout**:
|
||||
```json
|
||||
{
|
||||
"root": {
|
||||
"type": "container",
|
||||
"direction": "row",
|
||||
"gap": 4,
|
||||
"padding": 2,
|
||||
"children": [
|
||||
{
|
||||
"sizing": {"type": "flex", "value": 1},
|
||||
"node": {"type": "leaf", "widget_id": 1}
|
||||
},
|
||||
{
|
||||
"sizing": {"type": "fixed", "value": 80},
|
||||
"node": {"type": "leaf", "widget_id": 2}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
Nodes are recursive — containers can nest.
|
||||
|
||||
**Preset**:
|
||||
```json
|
||||
{"id": 1, "name": "dashboard", "layout": { "root": { ... } }}
|
||||
```
|
||||
|
||||
## Pages to build
|
||||
|
||||
1. **Dashboard** — overview of connected clients, active data sources, current layout. Landing page.
|
||||
2. **Data Sources** — CRUD list. Form: name, source_type (select), URL, API key, poll interval, headers.
|
||||
3. **Widgets** — CRUD list. Form: name, display_hint (select), data_source_id (select from existing sources), key mappings (dynamic list of source_path + target_key pairs), max_data_size.
|
||||
4. **Layout Builder** — visual tree editor. Add containers (row/column), nest them, place widgets as leaves, set sizing (fixed/flex), gap, padding. This is the most complex page.
|
||||
5. **Presets** — save current layout as preset, load presets, delete presets.
|
||||
|
||||
## SPA tech stack (already set up)
|
||||
|
||||
- React 19 + TypeScript
|
||||
- Vite 8
|
||||
- shadcn/ui (components already installed in `src/components/ui/`)
|
||||
- TanStack Router (not yet configured with routes)
|
||||
- TanStack Query (not yet configured with provider)
|
||||
- Tailwind CSS 4
|
||||
- Bun (lockfile is bun.lock)
|
||||
|
||||
## Server integration
|
||||
|
||||
The SPA's built files need to be served by the Axum server. Two approaches:
|
||||
1. **Dev**: SPA runs on Vite dev server (:5173), proxies API calls to :3000. Add proxy config to `vite.config.ts`.
|
||||
2. **Prod**: `bun run build` outputs to `spa/dist/`, Axum serves these as static files. Need to add static file serving to the http-api adapter.
|
||||
|
||||
The Vite proxy setup is needed first so development works.
|
||||
|
||||
## User preferences
|
||||
|
||||
- Concise, no filler
|
||||
- No mocking — test against real API
|
||||
- Clean code, modules, no huge files
|
||||
- shadcn components for all UI elements
|
||||
- TanStack Router for routing, TanStack Query for data fetching
|
||||
- No Co-Authored-By in commits
|
||||
|
||||
## What NOT to change
|
||||
|
||||
- No changes to Rust crates (domain, application, adapters, etc.)
|
||||
- No changes to the ESP32 firmware
|
||||
- API is stable — build against it as-is
|
||||
|
||||
## Suggested skills
|
||||
|
||||
- `superpowers:brainstorming` — for designing the layout builder UX (most complex page)
|
||||
- `frontend-design` — for visual design direction, making it not look like a default template
|
||||
- `shadcn` — for component usage, composition, and styling patterns
|
||||
Reference in New Issue
Block a user