new rendering engine
This commit is contained in:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user