diff --git a/Cargo.lock b/Cargo.lock index dbe2585..401a887 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,9 +79,11 @@ dependencies = [ name = "application" version = "0.1.0" dependencies = [ + "anyhow", "domain", "thiserror", "tokio", + "tracing", ] [[package]] @@ -526,6 +528,9 @@ dependencies = [ [[package]] name = "domain" version = "0.1.0" +dependencies = [ + "serde_json", +] [[package]] name = "dotenvy" @@ -1687,7 +1692,6 @@ dependencies = [ name = "protocol" version = "0.1.0" dependencies = [ - "domain", "postcard", "serde", ] diff --git a/crates/adapters/config-memory/src/lib.rs b/crates/adapters/config-memory/src/lib.rs index eef0218..db4bdfd 100644 --- a/crates/adapters/config-memory/src/lib.rs +++ b/crates/adapters/config-memory/src/lib.rs @@ -1,6 +1,6 @@ use domain::{ ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig, - User, WidgetConfig, WidgetId, WidgetState, + User, UserRepository, WidgetConfig, WidgetId, WidgetState, WidgetStateCache, }; use std::collections::HashMap; use std::sync::RwLock; @@ -177,6 +177,10 @@ impl ConfigRepository for MemoryConfigStore { guard.remove(&id); Ok(()) } +} + +impl UserRepository for MemoryConfigStore { + type Error = MemoryConfigError; async fn get_user_by_username(&self, username: &str) -> Result, Self::Error> { let guard = self @@ -203,6 +207,10 @@ impl ConfigRepository for MemoryConfigStore { .map_err(|_| MemoryConfigError::LockPoisoned)?; Ok(guard.len() as u32) } +} + +impl WidgetStateCache for MemoryConfigStore { + type Error = MemoryConfigError; async fn save_widget_states( &self, diff --git a/crates/adapters/config-sqlite/Cargo.toml b/crates/adapters/config-sqlite/Cargo.toml index 9bacfea..74e6e34 100644 --- a/crates/adapters/config-sqlite/Cargo.toml +++ b/crates/adapters/config-sqlite/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -domain.workspace = true +domain = { workspace = true, features = ["json"] } sqlx.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/adapters/config-sqlite/src/repository/mod.rs b/crates/adapters/config-sqlite/src/repository/mod.rs index 5680e40..d05d22a 100644 --- a/crates/adapters/config-sqlite/src/repository/mod.rs +++ b/crates/adapters/config-sqlite/src/repository/mod.rs @@ -10,7 +10,7 @@ use crate::SqliteConfigStore; use crate::error::SqliteConfigError; use domain::{ ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig, - User, WidgetConfig, WidgetId, WidgetState, + User, UserRepository, WidgetConfig, WidgetId, WidgetState, WidgetStateCache, }; impl ConfigRepository for SqliteConfigStore { @@ -79,6 +79,10 @@ impl ConfigRepository for SqliteConfigStore { async fn save_theme(&self, theme: &ThemeConfig) -> Result<(), Self::Error> { self.save_theme_impl(theme).await } +} + +impl UserRepository for SqliteConfigStore { + type Error = SqliteConfigError; async fn get_user_by_username(&self, username: &str) -> Result, Self::Error> { self.get_user_by_username_impl(username).await @@ -91,6 +95,10 @@ impl ConfigRepository for SqliteConfigStore { async fn count_users(&self) -> Result { self.count_users_impl().await } +} + +impl WidgetStateCache for SqliteConfigStore { + type Error = SqliteConfigError; async fn save_widget_states( &self, diff --git a/crates/adapters/config-sqlite/src/repository/widget_state_cache.rs b/crates/adapters/config-sqlite/src/repository/widget_state_cache.rs index 58e3809..b44ead4 100644 --- a/crates/adapters/config-sqlite/src/repository/widget_state_cache.rs +++ b/crates/adapters/config-sqlite/src/repository/widget_state_cache.rs @@ -44,56 +44,17 @@ impl SqliteConfigStore { } } -fn domain_value_to_json(v: &Value) -> serde_json::Value { - match v { - Value::Null => serde_json::Value::Null, - Value::Bool(b) => serde_json::Value::Bool(*b), - Value::Number(n) => serde_json::json!(n), - Value::String(s) => serde_json::Value::String(s.clone()), - Value::Array(arr) => { - serde_json::Value::Array(arr.iter().map(domain_value_to_json).collect()) - } - Value::Object(map) => { - let obj: serde_json::Map = map - .iter() - .map(|(k, v)| (k.clone(), domain_value_to_json(v))) - .collect(); - serde_json::Value::Object(obj) - } - } -} - -fn json_value_to_domain(v: &serde_json::Value) -> Value { - match v { - serde_json::Value::Null => Value::Null, - serde_json::Value::Bool(b) => Value::Bool(*b), - serde_json::Value::Number(n) => Value::Number(n.as_f64().unwrap_or(0.0)), - serde_json::Value::String(s) => Value::String(s.clone()), - serde_json::Value::Array(arr) => { - Value::Array(arr.iter().map(json_value_to_domain).collect()) - } - serde_json::Value::Object(map) => Value::Object( - map.iter() - .map(|(k, v)| (k.clone(), json_value_to_domain(v))) - .collect(), - ), - } -} - fn domain_state_to_json(state: &WidgetState) -> Result { let data: serde_json::Map = state .data .iter() - .map(|(k, v)| (k.clone(), domain_value_to_json(v))) + .map(|(k, v)| (k.clone(), v.into())) .collect(); serde_json::to_string(&data) } fn json_to_domain_state(json: &str) -> Result { let map: serde_json::Map = serde_json::from_str(json)?; - let data: BTreeMap = map - .iter() - .map(|(k, v)| (k.clone(), json_value_to_domain(v))) - .collect(); + let data: BTreeMap = map.into_iter().map(|(k, v)| (k, v.into())).collect(); Ok(WidgetState { data, error: None }) } diff --git a/crates/adapters/config-sqlite/tests/config_store_tests.rs b/crates/adapters/config-sqlite/tests/config_store_tests.rs index 9ce2429..e2a00b4 100644 --- a/crates/adapters/config-sqlite/tests/config_store_tests.rs +++ b/crates/adapters/config-sqlite/tests/config_store_tests.rs @@ -2,7 +2,7 @@ use config_sqlite::SqliteConfigStore; use domain::{ AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType, Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild, - LayoutNode, LayoutPreset, Sizing, WidgetConfig, + LayoutNode, LayoutPreset, Sizing, UserRepository, WidgetConfig, WidgetStateCache, }; use std::time::Duration; diff --git a/crates/adapters/http-api/Cargo.toml b/crates/adapters/http-api/Cargo.toml index 1f38e72..e44a2bc 100644 --- a/crates/adapters/http-api/Cargo.toml +++ b/crates/adapters/http-api/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -domain.workspace = true +domain = { workspace = true, features = ["json"] } application.workspace = true api-types.workspace = true axum.workspace = true diff --git a/crates/adapters/http-api/src/lib.rs b/crates/adapters/http-api/src/lib.rs index 3807944..54052a5 100644 --- a/crates/adapters/http-api/src/lib.rs +++ b/crates/adapters/http-api/src/lib.rs @@ -4,7 +4,7 @@ mod routes; use axum::Router; use domain::{ AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort, - WidgetStateReader, + UserRepository, WidgetStateCache, WidgetStateReader, }; use std::sync::Arc; use tower_http::cors::CorsLayer; @@ -38,8 +38,9 @@ impl Clone for AppState { pub fn router(state: AppState) -> Router where - C: ConfigRepository + Send + Sync + 'static, - C::Error: std::fmt::Debug + Send, + C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static, + ::Error: std::fmt::Debug + Send, + ::Error: std::fmt::Debug + Send, E: EventPublisher + Send + Sync + 'static, E::Error: std::fmt::Debug + Send, W: WidgetStateReader + Send + Sync + 'static, @@ -69,8 +70,9 @@ pub async fn serve( state: AppState, ) -> Result<(), std::io::Error> where - C: ConfigRepository + Send + Sync + 'static, - C::Error: std::fmt::Debug + Send, + C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static, + ::Error: std::fmt::Debug + Send, + ::Error: std::fmt::Debug + Send, E: EventPublisher + Send + Sync + 'static, E::Error: std::fmt::Debug + Send, W: WidgetStateReader + Send + Sync + 'static, diff --git a/crates/adapters/http-api/src/routes/auth.rs b/crates/adapters/http-api/src/routes/auth.rs index d4ee12b..0ab9516 100644 --- a/crates/adapters/http-api/src/routes/auth.rs +++ b/crates/adapters/http-api/src/routes/auth.rs @@ -2,7 +2,7 @@ use crate::AppState; use axum::extract::State; use axum::http::StatusCode; use axum::response::Json; -use domain::{AuthPort, ConfigRepository, PasswordHashPort}; +use domain::{AuthPort, PasswordHashPort, UserRepository}; use serde::{Deserialize, Serialize}; type S = State>; @@ -28,7 +28,7 @@ pub async fn login( Json(body): Json, ) -> Result, (StatusCode, String)> where - C: ConfigRepository, + C: UserRepository, C::Error: std::fmt::Debug, A: AuthPort, H: PasswordHashPort, @@ -51,7 +51,7 @@ pub async fn register( Json(body): Json, ) -> Result where - C: ConfigRepository, + C: UserRepository, C::Error: std::fmt::Debug, H: PasswordHashPort, { @@ -71,7 +71,7 @@ pub async fn auth_status( State(state): S, ) -> Result, StatusCode> where - C: ConfigRepository, + C: UserRepository, C::Error: std::fmt::Debug, { let count = state diff --git a/crates/adapters/http-api/src/routes/mod.rs b/crates/adapters/http-api/src/routes/mod.rs index c6b2598..8f8bca5 100644 --- a/crates/adapters/http-api/src/routes/mod.rs +++ b/crates/adapters/http-api/src/routes/mod.rs @@ -12,13 +12,14 @@ use axum::Router; use axum::routing::{get, post}; use domain::{ AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort, - WidgetStateReader, + UserRepository, WidgetStateCache, WidgetStateReader, }; pub fn api_routes() -> Router> where - C: ConfigRepository + Send + Sync + 'static, - C::Error: std::fmt::Debug + Send, + C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static, + ::Error: std::fmt::Debug + Send, + ::Error: std::fmt::Debug + Send, E: EventPublisher + Send + Sync + 'static, E::Error: std::fmt::Debug + Send, W: WidgetStateReader + Send + Sync + 'static, diff --git a/crates/adapters/http-api/src/routes/webhook.rs b/crates/adapters/http-api/src/routes/webhook.rs index b4e5bac..d54cfa3 100644 --- a/crates/adapters/http-api/src/routes/webhook.rs +++ b/crates/adapters/http-api/src/routes/webhook.rs @@ -31,7 +31,7 @@ where )); } - let data = json_to_domain_value(body); + let data: domain::Value = body.into(); state .events @@ -41,22 +41,3 @@ where Ok(StatusCode::OK) } - -fn json_to_domain_value(json: serde_json::Value) -> domain::Value { - match json { - serde_json::Value::Null => domain::Value::Null, - serde_json::Value::Bool(b) => domain::Value::Bool(b), - serde_json::Value::Number(n) => domain::Value::Number(n.as_f64().unwrap_or(0.0)), - serde_json::Value::String(s) => domain::Value::String(s), - serde_json::Value::Array(arr) => { - domain::Value::Array(arr.into_iter().map(json_to_domain_value).collect()) - } - serde_json::Value::Object(obj) => { - let map = obj - .into_iter() - .map(|(k, v)| (k, json_to_domain_value(v))) - .collect(); - domain::Value::Object(map) - } - } -} diff --git a/crates/adapters/http-api/src/routes/widgets.rs b/crates/adapters/http-api/src/routes/widgets.rs index d5c8883..4e4418b 100644 --- a/crates/adapters/http-api/src/routes/widgets.rs +++ b/crates/adapters/http-api/src/routes/widgets.rs @@ -126,32 +126,10 @@ where { match state.widget_states.get_widget_state(id).await { Some(ws) => { - let map: serde_json::Map = ws - .data - .iter() - .map(|(k, v)| (k.clone(), domain_value_to_json(v))) - .collect(); + let map: serde_json::Map = + ws.data.iter().map(|(k, v)| (k.clone(), v.into())).collect(); Ok(Json(serde_json::Value::Object(map))) } None => Err(StatusCode::NOT_FOUND), } } - -fn domain_value_to_json(v: &domain::Value) -> serde_json::Value { - match v { - domain::Value::Null => serde_json::Value::Null, - domain::Value::Bool(b) => serde_json::Value::Bool(*b), - domain::Value::Number(n) => serde_json::json!(n), - domain::Value::String(s) => serde_json::Value::String(s.clone()), - domain::Value::Array(arr) => { - serde_json::Value::Array(arr.iter().map(domain_value_to_json).collect()) - } - domain::Value::Object(obj) => { - let map = obj - .iter() - .map(|(k, v)| (k.clone(), domain_value_to_json(v))) - .collect(); - serde_json::Value::Object(map) - } - } -} diff --git a/crates/adapters/http-json/Cargo.toml b/crates/adapters/http-json/Cargo.toml index 2502db9..35d073e 100644 --- a/crates/adapters/http-json/Cargo.toml +++ b/crates/adapters/http-json/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -domain.workspace = true +domain = { workspace = true, features = ["json"] } reqwest.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/adapters/http-json/src/lib.rs b/crates/adapters/http-json/src/lib.rs index a3efed6..5b7e1f6 100644 --- a/crates/adapters/http-json/src/lib.rs +++ b/crates/adapters/http-json/src/lib.rs @@ -28,21 +28,6 @@ impl HttpJsonAdapter { } } -fn json_to_value(json: serde_json::Value) -> Value { - match json { - serde_json::Value::Null => Value::Null, - serde_json::Value::Bool(b) => Value::Bool(b), - serde_json::Value::Number(n) => Value::Number(n.as_f64().unwrap_or(0.0)), - serde_json::Value::String(s) => Value::String(s), - serde_json::Value::Array(arr) => Value::Array(arr.into_iter().map(json_to_value).collect()), - serde_json::Value::Object(map) => Value::Object( - map.into_iter() - .map(|(k, v)| (k, json_to_value(v))) - .collect(), - ), - } -} - impl DataSourcePort for HttpJsonAdapter { type Error = HttpJsonError; @@ -70,6 +55,6 @@ impl DataSourcePort for HttpJsonAdapter { let resp = req.send().await.map_err(HttpJsonError::Request)?; let json: serde_json::Value = resp.json().await.map_err(HttpJsonError::Request)?; - Ok(json_to_value(json)) + Ok(json.into()) } } diff --git a/crates/adapters/tcp-server/src/broadcaster.rs b/crates/adapters/tcp-server/src/broadcaster.rs index dcb96dd..c155351 100644 --- a/crates/adapters/tcp-server/src/broadcaster.rs +++ b/crates/adapters/tcp-server/src/broadcaster.rs @@ -1,6 +1,7 @@ +use crate::conversions::{display_hint_to_wire, layout_to_wire, widget_state_to_wire}; use crate::error::TcpServerError; use domain::{BroadcastPort, DisplayHint, Layout, ThemeConfig, WidgetId, WidgetState}; -use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireLayoutNode, WireTheme, encode}; +use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireTheme, encode}; use tokio::sync::broadcast; pub struct TcpBroadcaster { @@ -31,13 +32,13 @@ impl BroadcastPort for TcpBroadcaster { layout: &Layout, widgets: &[(WidgetId, DisplayHint, WidgetState)], ) -> Result<(), Self::Error> { - let wire_layout: WireLayoutNode = (&layout.root).into(); + let wire_layout = layout_to_wire(&layout.root); let wire_widgets: Vec = widgets .iter() .map(|(id, hint, state)| WidgetDescriptor { id: *id, - display_hint: hint.into(), - state: state.into(), + display_hint: display_hint_to_wire(hint), + state: widget_state_to_wire(state), }) .collect(); @@ -58,8 +59,8 @@ impl BroadcastPort for TcpBroadcaster { .iter() .map(|(id, hint, state)| WidgetDescriptor { id: *id, - display_hint: hint.into(), - state: state.into(), + display_hint: display_hint_to_wire(hint), + state: widget_state_to_wire(state), }) .collect(); diff --git a/crates/adapters/tcp-server/src/conversions.rs b/crates/adapters/tcp-server/src/conversions.rs new file mode 100644 index 0000000..9930bb1 --- /dev/null +++ b/crates/adapters/tcp-server/src/conversions.rs @@ -0,0 +1,103 @@ +use domain::value_objects::{ + AlignItems, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent, LayoutNode, + Sizing, VAlign, Value, WidgetError, WidgetState, +}; +use protocol::{ + WireAlignItems, WireContainerNode, WireDirection, WireDisplayHint, WireDisplayHintKind, + WireHAlign, WireJustifyContent, WireKeyValue, WireLayoutChild, WireLayoutNode, WireSizing, + WireVAlign, WireValue, WireWidgetError, WireWidgetState, +}; + +pub fn value_to_wire(v: &Value) -> WireValue { + match v { + Value::Null => WireValue::Null, + Value::Bool(b) => WireValue::Bool(*b), + Value::Number(n) => WireValue::Number(*n), + Value::String(s) => WireValue::String(s.clone()), + Value::Array(arr) => WireValue::Array(arr.iter().map(value_to_wire).collect()), + Value::Object(map) => WireValue::Object( + map.iter() + .map(|(k, v)| (k.clone(), value_to_wire(v))) + .collect(), + ), + } +} + +pub fn widget_error_to_wire(e: &WidgetError) -> WireWidgetError { + match e { + WidgetError::SourceUnavailable => WireWidgetError::SourceUnavailable, + WidgetError::ExtractionFailed => WireWidgetError::ExtractionFailed, + } +} + +pub fn widget_state_to_wire(s: &WidgetState) -> WireWidgetState { + WireWidgetState { + data: s + .data + .iter() + .map(|(k, v)| WireKeyValue { + key: k.clone(), + value: value_to_wire(v), + }) + .collect(), + error: s.error.as_ref().map(widget_error_to_wire), + } +} + +pub fn display_hint_to_wire(h: &DisplayHint) -> WireDisplayHint { + WireDisplayHint { + kind: match h.kind { + DisplayHintKind::IconValue => WireDisplayHintKind::IconValue, + DisplayHintKind::TextBlock => WireDisplayHintKind::TextBlock, + DisplayHintKind::KeyValue => WireDisplayHintKind::KeyValue, + }, + h_align: match h.h_align { + HAlign::Left => WireHAlign::Left, + HAlign::Center => WireHAlign::Center, + HAlign::Right => WireHAlign::Right, + }, + v_align: match h.v_align { + VAlign::Top => WireVAlign::Top, + VAlign::Middle => WireVAlign::Middle, + VAlign::Bottom => WireVAlign::Bottom, + }, + } +} + +pub fn layout_to_wire(n: &LayoutNode) -> WireLayoutNode { + match n { + LayoutNode::Leaf(id) => WireLayoutNode::Leaf(*id), + LayoutNode::Container(c) => WireLayoutNode::Container(WireContainerNode { + direction: match c.direction { + Direction::Row => WireDirection::Row, + Direction::Column => WireDirection::Column, + }, + gap: c.gap, + padding: c.padding, + justify_content: match c.justify_content { + JustifyContent::Start => WireJustifyContent::Start, + JustifyContent::Center => WireJustifyContent::Center, + JustifyContent::End => WireJustifyContent::End, + JustifyContent::SpaceBetween => WireJustifyContent::SpaceBetween, + JustifyContent::SpaceEvenly => WireJustifyContent::SpaceEvenly, + }, + align_items: match c.align_items { + AlignItems::Start => WireAlignItems::Start, + AlignItems::Center => WireAlignItems::Center, + AlignItems::End => WireAlignItems::End, + AlignItems::Stretch => WireAlignItems::Stretch, + }, + children: c + .children + .iter() + .map(|ch| WireLayoutChild { + sizing: match ch.sizing { + Sizing::Fixed(px) => WireSizing::Fixed(px), + Sizing::Flex(w) => WireSizing::Flex(w), + }, + node: layout_to_wire(&ch.node), + }) + .collect(), + }), + } +} diff --git a/crates/adapters/tcp-server/src/lib.rs b/crates/adapters/tcp-server/src/lib.rs index 4ab1c81..ee3bd9e 100644 --- a/crates/adapters/tcp-server/src/lib.rs +++ b/crates/adapters/tcp-server/src/lib.rs @@ -1,5 +1,6 @@ mod broadcaster; mod client_tracker; +mod conversions; mod error; mod event_bus; mod server; diff --git a/crates/adapters/tcp-server/src/server.rs b/crates/adapters/tcp-server/src/server.rs index f75fed0..563a856 100644 --- a/crates/adapters/tcp-server/src/server.rs +++ b/crates/adapters/tcp-server/src/server.rs @@ -1,8 +1,9 @@ use crate::broadcaster::domain_theme_to_wire; use crate::client_tracker::ClientTracker; +use crate::conversions::{display_hint_to_wire, layout_to_wire, widget_state_to_wire}; use crate::error::TcpServerError; use domain::{ConfigRepository, WidgetStateReader}; -use protocol::{ServerMessage, WidgetDescriptor, WireLayoutNode, encode}; +use protocol::{ServerMessage, WidgetDescriptor, encode}; use std::sync::Arc; use tokio::io::AsyncWriteExt; use tokio::net::TcpListener; @@ -87,14 +88,14 @@ where } }; - let wire_layout: WireLayoutNode = (&layout.root).into(); + let wire_layout = layout_to_wire(&layout.root); let mut wire_widgets = Vec::new(); for w in &widgets { if let Some(s) = widget_states.get_widget_state(w.id).await { wire_widgets.push(WidgetDescriptor { id: w.id, - display_hint: (&w.display_hint).into(), - state: (&s).into(), + display_hint: display_hint_to_wire(&w.display_hint), + state: widget_state_to_wire(&s), }); } } diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index 3c6ec35..8d484c9 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -7,6 +7,8 @@ edition = "2024" domain.workspace = true thiserror.workspace = true tokio.workspace = true +anyhow.workspace = true +tracing.workspace = true [dev-dependencies] tokio = { workspace = true } diff --git a/crates/application/src/auth_service.rs b/crates/application/src/auth_service.rs index 0e2cf9e..2a5b8f6 100644 --- a/crates/application/src/auth_service.rs +++ b/crates/application/src/auth_service.rs @@ -1,4 +1,4 @@ -use domain::{AuthPort, ConfigRepository, PasswordHashPort, User}; +use domain::{AuthPort, PasswordHashPort, User, UserRepository}; pub enum AuthError { InvalidCredentials, @@ -26,7 +26,7 @@ pub async fn login( password: &str, ) -> Result> where - C: ConfigRepository, + C: UserRepository, A: AuthPort, H: PasswordHashPort, { @@ -55,7 +55,7 @@ pub async fn register( password: &str, ) -> Result<(), AuthError> where - C: ConfigRepository, + C: UserRepository, H: PasswordHashPort, { let count = config.count_users().await.map_err(AuthError::Repository)?; diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 63427c2..99ed404 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1,6 +1,7 @@ pub mod auth_service; mod config_service; mod data_projection; +pub mod polling_service; pub use config_service::ConfigService; pub use data_projection::DataProjection; diff --git a/crates/application/src/polling_service.rs b/crates/application/src/polling_service.rs new file mode 100644 index 0000000..1d1ad73 --- /dev/null +++ b/crates/application/src/polling_service.rs @@ -0,0 +1,271 @@ +use crate::DataProjection; +use domain::{ + BroadcastPort, ConfigRepository, DataSource, Value, WidgetConfig, WidgetError, WidgetState, + WidgetStateCache, +}; +use std::collections::{HashMap, HashSet}; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; +use tokio::task::JoinHandle; +use tracing::{debug, info, warn}; + +const SOURCE_REFRESH_INTERVAL: Duration = Duration::from_secs(30); + +pub async fn run( + config: Arc, + broadcaster: Arc, + projection: Arc, + poller: Arc

, +) where + C: ConfigRepository + WidgetStateCache + Send + Sync + 'static, + ::Error: std::fmt::Display + Send, + ::Error: std::fmt::Display + Send, + B: BroadcastPort + Send + Sync + 'static, + B::Error: std::fmt::Display + Send, + P: Fn(&DataSource) -> F + Send + Sync + 'static, + F: Future> + Send, +{ + let mut running: HashMap> = HashMap::new(); + let mut static_done: HashSet = HashSet::new(); + + info!("polling manager started"); + + loop { + let sources = match config.list_data_sources().await { + Ok(s) => s, + Err(e) => { + warn!(error = %e, "failed to list data sources"); + tokio::time::sleep(SOURCE_REFRESH_INTERVAL).await; + continue; + } + }; + + let current_ids: Vec = sources.iter().map(|s| s.id).collect(); + + running.retain(|id, handle| { + if !current_ids.contains(id) { + info!(source_id = id, "stopping poll for removed source"); + handle.abort(); + false + } else { + true + } + }); + static_done.retain(|id| current_ids.contains(id)); + + for source in &sources { + if source.source_type == domain::DataSourceType::Webhook { + continue; + } + + if source.source_type == domain::DataSourceType::StaticText { + if static_done.contains(&source.id) { + continue; + } + poll_and_broadcast(&*poller, source, &config, &broadcaster, &projection).await; + static_done.insert(source.id); + continue; + } + + if running.contains_key(&source.id) { + continue; + } + + let source_id = source.id; + let source = source.clone(); + let config = config.clone(); + let broadcaster = broadcaster.clone(); + let projection = projection.clone(); + let poller = poller.clone(); + + info!( + source_id = source.id, + name = %source.name, + interval_secs = source.poll_interval.as_secs(), + "starting poll task" + ); + + let handle = tokio::spawn(async move { + poll_loop(source, config, broadcaster, projection, poller).await; + }); + + running.insert(source_id, handle); + } + + if running.is_empty() && static_done.is_empty() { + debug!("no pollable sources, waiting"); + } + + tokio::time::sleep(SOURCE_REFRESH_INTERVAL).await; + } +} + +async fn poll_and_broadcast( + poller: &P, + source: &DataSource, + config: &Arc, + broadcaster: &Arc, + projection: &Arc, +) where + C: ConfigRepository + WidgetStateCache, + ::Error: std::fmt::Display, + ::Error: std::fmt::Display, + B: BroadcastPort, + B::Error: std::fmt::Display, + P: Fn(&DataSource) -> F, + F: Future>, +{ + let result = match poller(source).await { + Ok(v) => v, + Err(e) => { + warn!(source = %source.name, error = %e, "poll failed"); + return; + } + }; + let widgets = match config.list_widgets().await { + Ok(w) => w, + Err(e) => { + warn!(error = %e, "failed to fetch widgets"); + return; + } + }; + broadcast_changes(source, &result, &widgets, broadcaster, projection, config).await; +} + +async fn poll_loop( + source: DataSource, + config: Arc, + broadcaster: Arc, + projection: Arc, + poller: Arc

, +) where + C: ConfigRepository + WidgetStateCache, + ::Error: std::fmt::Display, + ::Error: std::fmt::Display, + B: BroadcastPort, + B::Error: std::fmt::Display, + P: Fn(&DataSource) -> F, + F: Future>, +{ + let interval = source.poll_interval; + let mut widgets = match config.list_widgets().await { + Ok(w) => w, + Err(e) => { + warn!(error = %e, "failed to fetch initial widget list"); + vec![] + } + }; + let mut last_refresh = tokio::time::Instant::now(); + + loop { + let result = match poller(&source).await { + Ok(v) => v, + Err(e) => { + warn!(source = %source.name, error = %e, "poll failed"); + broadcast_errors(&source, &widgets, &broadcaster, &projection).await; + tokio::time::sleep(interval).await; + continue; + } + }; + + if last_refresh.elapsed() >= SOURCE_REFRESH_INTERVAL { + if let Ok(w) = config.list_widgets().await { + widgets = w; + } + last_refresh = tokio::time::Instant::now(); + } + + broadcast_changes( + &source, + &result, + &widgets, + &broadcaster, + &projection, + &config, + ) + .await; + + tokio::time::sleep(interval).await; + } +} + +async fn broadcast_changes( + source: &DataSource, + result: &Value, + widgets: &[WidgetConfig], + broadcaster: &Arc, + projection: &Arc, + config: &Arc, +) where + C: WidgetStateCache, + C::Error: std::fmt::Display, + B: BroadcastPort, + B::Error: std::fmt::Display, +{ + let changed: Vec<(u16, WidgetState)> = projection + .apply_poll_result(source.id, result, widgets) + .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 Err(e) = broadcaster.push_data_update(&with_hints).await { + warn!(error = %e, "failed to push update"); + } + if let Err(e) = config.save_widget_states(&changed).await { + warn!(error = %e, "failed to cache widget states"); + } + info!(source = %source.name, count = changed.len(), "pushed widget updates"); + } +} + +async fn broadcast_errors( + source: &DataSource, + widgets: &[WidgetConfig], + broadcaster: &Arc, + projection: &Arc, +) where + B: BroadcastPort, + B::Error: std::fmt::Display, +{ + let affected: Vec<_> = widgets + .iter() + .filter(|w| w.data_source_id == source.id) + .collect(); + + if affected.is_empty() { + return; + } + + let mut error_states = Vec::new(); + for w in &affected { + let mut state = projection + .get_state(w.id) + .await + .unwrap_or_else(|| WidgetState { + data: std::collections::BTreeMap::new(), + error: None, + }); + state.error = Some(WidgetError::SourceUnavailable); + error_states.push((w.id, state)); + } + + projection.seed(error_states.clone()).await; + + let with_hints: Vec<_> = error_states + .iter() + .filter_map(|(id, state)| { + let hint = affected.iter().find(|w| w.id == *id)?.display_hint.clone(); + Some((*id, hint, state.clone())) + }) + .collect(); + if let Err(e) = broadcaster.push_data_update(&with_hints).await { + warn!(error = %e, "failed to push error update"); + } +} diff --git a/crates/application/tests/support/mod.rs b/crates/application/tests/support/mod.rs index b8760ef..d4a8f5e 100644 --- a/crates/application/tests/support/mod.rs +++ b/crates/application/tests/support/mod.rs @@ -1,6 +1,7 @@ use domain::{ ConfigRepository, DataSource, DataSourceId, DomainEvent, EventPublisher, Layout, LayoutPreset, - LayoutPresetId, ThemeConfig, User, WidgetConfig, WidgetId, WidgetState, + LayoutPresetId, ThemeConfig, User, UserRepository, WidgetConfig, WidgetId, WidgetState, + WidgetStateCache, }; use std::collections::HashMap; use std::sync::Mutex; @@ -123,6 +124,10 @@ impl ConfigRepository for InMemoryConfigRepository { self.presets.lock().unwrap().remove(&id); Ok(()) } +} + +impl UserRepository for InMemoryConfigRepository { + type Error = Never; async fn get_user_by_username(&self, _username: &str) -> Result, Self::Error> { Ok(None) @@ -135,6 +140,10 @@ impl ConfigRepository for InMemoryConfigRepository { async fn count_users(&self) -> Result { Ok(0) } +} + +impl WidgetStateCache for InMemoryConfigRepository { + type Error = Never; async fn save_widget_states( &self, diff --git a/crates/bootstrap/src/main.rs b/crates/bootstrap/src/main.rs index bc0540b..58150b6 100644 --- a/crates/bootstrap/src/main.rs +++ b/crates/bootstrap/src/main.rs @@ -5,7 +5,7 @@ mod polling; use anyhow::Result; use application::DataProjection; use config_sqlite::SqliteConfigStore; -use domain::ConfigRepository; +use domain::WidgetStateCache; use http_api::AppState; use kframe_auth::{Argon2Hasher, AuthConfig, JwtAuthService}; use secret_store::AesSecretStore; diff --git a/crates/bootstrap/src/polling.rs b/crates/bootstrap/src/polling.rs index 8c4f3a9..5c2d53e 100644 --- a/crates/bootstrap/src/polling.rs +++ b/crates/bootstrap/src/polling.rs @@ -2,21 +2,12 @@ use anyhow::Result; use application::DataProjection; use config_sqlite::SqliteConfigStore; use data_generators::{ClockGenerator, StaticTextGenerator}; -use domain::{ - BroadcastPort, ConfigRepository, DataSource, DataSourcePort, DataSourceType, Value, - WidgetError, WidgetState, -}; +use domain::{DataSource, DataSourcePort, DataSourceType, Value}; use http_json::HttpJsonAdapter; use media_adapter::MediaAdapter; use rss_adapter::RssAdapter; -use std::collections::HashMap; use std::sync::Arc; -use std::time::Duration; use tcp_server::TcpBroadcaster; -use tokio::task::JoinHandle; -use tracing::{debug, info, warn}; - -const SOURCE_REFRESH_INTERVAL: Duration = Duration::from_secs(30); #[derive(Clone)] struct Adapters { @@ -76,219 +67,12 @@ pub async fn run( static_text: Arc::new(StaticTextGenerator::new()), }; - let mut running: HashMap> = HashMap::new(); - let mut static_done: std::collections::HashSet = std::collections::HashSet::new(); + let poller = Arc::new(move |source: &DataSource| { + let adapters = adapters.clone(); + let source = source.clone(); + async move { adapters.poll(&source).await } + }); - info!("polling manager started"); - - loop { - let sources = config - .list_data_sources() - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - - let current_ids: Vec = sources.iter().map(|s| s.id).collect(); - - running.retain(|id, handle| { - if !current_ids.contains(id) { - info!(source_id = id, "stopping poll for removed source"); - handle.abort(); - false - } else { - true - } - }); - static_done.retain(|id| current_ids.contains(id)); - - for source in &sources { - if source.source_type == DataSourceType::Webhook { - continue; - } - - // Static text: poll once inline, never spawn a task - if source.source_type == DataSourceType::StaticText { - if static_done.contains(&source.id) { - continue; - } - poll_once(&adapters, source, &config, &broadcaster, &projection).await; - static_done.insert(source.id); - continue; - } - - if running.contains_key(&source.id) { - continue; - } - - let source_id = source.id; - let source = source.clone(); - let config = config.clone(); - let broadcaster = broadcaster.clone(); - let projection = projection.clone(); - let adapters = adapters.clone(); - - info!( - source_id = source.id, - name = %source.name, - interval_secs = source.poll_interval.as_secs(), - "starting poll task" - ); - - let handle = tokio::spawn(async move { - poll_loop(source, config, broadcaster, projection, adapters).await; - }); - - running.insert(source_id, handle); - } - - if running.is_empty() && static_done.is_empty() { - debug!("no pollable sources, waiting"); - } - - tokio::time::sleep(SOURCE_REFRESH_INTERVAL).await; - } -} - -async fn poll_once( - adapters: &Adapters, - source: &DataSource, - config: &Arc, - broadcaster: &Arc, - projection: &Arc, -) { - let result = match adapters.poll(source).await { - Ok(v) => v, - Err(e) => { - warn!(source = %source.name, error = %e, "poll failed"); - return; - } - }; - let widgets = match config.list_widgets().await { - Ok(w) => w, - Err(e) => { - warn!(error = %e, "failed to fetch widgets"); - return; - } - }; - broadcast_changes(source, &result, &widgets, broadcaster, projection, config).await; -} - -async fn poll_loop( - source: DataSource, - config: Arc, - broadcaster: Arc, - projection: Arc, - adapters: Adapters, -) { - let interval = source.poll_interval; - let mut widgets = match config.list_widgets().await { - Ok(w) => w, - Err(e) => { - warn!(error = %e, "failed to fetch initial widget list"); - vec![] - } - }; - let mut last_refresh = tokio::time::Instant::now(); - - loop { - let result = match adapters.poll(&source).await { - Ok(v) => v, - Err(e) => { - warn!(source = %source.name, error = %e, "poll failed"); - broadcast_errors(&source, &widgets, &broadcaster, &projection).await; - tokio::time::sleep(interval).await; - continue; - } - }; - - if last_refresh.elapsed() >= SOURCE_REFRESH_INTERVAL { - if let Ok(w) = config.list_widgets().await { - widgets = w; - } - last_refresh = tokio::time::Instant::now(); - } - - broadcast_changes( - &source, - &result, - &widgets, - &broadcaster, - &projection, - &config, - ) - .await; - - tokio::time::sleep(interval).await; - } -} - -async fn broadcast_changes( - source: &DataSource, - result: &Value, - widgets: &[domain::WidgetConfig], - broadcaster: &Arc, - projection: &Arc, - config: &Arc, -) { - let changed: Vec<(u16, WidgetState)> = projection - .apply_poll_result(source.id, result, widgets) - .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 Err(e) = broadcaster.push_data_update(&with_hints).await { - warn!(error = %e, "failed to push update"); - } - if let Err(e) = config.save_widget_states(&changed).await { - warn!(error = %e, "failed to cache widget states"); - } - info!(source = %source.name, count = changed.len(), "pushed widget updates"); - } -} - -async fn broadcast_errors( - source: &DataSource, - widgets: &[domain::WidgetConfig], - broadcaster: &Arc, - projection: &Arc, -) { - let affected: Vec<_> = widgets - .iter() - .filter(|w| w.data_source_id == source.id) - .collect(); - - if affected.is_empty() { - return; - } - - let mut error_states = Vec::new(); - for w in &affected { - let mut state = projection - .get_state(w.id) - .await - .unwrap_or_else(|| WidgetState { - data: std::collections::BTreeMap::new(), - error: None, - }); - state.error = Some(WidgetError::SourceUnavailable); - error_states.push((w.id, state)); - } - - projection.seed(error_states.clone()).await; - - let with_hints: Vec<_> = error_states - .iter() - .filter_map(|(id, state)| { - let hint = affected.iter().find(|w| w.id == *id)?.display_hint.clone(); - Some((*id, hint, state.clone())) - }) - .collect(); - if let Err(e) = broadcaster.push_data_update(&with_hints).await { - warn!(error = %e, "failed to push error update"); - } + application::polling_service::run(config, broadcaster, projection, poller).await; + Ok(()) } diff --git a/crates/client-application/src/client_app.rs b/crates/client-application/src/client_app.rs index 2747383..b89acc4 100644 --- a/crates/client-application/src/client_app.rs +++ b/crates/client-application/src/client_app.rs @@ -1,5 +1,5 @@ +use crate::conversions::wire_to_layout; use client_domain::{BoundingBox, Color, LayoutEngine, RenderTree, ThemeConfig}; -use domain::LayoutNode; use protocol::{ ServerMessage, WidgetDescriptor, WireColor, WireDisplayHint, WireLayoutNode, WireWidgetState, }; @@ -73,7 +73,7 @@ impl ClientApp { wire_layout: WireLayoutNode, widgets: Vec, ) -> Vec { - let layout: LayoutNode = wire_layout.into(); + let layout = wire_to_layout(wire_layout); let new_tree = LayoutEngine::compute(&layout, self.screen); self.widget_states.clear(); diff --git a/crates/client-application/src/connection_loop.rs b/crates/client-application/src/connection_loop.rs new file mode 100644 index 0000000..8453af7 --- /dev/null +++ b/crates/client-application/src/connection_loop.rs @@ -0,0 +1,42 @@ +use client_domain::NetworkPort; +use protocol::{ServerMessage, decode_server_message}; +use std::thread; +use std::time::Duration; + +pub fn run_connection_loop( + net: &mut N, + server_addr: &str, + poll_interval: Duration, + reconnect_delay: Duration, + mut on_message: impl FnMut(ServerMessage), + mut on_connection_change: impl FnMut(bool), +) { + loop { + if !net.is_connected() { + match net.connect(server_addr) { + Ok(()) => on_connection_change(true), + Err(_) => { + on_connection_change(false); + thread::sleep(reconnect_delay); + continue; + } + } + } + + match net.receive() { + Ok(Some(payload)) => { + if let Ok(msg) = decode_server_message(&payload) { + on_message(msg); + } + } + Ok(None) => { + thread::sleep(poll_interval); + } + Err(_) => { + let _ = net.disconnect(); + on_connection_change(false); + thread::sleep(reconnect_delay); + } + } + } +} diff --git a/crates/client-application/src/conversions.rs b/crates/client-application/src/conversions.rs new file mode 100644 index 0000000..e7f276c --- /dev/null +++ b/crates/client-application/src/conversions.rs @@ -0,0 +1,100 @@ +use domain::value_objects::{ + AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent, + LayoutChild, LayoutNode, Sizing, VAlign, Value, WidgetError, WidgetState, +}; +use protocol::{ + WireAlignItems, WireDirection, WireDisplayHint, WireDisplayHintKind, WireHAlign, + WireJustifyContent, WireLayoutNode, WireSizing, WireVAlign, WireValue, WireWidgetError, + WireWidgetState, +}; + +pub fn wire_to_value(w: WireValue) -> Value { + match w { + WireValue::Null => Value::Null, + WireValue::Bool(b) => Value::Bool(b), + WireValue::Number(n) => Value::Number(n), + WireValue::String(s) => Value::String(s), + WireValue::Array(arr) => Value::Array(arr.into_iter().map(wire_to_value).collect()), + WireValue::Object(map) => Value::Object( + map.into_iter() + .map(|(k, v)| (k, wire_to_value(v))) + .collect(), + ), + } +} + +pub fn wire_to_widget_error(w: WireWidgetError) -> WidgetError { + match w { + WireWidgetError::SourceUnavailable => WidgetError::SourceUnavailable, + WireWidgetError::ExtractionFailed => WidgetError::ExtractionFailed, + } +} + +pub fn wire_to_widget_state(w: WireWidgetState) -> WidgetState { + WidgetState { + data: w + .data + .into_iter() + .map(|kv| (kv.key, wire_to_value(kv.value))) + .collect(), + error: w.error.map(wire_to_widget_error), + } +} + +pub fn wire_to_display_hint(w: WireDisplayHint) -> DisplayHint { + DisplayHint { + kind: match w.kind { + WireDisplayHintKind::IconValue => DisplayHintKind::IconValue, + WireDisplayHintKind::TextBlock => DisplayHintKind::TextBlock, + WireDisplayHintKind::KeyValue => DisplayHintKind::KeyValue, + }, + h_align: match w.h_align { + WireHAlign::Left => HAlign::Left, + WireHAlign::Center => HAlign::Center, + WireHAlign::Right => HAlign::Right, + }, + v_align: match w.v_align { + WireVAlign::Top => VAlign::Top, + WireVAlign::Middle => VAlign::Middle, + WireVAlign::Bottom => VAlign::Bottom, + }, + } +} + +pub fn wire_to_layout(w: WireLayoutNode) -> LayoutNode { + match w { + WireLayoutNode::Leaf(id) => LayoutNode::Leaf(id), + WireLayoutNode::Container(c) => LayoutNode::Container(ContainerNode { + direction: match c.direction { + WireDirection::Row => Direction::Row, + WireDirection::Column => Direction::Column, + }, + gap: c.gap, + padding: c.padding, + justify_content: match c.justify_content { + WireJustifyContent::Start => JustifyContent::Start, + WireJustifyContent::Center => JustifyContent::Center, + WireJustifyContent::End => JustifyContent::End, + WireJustifyContent::SpaceBetween => JustifyContent::SpaceBetween, + WireJustifyContent::SpaceEvenly => JustifyContent::SpaceEvenly, + }, + align_items: match c.align_items { + WireAlignItems::Start => AlignItems::Start, + WireAlignItems::Center => AlignItems::Center, + WireAlignItems::End => AlignItems::End, + WireAlignItems::Stretch => AlignItems::Stretch, + }, + children: c + .children + .into_iter() + .map(|ch| LayoutChild { + sizing: match ch.sizing { + WireSizing::Fixed(px) => Sizing::Fixed(px), + WireSizing::Flex(weight) => Sizing::Flex(weight), + }, + node: wire_to_layout(ch.node), + }) + .collect(), + }), + } +} diff --git a/crates/client-application/src/lib.rs b/crates/client-application/src/lib.rs index 9f7e106..e9f2460 100644 --- a/crates/client-application/src/lib.rs +++ b/crates/client-application/src/lib.rs @@ -1,3 +1,6 @@ mod client_app; +mod connection_loop; +pub mod conversions; pub use client_app::{ClientApp, RepaintCommand}; +pub use connection_loop::run_connection_loop; diff --git a/crates/client-application/tests/conversion_tests.rs b/crates/client-application/tests/conversion_tests.rs new file mode 100644 index 0000000..273ec5f --- /dev/null +++ b/crates/client-application/tests/conversion_tests.rs @@ -0,0 +1,151 @@ +use client_application::conversions::{ + wire_to_display_hint, wire_to_layout, wire_to_value, wire_to_widget_state, +}; +use domain::{ + AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, JustifyContent, + LayoutChild, LayoutNode, Sizing, Value, WidgetError, WidgetState, +}; +use protocol::{ + WireContainerNode, WireDirection, WireDisplayHint, WireKeyValue, WireLayoutChild, + WireLayoutNode, WireSizing, WireValue, WireWidgetError, WireWidgetState, +}; +use std::collections::BTreeMap; + +fn value_to_wire(v: &Value) -> WireValue { + match v { + Value::Null => WireValue::Null, + Value::Bool(b) => WireValue::Bool(*b), + Value::Number(n) => WireValue::Number(*n), + Value::String(s) => WireValue::String(s.clone()), + Value::Array(arr) => WireValue::Array(arr.iter().map(value_to_wire).collect()), + Value::Object(map) => WireValue::Object( + map.iter() + .map(|(k, v)| (k.clone(), value_to_wire(v))) + .collect(), + ), + } +} + +#[test] +fn value_converts_to_wire_and_back() { + let original = Value::Object(BTreeMap::from([( + "items".into(), + Value::Array(vec![ + Value::String("hello".into()), + Value::Number(42.0), + Value::Bool(true), + Value::Null, + ]), + )])); + + let wire = value_to_wire(&original); + let roundtripped = wire_to_value(wire); + assert_eq!(original, roundtripped); +} + +#[test] +fn widget_state_with_error_converts_to_wire_and_back() { + let original = WidgetState { + data: BTreeMap::from([("temp".into(), Value::Number(5.4))]), + error: Some(WidgetError::SourceUnavailable), + }; + + let wire = WireWidgetState { + data: original + .data + .iter() + .map(|(k, v)| WireKeyValue { + key: k.clone(), + value: value_to_wire(v), + }) + .collect(), + error: Some(WireWidgetError::SourceUnavailable), + }; + let roundtripped = wire_to_widget_state(wire); + assert_eq!(original, roundtripped); +} + +#[test] +fn layout_tree_converts_to_wire_and_back() { + let original = LayoutNode::Container(ContainerNode { + direction: Direction::Row, + gap: 4, + padding: 2, + justify_content: JustifyContent::Start, + align_items: AlignItems::Stretch, + children: vec![ + LayoutChild { + sizing: Sizing::Flex(1), + node: LayoutNode::Leaf(1), + }, + LayoutChild { + sizing: Sizing::Fixed(100), + node: LayoutNode::Container(ContainerNode { + 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), + }], + }), + }, + ], + }); + + let wire = WireLayoutNode::Container(WireContainerNode { + direction: WireDirection::Row, + gap: 4, + padding: 2, + justify_content: protocol::WireJustifyContent::Start, + align_items: protocol::WireAlignItems::Stretch, + children: vec![ + WireLayoutChild { + sizing: WireSizing::Flex(1), + node: WireLayoutNode::Leaf(1), + }, + WireLayoutChild { + sizing: WireSizing::Fixed(100), + node: WireLayoutNode::Container(WireContainerNode { + direction: WireDirection::Column, + gap: 2, + padding: 0, + justify_content: protocol::WireJustifyContent::Start, + align_items: protocol::WireAlignItems::Stretch, + children: vec![WireLayoutChild { + sizing: WireSizing::Flex(1), + node: WireLayoutNode::Leaf(2), + }], + }), + }, + ], + }); + + let roundtripped = wire_to_layout(wire); + assert_eq!(original, roundtripped); +} + +#[test] +fn display_hint_converts_to_wire_and_back() { + for (hint, wire_kind) in [ + ( + DisplayHintKind::IconValue, + protocol::WireDisplayHintKind::IconValue, + ), + ( + DisplayHintKind::TextBlock, + protocol::WireDisplayHintKind::TextBlock, + ), + ( + DisplayHintKind::KeyValue, + protocol::WireDisplayHintKind::KeyValue, + ), + ] { + let original = DisplayHint::new(hint); + let wire = WireDisplayHint::new(wire_kind); + let roundtripped = wire_to_display_hint(wire); + assert_eq!(original, roundtripped); + } +} diff --git a/crates/client-desktop/src/main.rs b/crates/client-desktop/src/main.rs index c7d2890..aa1d1cd 100644 --- a/crates/client-desktop/src/main.rs +++ b/crates/client-desktop/src/main.rs @@ -1,12 +1,13 @@ -use client_application::ClientApp; -use client_domain::NetworkPort; -use client_domain::{BoundingBox, DisplayPort, FontMetrics, RenderEngine, ThemeConfig}; +use client_application::{ClientApp, conversions, run_connection_loop}; +use client_domain::{ + BoundingBox, DisplayPort, FontMetrics, RenderEngine, RepaintRequest, ThemeConfig, + WidgetRenderer, +}; use display_terminal::TerminalDisplay; -use domain::{DisplayHint, WidgetError}; -use protocol::decode_server_message; +use protocol::ServerMessage; use std::sync::mpsc; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; use tcp_client::StdTcpClient; fn main() { @@ -18,52 +19,33 @@ fn main() { large: (10, 20), }; let mut engine = RenderEngine::new(metrics, ThemeConfig::default()); + let mut renderer = WidgetRenderer::new(); println!("=== K-Frame Desktop Client ==="); println!("Screen: {}x{}", screen.width, screen.height); - let (tx, rx) = mpsc::channel(); + let (tx, rx) = mpsc::channel::(); thread::spawn(move || { - let server_addr = "127.0.0.1:2699"; let mut net = StdTcpClient::new(); - - loop { - if !net.is_connected() { - println!("[NET] Connecting to {server_addr}..."); - match net.connect(server_addr) { - Ok(()) => println!("[NET] Connected!"), - Err(e) => { - println!("[NET] Connection failed: {e}, retrying in 2s..."); - thread::sleep(Duration::from_secs(2)); - continue; - } - } - } - - match net.receive() { - Ok(Some(payload)) => match decode_server_message(&payload) { - Ok(msg) => { - let _ = tx.send(msg); - } - Err(e) => println!("[NET] Decode error: {e}"), - }, - Ok(None) => { - thread::sleep(Duration::from_millis(50)); - } - Err(e) => { - println!("[NET] Receive error: {e}, reconnecting..."); - let _ = net.disconnect(); - thread::sleep(Duration::from_secs(2)); - } - } - } + let tx_clone = tx.clone(); + run_connection_loop( + &mut net, + "127.0.0.1:2699", + Duration::from_millis(50), + Duration::from_secs(2), + move |msg| { + let _ = tx_clone.send(msg); + }, + |_connected| {}, + ); }); println!("[RENDER] Render loop started"); + let mut last_tick = Instant::now(); loop { - match rx.recv_timeout(Duration::from_millis(100)) { + match rx.recv_timeout(Duration::from_millis(50)) { Ok(msg) => { let repaints = app.handle_message(msg); @@ -73,23 +55,36 @@ fn main() { if !repaints.is_empty() { println!("\n--- Repaint ({} widgets) ---", repaints.len()); + + let requests: Vec = repaints + .iter() + .map(|cmd| RepaintRequest { + widget_id: cmd.widget_id, + bounds: cmd.bounds, + display_hint: conversions::wire_to_display_hint( + cmd.display_hint.clone(), + ), + data: cmd + .state + .data + .iter() + .map(|kv| { + (kv.key.clone(), conversions::wire_to_value(kv.value.clone())) + }) + .collect(), + error: cmd + .state + .error + .as_ref() + .map(|e| conversions::wire_to_widget_error(e.clone())), + }) + .collect(); + let bg = engine.theme().background; - for cmd in &repaints { - display.fill_rect(cmd.bounds, bg).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 error: Option = - cmd.state.error.as_ref().map(|e| e.clone().into()); - let draw_cmds = - engine.render_widget(&hint, &data, cmd.bounds, 0, error.as_ref()); - for dc in &draw_cmds { + let updates = renderer.apply_repaints(&engine, requests); + for update in &updates { + display.fill_rect(update.bounds, bg).unwrap(); + for dc in &update.commands { display .draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font) .unwrap(); @@ -101,5 +96,23 @@ fn main() { Err(mpsc::RecvTimeoutError::Timeout) => {} Err(mpsc::RecvTimeoutError::Disconnected) => break, } + + let now = Instant::now(); + let elapsed = now.duration_since(last_tick); + last_tick = now; + + let scroll_updates = renderer.tick_scroll(&engine, elapsed); + if !scroll_updates.is_empty() { + let bg = engine.theme().background; + for update in &scroll_updates { + display.fill_rect(update.bounds, bg).unwrap(); + for dc in &update.commands { + display + .draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font) + .unwrap(); + } + } + display.flush().unwrap(); + } } } diff --git a/crates/client-domain/src/lib.rs b/crates/client-domain/src/lib.rs index e314594..15948da 100644 --- a/crates/client-domain/src/lib.rs +++ b/crates/client-domain/src/lib.rs @@ -10,6 +10,7 @@ mod render_tree; mod scroll; mod text_layout; mod theme; +mod widget_renderer; pub use alignment::align_offset; pub use bounding_box::BoundingBox; @@ -24,3 +25,4 @@ pub use render_tree::RenderTree; pub use scroll::ScrollState; pub use text_layout::wrap_lines; pub use theme::ThemeConfig; +pub use widget_renderer::{RenderUpdate, RepaintRequest, WidgetRenderer}; diff --git a/crates/client-domain/src/widget_renderer.rs b/crates/client-domain/src/widget_renderer.rs new file mode 100644 index 0000000..daa908b --- /dev/null +++ b/crates/client-domain/src/widget_renderer.rs @@ -0,0 +1,113 @@ +use crate::{BoundingBox, DrawCommand, RenderEngine, ScrollState}; +use domain::{DisplayHint, Value, WidgetError}; +use std::collections::HashMap; +use std::time::Duration; + +pub struct RenderUpdate { + pub bounds: BoundingBox, + pub commands: Vec, +} + +struct WidgetCache { + hint: DisplayHint, + data: Vec<(String, Value)>, + error: Option, + bounds: BoundingBox, + scroll: ScrollState, +} + +pub struct RepaintRequest { + pub widget_id: u16, + pub bounds: BoundingBox, + pub display_hint: DisplayHint, + pub data: Vec<(String, Value)>, + pub error: Option, +} + +pub struct WidgetRenderer { + widgets: HashMap, +} + +impl Default for WidgetRenderer { + fn default() -> Self { + Self::new() + } +} + +impl WidgetRenderer { + pub fn new() -> Self { + Self { + widgets: HashMap::new(), + } + } + + pub fn apply_repaints( + &mut self, + engine: &RenderEngine, + repaints: Vec, + ) -> Vec { + let mut updates = Vec::new(); + for req in repaints { + let content_h = engine.content_height( + &req.display_hint, + &req.data, + req.bounds.width, + req.error.as_ref(), + ); + let scroll = ScrollState::new(req.bounds.height, content_h); + + let cmds = engine.render_widget( + &req.display_hint, + &req.data, + req.bounds, + scroll.offset(), + req.error.as_ref(), + ); + + updates.push(RenderUpdate { + bounds: req.bounds, + commands: cmds, + }); + + self.widgets.insert( + req.widget_id, + WidgetCache { + hint: req.display_hint, + data: req.data, + error: req.error, + bounds: req.bounds, + scroll, + }, + ); + } + updates + } + + pub fn tick_scroll(&mut self, engine: &RenderEngine, elapsed: Duration) -> Vec { + let mut updates = Vec::new(); + for cache in self.widgets.values_mut() { + if cache.scroll.tick(elapsed) { + let cmds = engine.render_widget( + &cache.hint, + &cache.data, + cache.bounds, + cache.scroll.offset(), + cache.error.as_ref(), + ); + updates.push(RenderUpdate { + bounds: cache.bounds, + commands: cmds, + }); + } + } + updates + } + + pub fn has_active_scrollers(&self) -> bool { + self.widgets.values().any(|c| c.scroll.is_active()) + } + + pub fn clear(&mut self) { + self.widgets.clear(); + } +} diff --git a/crates/client-esp32/Cargo.lock b/crates/client-esp32/Cargo.lock index 05bd673..825b476 100644 --- a/crates/client-esp32/Cargo.lock +++ b/crates/client-esp32/Cargo.lock @@ -238,17 +238,13 @@ dependencies = [ "client-domain", "domain", "embedded-graphics", - "embedded-hal-bus", - "embedded-text", "embuild", "esp-idf-hal", "esp-idf-svc", "esp-idf-sys", "log", "mipidsi", - "postcard", "protocol", - "serde", ] [[package]] @@ -489,16 +485,6 @@ dependencies = [ "embedded-hal 1.0.0", ] -[[package]] -name = "embedded-hal-bus" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513e0b3a8fb7d3013a8ae17a834283f170deaf7d0eeab0a7c1a36ad4dd356d22" -dependencies = [ - "critical-section", - "embedded-hal 1.0.0", -] - [[package]] name = "embedded-hal-nb" version = "1.0.0" @@ -561,17 +547,6 @@ dependencies = [ "strum 0.27.2", ] -[[package]] -name = "embedded-text" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cf5c72c52db2f7dbe4a9c1ed81cd21301e8d66311b194fa41c04fb4f71843ba" -dependencies = [ - "az", - "embedded-graphics", - "object-chain", -] - [[package]] name = "embuild" version = "0.33.1" @@ -1126,12 +1101,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "object-chain" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41af26158b0f5530f7b79955006c2727cd23d0d8e7c3109dc316db0a919784dd" - [[package]] name = "once_cell" version = "1.21.4" diff --git a/crates/client-esp32/src/tasks/network.rs b/crates/client-esp32/src/tasks/network.rs index 438cfa4..571d990 100644 --- a/crates/client-esp32/src/tasks/network.rs +++ b/crates/client-esp32/src/tasks/network.rs @@ -1,56 +1,26 @@ use std::sync::mpsc; use std::thread; -use client_domain::NetworkPort; -use protocol::decode_server_message; +use client_application::run_connection_loop; use super::RenderEvent; use crate::config::{NET_THREAD_STACK_SIZE, NET_POLL_INTERVAL, NET_RECONNECT_DELAY}; use crate::adapters::network::Esp32Network; -use log::*; pub fn spawn(server_addr: String, tx: mpsc::Sender) { thread::Builder::new() .stack_size(NET_THREAD_STACK_SIZE) .name("net".into()) - .spawn(move || run(server_addr, tx)) + .spawn(move || { + let mut net = Esp32Network::new(); + let tx_msg = tx.clone(); + let tx_status = tx.clone(); + run_connection_loop( + &mut net, + &server_addr, + NET_POLL_INTERVAL, + NET_RECONNECT_DELAY, + move |msg| { let _ = tx_msg.send(RenderEvent::Server(msg)); }, + move |connected| { let _ = tx_status.send(RenderEvent::ConnectionStatus(connected)); }, + ); + }) .expect("failed to spawn network thread"); } - -fn run(server_addr: String, tx: mpsc::Sender) { - let mut net = Esp32Network::new(); - - loop { - if !net.is_connected() { - info!("Connecting to server {server_addr}..."); - match net.connect(&server_addr) { - Ok(()) => { - info!("Server connected"); - let _ = tx.send(RenderEvent::ConnectionStatus(true)); - } - Err(e) => { - error!("Connection failed: {e}, retrying..."); - let _ = tx.send(RenderEvent::ConnectionStatus(false)); - thread::sleep(NET_RECONNECT_DELAY); - continue; - } - } - } - - match net.receive() { - Ok(Some(payload)) => { - match decode_server_message(&payload) { - Ok(msg) => { let _ = tx.send(RenderEvent::Server(msg)); } - Err(e) => error!("Decode error: {e}"), - } - } - Ok(None) => { - thread::sleep(NET_POLL_INTERVAL); - } - Err(e) => { - error!("Receive error: {e}, reconnecting..."); - let _ = net.disconnect(); - let _ = tx.send(RenderEvent::ConnectionStatus(false)); - thread::sleep(NET_RECONNECT_DELAY); - } - } - } -} diff --git a/crates/client-esp32/src/tasks/render.rs b/crates/client-esp32/src/tasks/render.rs index f9c0adc..8944f06 100644 --- a/crates/client-esp32/src/tasks/render.rs +++ b/crates/client-esp32/src/tasks/render.rs @@ -1,11 +1,10 @@ use std::sync::mpsc; use std::time::{Duration, Instant}; -use std::collections::HashMap; use client_domain::{ - BoundingBox, Color, DisplayPort, FontMetrics, RenderEngine, ScrollState, ThemeConfig, + BoundingBox, Color, DisplayPort, FontMetrics, RenderEngine, RepaintRequest, ThemeConfig, + WidgetRenderer, }; -use client_application::{ClientApp, RepaintCommand}; -use domain::{DisplayHint, Value, WidgetError}; +use client_application::{ClientApp, RepaintCommand, conversions}; use protocol::ServerMessage; use super::RenderEvent; use crate::config::RENDER_POLL_INTERVAL; @@ -18,12 +17,17 @@ const INDICATOR_MARGIN: u16 = 4; const COLOR_CONNECTED: Color = Color(0, 200, 0); const COLOR_DISCONNECTED: Color = Color(200, 0, 0); -struct WidgetCache { - hint: DisplayHint, - data: Vec<(String, Value)>, - error: Option, - bounds: BoundingBox, - scroll: ScrollState, +fn to_repaint_requests(repaints: &[RepaintCommand]) -> Vec { + repaints + .iter() + .map(|cmd| RepaintRequest { + widget_id: cmd.widget_id, + bounds: cmd.bounds, + display_hint: conversions::wire_to_display_hint(cmd.display_hint.clone()), + data: cmd.state.data.iter().map(|kv| (kv.key.clone(), conversions::wire_to_value(kv.value.clone()))).collect(), + error: cmd.state.error.as_ref().map(|e| conversions::wire_to_widget_error(e.clone())), + }) + .collect() } pub fn run( @@ -37,7 +41,7 @@ pub fn run( }; let mut engine = RenderEngine::new(metrics, ThemeConfig::default()); let mut app = ClientApp::new(screen); - let mut widgets: HashMap = HashMap::new(); + let mut renderer = WidgetRenderer::new(); let mut first_update = true; let mut last_tick = Instant::now(); let mut connected = false; @@ -47,7 +51,7 @@ pub fn run( display.flush().unwrap(); loop { - let has_scrollers = widgets.values().any(|c| c.scroll.is_active()); + let has_scrollers = renderer.has_active_scrollers(); let timeout = if has_scrollers { SCROLL_TICK } else { RENDER_POLL_INTERVAL }; match rx.recv_timeout(timeout) { Ok(RenderEvent::ConnectionStatus(status)) => { @@ -71,14 +75,17 @@ pub fn run( display.fill_rect(screen, bg).unwrap(); first_update = false; } - for cmd in &repaints { - let cache = update_cache(&engine, cmd); - display.fill_rect(cache.bounds, bg).unwrap(); - draw_widget(&engine, &mut display, &cache); - widgets.insert(cmd.widget_id, cache); + + let requests = to_repaint_requests(&repaints); + let updates = renderer.apply_repaints(&engine, requests); + for update in &updates { + display.fill_rect(update.bounds, bg).unwrap(); + for dc in &update.commands { + display.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font).unwrap(); + } } - if !repaints.is_empty() { + if !updates.is_empty() { draw_indicator(&mut display, screen, connected); display.flush().unwrap(); } @@ -94,16 +101,15 @@ pub fn run( 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; + let scroll_updates = renderer.tick_scroll(&engine, elapsed); + if !scroll_updates.is_empty() { + let bg = engine.theme().background; + for update in &scroll_updates { + display.fill_rect(update.bounds, bg).unwrap(); + for dc in &update.commands { + display.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font).unwrap(); + } } - } - if needs_flush { draw_indicator(&mut display, screen, connected); display.flush().unwrap(); } @@ -116,41 +122,3 @@ fn draw_indicator(display: &mut Esp32DisplayAdapter, screen: BoundingBox, connec let y = screen.y + screen.height - INDICATOR_DIAMETER - INDICATOR_MARGIN; display.fill_circle(x, y, INDICATOR_DIAMETER, color).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 error: Option = cmd.state.error.as_ref().map(|e| e.clone().into()); - - let content_h = engine.content_height(&hint, &data, cmd.bounds.width, error.as_ref()); - let scroll = ScrollState::new(cmd.bounds.height, content_h); - - WidgetCache { - hint, - data, - error, - 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(), - cache.error.as_ref(), - ); - - for dc in &draw_cmds { - display.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font).unwrap(); - } -} diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index 907f941..502be55 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -3,6 +3,10 @@ name = "domain" version = "0.1.0" edition = "2024" +[features] +json = ["serde_json"] + [dependencies] +serde_json = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 18a4224..47bc3ab 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -12,7 +12,8 @@ pub use entities::{ pub use events::DomainEvent; pub use ports::{ AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, ConnectedClient, DataSourcePort, - EventPublisher, PasswordHashPort, SecretStore, WidgetStateReader, + EventPublisher, PasswordHashPort, SecretStore, UserRepository, WidgetStateCache, + WidgetStateReader, }; pub use value_objects::{ AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent, diff --git a/crates/domain/src/ports/config_repository.rs b/crates/domain/src/ports/config_repository.rs index dfb3a7a..c412c05 100644 --- a/crates/domain/src/ports/config_repository.rs +++ b/crates/domain/src/ports/config_repository.rs @@ -1,7 +1,7 @@ use crate::entities::{ - DataSource, DataSourceId, LayoutPreset, LayoutPresetId, User, WidgetConfig, WidgetId, + DataSource, DataSourceId, LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId, }; -use crate::value_objects::{Layout, ThemeConfig, WidgetState}; +use crate::value_objects::{Layout, ThemeConfig}; use std::future::Future; pub trait ConfigRepository { @@ -56,19 +56,4 @@ pub trait ConfigRepository { &self, theme: &ThemeConfig, ) -> impl Future> + Send; - - fn get_user_by_username( - &self, - username: &str, - ) -> impl Future, Self::Error>> + Send; - fn save_user(&self, user: &User) -> impl Future> + Send; - fn count_users(&self) -> impl Future> + Send; - - fn save_widget_states( - &self, - states: &[(WidgetId, WidgetState)], - ) -> impl Future> + Send; - fn load_widget_states( - &self, - ) -> impl Future, Self::Error>> + Send; } diff --git a/crates/domain/src/ports/mod.rs b/crates/domain/src/ports/mod.rs index 20e7049..afbe20a 100644 --- a/crates/domain/src/ports/mod.rs +++ b/crates/domain/src/ports/mod.rs @@ -5,6 +5,8 @@ mod config_repository; mod data_source_port; mod event; mod secret_store; +mod user_repository; +mod widget_state_cache; mod widget_state_reader; pub use auth::{AuthPort, PasswordHashPort}; @@ -14,4 +16,6 @@ pub use config_repository::ConfigRepository; pub use data_source_port::DataSourcePort; pub use event::EventPublisher; pub use secret_store::SecretStore; +pub use user_repository::UserRepository; +pub use widget_state_cache::WidgetStateCache; pub use widget_state_reader::WidgetStateReader; diff --git a/crates/domain/src/ports/user_repository.rs b/crates/domain/src/ports/user_repository.rs new file mode 100644 index 0000000..5437614 --- /dev/null +++ b/crates/domain/src/ports/user_repository.rs @@ -0,0 +1,13 @@ +use crate::entities::User; +use std::future::Future; + +pub trait UserRepository { + type Error; + + fn get_user_by_username( + &self, + username: &str, + ) -> impl Future, Self::Error>> + Send; + fn save_user(&self, user: &User) -> impl Future> + Send; + fn count_users(&self) -> impl Future> + Send; +} diff --git a/crates/domain/src/ports/widget_state_cache.rs b/crates/domain/src/ports/widget_state_cache.rs new file mode 100644 index 0000000..8832b59 --- /dev/null +++ b/crates/domain/src/ports/widget_state_cache.rs @@ -0,0 +1,15 @@ +use crate::entities::WidgetId; +use crate::value_objects::WidgetState; +use std::future::Future; + +pub trait WidgetStateCache { + type Error; + + fn save_widget_states( + &self, + states: &[(WidgetId, WidgetState)], + ) -> impl Future> + Send; + fn load_widget_states( + &self, + ) -> impl Future, Self::Error>> + Send; +} diff --git a/crates/domain/src/value_objects/value.rs b/crates/domain/src/value_objects/value.rs index f6eac90..e056c0c 100644 --- a/crates/domain/src/value_objects/value.rs +++ b/crates/domain/src/value_objects/value.rs @@ -1,5 +1,39 @@ use std::collections::BTreeMap; +#[cfg(feature = "json")] +impl From for Value { + fn from(json: serde_json::Value) -> Self { + match json { + serde_json::Value::Null => Value::Null, + serde_json::Value::Bool(b) => Value::Bool(b), + serde_json::Value::Number(n) => Value::Number(n.as_f64().unwrap_or(0.0)), + serde_json::Value::String(s) => Value::String(s), + serde_json::Value::Array(arr) => { + Value::Array(arr.into_iter().map(Into::into).collect()) + } + serde_json::Value::Object(map) => { + Value::Object(map.into_iter().map(|(k, v)| (k, v.into())).collect()) + } + } + } +} + +#[cfg(feature = "json")] +impl From<&Value> for serde_json::Value { + fn from(v: &Value) -> Self { + match v { + Value::Null => serde_json::Value::Null, + Value::Bool(b) => serde_json::Value::Bool(*b), + Value::Number(n) => serde_json::json!(*n), + Value::String(s) => serde_json::Value::String(s.clone()), + Value::Array(arr) => serde_json::Value::Array(arr.iter().map(Into::into).collect()), + Value::Object(map) => { + serde_json::Value::Object(map.iter().map(|(k, v)| (k.clone(), v.into())).collect()) + } + } + } +} + #[derive(Debug, Clone, PartialEq)] pub enum Value { Null, diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml index 3733435..ee82fef 100644 --- a/crates/protocol/Cargo.toml +++ b/crates/protocol/Cargo.toml @@ -4,7 +4,6 @@ version = "0.1.0" edition = "2024" [dependencies] -domain.workspace = true serde.workspace = true postcard.workspace = true diff --git a/crates/protocol/src/wire.rs b/crates/protocol/src/wire.rs index 6426465..32d4fac 100644 --- a/crates/protocol/src/wire.rs +++ b/crates/protocol/src/wire.rs @@ -1,7 +1,3 @@ -use domain::value_objects::{ - AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent, - LayoutChild, LayoutNode, Sizing, VAlign, Value, WidgetError, WidgetState, -}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -15,60 +11,12 @@ pub enum WireValue { Object(BTreeMap), } -impl From<&Value> for WireValue { - fn from(v: &Value) -> Self { - match v { - Value::Null => WireValue::Null, - Value::Bool(b) => WireValue::Bool(*b), - Value::Number(n) => WireValue::Number(*n), - Value::String(s) => WireValue::String(s.clone()), - Value::Array(arr) => WireValue::Array(arr.iter().map(Into::into).collect()), - Value::Object(map) => { - WireValue::Object(map.iter().map(|(k, v)| (k.clone(), v.into())).collect()) - } - } - } -} - -impl From for Value { - fn from(w: WireValue) -> Self { - match w { - WireValue::Null => Value::Null, - WireValue::Bool(b) => Value::Bool(b), - WireValue::Number(n) => Value::Number(n), - WireValue::String(s) => Value::String(s), - WireValue::Array(arr) => Value::Array(arr.into_iter().map(Into::into).collect()), - WireValue::Object(map) => { - Value::Object(map.into_iter().map(|(k, v)| (k, v.into())).collect()) - } - } - } -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum WireWidgetError { SourceUnavailable, ExtractionFailed, } -impl From<&WidgetError> for WireWidgetError { - fn from(e: &WidgetError) -> Self { - match e { - WidgetError::SourceUnavailable => WireWidgetError::SourceUnavailable, - WidgetError::ExtractionFailed => WireWidgetError::ExtractionFailed, - } - } -} - -impl From for WidgetError { - fn from(w: WireWidgetError) -> Self { - match w { - WireWidgetError::SourceUnavailable => WidgetError::SourceUnavailable, - WireWidgetError::ExtractionFailed => WidgetError::ExtractionFailed, - } - } -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct WireKeyValue { pub key: String, @@ -81,35 +29,6 @@ pub struct WireWidgetState { pub error: Option, } -impl From<&WidgetState> for WireWidgetState { - fn from(s: &WidgetState) -> Self { - WireWidgetState { - data: s - .data - .iter() - .map(|(k, v)| WireKeyValue { - key: k.clone(), - value: v.into(), - }) - .collect(), - error: s.error.as_ref().map(Into::into), - } - } -} - -impl From for WidgetState { - fn from(w: WireWidgetState) -> Self { - WidgetState { - data: w - .data - .into_iter() - .map(|kv| (kv.key, kv.value.into())) - .collect(), - error: w.error.map(Into::into), - } - } -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum WireDisplayHintKind { IconValue, @@ -148,134 +67,18 @@ impl WireDisplayHint { } } -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 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 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 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 { - WireDisplayHint { - kind: (&h.kind).into(), - h_align: (&h.h_align).into(), - v_align: (&h.v_align).into(), - } - } -} - -impl From for DisplayHint { - fn from(w: WireDisplayHint) -> Self { - DisplayHint { - kind: w.kind.into(), - h_align: w.h_align.into(), - v_align: w.v_align.into(), - } - } -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum WireSizing { Fixed(u16), Flex(u8), } -impl From<&Sizing> for WireSizing { - fn from(s: &Sizing) -> Self { - match s { - Sizing::Fixed(px) => WireSizing::Fixed(*px), - Sizing::Flex(w) => WireSizing::Flex(*w), - } - } -} - -impl From for Sizing { - fn from(w: WireSizing) -> Self { - match w { - WireSizing::Fixed(px) => Sizing::Fixed(px), - WireSizing::Flex(weight) => Sizing::Flex(weight), - } - } -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum WireDirection { Row, Column, } -impl From<&Direction> for WireDirection { - fn from(d: &Direction) -> Self { - match d { - Direction::Row => WireDirection::Row, - Direction::Column => WireDirection::Column, - } - } -} - -impl From for Direction { - fn from(w: WireDirection) -> Self { - match w { - WireDirection::Row => Direction::Row, - WireDirection::Column => Direction::Column, - } - } -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum WireJustifyContent { Start, @@ -285,30 +88,6 @@ pub enum WireJustifyContent { 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 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, @@ -317,28 +96,6 @@ pub enum WireAlignItems { 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 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, @@ -360,49 +117,3 @@ pub enum WireLayoutNode { Container(WireContainerNode), Leaf(u16), } - -impl From<&LayoutNode> for WireLayoutNode { - fn from(n: &LayoutNode) -> Self { - match n { - LayoutNode::Leaf(id) => WireLayoutNode::Leaf(*id), - LayoutNode::Container(c) => WireLayoutNode::Container(WireContainerNode { - 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() - .map(|ch| WireLayoutChild { - sizing: (&ch.sizing).into(), - node: (&ch.node).into(), - }) - .collect(), - }), - } - } -} - -impl From for LayoutNode { - fn from(w: WireLayoutNode) -> Self { - match w { - WireLayoutNode::Leaf(id) => LayoutNode::Leaf(id), - WireLayoutNode::Container(c) => LayoutNode::Container(ContainerNode { - 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() - .map(|ch| LayoutChild { - sizing: ch.sizing.into(), - node: ch.node.into(), - }) - .collect(), - }), - } - } -} diff --git a/crates/protocol/tests/conversion_tests.rs b/crates/protocol/tests/conversion_tests.rs deleted file mode 100644 index 15e97ba..0000000 --- a/crates/protocol/tests/conversion_tests.rs +++ /dev/null @@ -1,86 +0,0 @@ -use domain::{ - AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, JustifyContent, - LayoutChild, LayoutNode, Sizing, Value, WidgetError, WidgetState, -}; -use protocol::{ - WireContainerNode, WireDirection, WireDisplayHint, WireKeyValue, WireLayoutChild, - WireLayoutNode, WireSizing, WireValue, WireWidgetError, WireWidgetState, -}; -use std::collections::BTreeMap; - -#[test] -fn value_converts_to_wire_and_back() { - let original = Value::Object(BTreeMap::from([( - "items".into(), - Value::Array(vec![ - Value::String("hello".into()), - Value::Number(42.0), - Value::Bool(true), - Value::Null, - ]), - )])); - - let wire: WireValue = (&original).into(); - let roundtripped: Value = wire.into(); - assert_eq!(original, roundtripped); -} - -#[test] -fn widget_state_with_error_converts_to_wire_and_back() { - let original = WidgetState { - data: BTreeMap::from([("temp".into(), Value::Number(5.4))]), - error: Some(WidgetError::SourceUnavailable), - }; - - let wire: WireWidgetState = (&original).into(); - let roundtripped: WidgetState = wire.into(); - assert_eq!(original, roundtripped); -} - -#[test] -fn layout_tree_converts_to_wire_and_back() { - let original = LayoutNode::Container(ContainerNode { - direction: Direction::Row, - gap: 4, - padding: 2, - justify_content: JustifyContent::Start, - align_items: AlignItems::Stretch, - children: vec![ - LayoutChild { - sizing: Sizing::Flex(1), - node: LayoutNode::Leaf(1), - }, - LayoutChild { - sizing: Sizing::Fixed(100), - node: LayoutNode::Container(ContainerNode { - 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), - }], - }), - }, - ], - }); - - let wire: WireLayoutNode = (&original).into(); - let roundtripped: LayoutNode = wire.into(); - assert_eq!(original, roundtripped); -} - -#[test] -fn display_hint_converts_to_wire_and_back() { - for hint in [ - DisplayHint::new(DisplayHintKind::IconValue), - DisplayHint::new(DisplayHintKind::TextBlock), - DisplayHint::new(DisplayHintKind::KeyValue), - ] { - let wire: WireDisplayHint = (&hint).into(); - let roundtripped: DisplayHint = wire.into(); - assert_eq!(hint, roundtripped); - } -}