arch: split ConfigRepository, extract polling, consolidate conversions, decouple protocol
- Value↔JSON: From impls on domain Value behind `json` feature, delete 4 duplicate converters - ConfigRepository split into ConfigRepository (12), UserRepository (3), WidgetStateCache (2) - polling orchestration moved from bootstrap to application::polling_service - WidgetRenderer in client-domain owns scroll/cache, both clients use it - network loop consolidated into client-application::run_connection_loop - protocol crate drops domain dep, Wire↔Domain conversions move to adapters
This commit is contained in:
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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<Option<User>, 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Option<User>, Self::Error> {
|
||||
self.get_user_by_username_impl(username).await
|
||||
@@ -91,6 +95,10 @@ impl ConfigRepository for SqliteConfigStore {
|
||||
async fn count_users(&self) -> Result<u32, Self::Error> {
|
||||
self.count_users_impl().await
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetStateCache for SqliteConfigStore {
|
||||
type Error = SqliteConfigError;
|
||||
|
||||
async fn save_widget_states(
|
||||
&self,
|
||||
|
||||
@@ -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<String, serde_json::Value> = 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<String, serde_json::Error> {
|
||||
let data: serde_json::Map<String, serde_json::Value> = 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<WidgetState, serde_json::Error> {
|
||||
let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(json)?;
|
||||
let data: BTreeMap<String, Value> = map
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), json_value_to_domain(v)))
|
||||
.collect();
|
||||
let data: BTreeMap<String, Value> = map.into_iter().map(|(k, v)| (k, v.into())).collect();
|
||||
Ok(WidgetState { data, error: None })
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<C, E, W, B, R, A, H> Clone for AppState<C, E, W, B, R, A, H> {
|
||||
|
||||
pub fn router<C, E, W, B, R, A, H>(state: AppState<C, E, W, B, R, A, H>) -> Router
|
||||
where
|
||||
C: ConfigRepository + Send + Sync + 'static,
|
||||
C::Error: std::fmt::Debug + Send,
|
||||
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
|
||||
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
|
||||
<C as UserRepository>::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<C, E, W, B, R, A, H>(
|
||||
state: AppState<C, E, W, B, R, A, H>,
|
||||
) -> Result<(), std::io::Error>
|
||||
where
|
||||
C: ConfigRepository + Send + Sync + 'static,
|
||||
C::Error: std::fmt::Debug + Send,
|
||||
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
|
||||
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
|
||||
<C as UserRepository>::Error: std::fmt::Debug + Send,
|
||||
E: EventPublisher + Send + Sync + 'static,
|
||||
E::Error: std::fmt::Debug + Send,
|
||||
W: WidgetStateReader + Send + Sync + 'static,
|
||||
|
||||
@@ -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<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
||||
@@ -28,7 +28,7 @@ pub async fn login<C, E, W, B, R, A, H>(
|
||||
Json(body): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, (StatusCode, String)>
|
||||
where
|
||||
C: ConfigRepository,
|
||||
C: UserRepository,
|
||||
C::Error: std::fmt::Debug,
|
||||
A: AuthPort,
|
||||
H: PasswordHashPort,
|
||||
@@ -51,7 +51,7 @@ pub async fn register<C, E, W, B, R, A, H>(
|
||||
Json(body): Json<LoginRequest>,
|
||||
) -> Result<StatusCode, (StatusCode, String)>
|
||||
where
|
||||
C: ConfigRepository,
|
||||
C: UserRepository,
|
||||
C::Error: std::fmt::Debug,
|
||||
H: PasswordHashPort,
|
||||
{
|
||||
@@ -71,7 +71,7 @@ pub async fn auth_status<C, E, W, B, R, A, H>(
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
) -> Result<Json<StatusResponse>, StatusCode>
|
||||
where
|
||||
C: ConfigRepository,
|
||||
C: UserRepository,
|
||||
C::Error: std::fmt::Debug,
|
||||
{
|
||||
let count = state
|
||||
|
||||
@@ -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<C, E, W, B, R, A, H>() -> Router<AppState<C, E, W, B, R, A, H>>
|
||||
where
|
||||
C: ConfigRepository + Send + Sync + 'static,
|
||||
C::Error: std::fmt::Debug + Send,
|
||||
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
|
||||
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
|
||||
<C as UserRepository>::Error: std::fmt::Debug + Send,
|
||||
E: EventPublisher + Send + Sync + 'static,
|
||||
E::Error: std::fmt::Debug + Send,
|
||||
W: WidgetStateReader + Send + Sync + 'static,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,32 +126,10 @@ where
|
||||
{
|
||||
match state.widget_states.get_widget_state(id).await {
|
||||
Some(ws) => {
|
||||
let map: serde_json::Map<String, serde_json::Value> = ws
|
||||
.data
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
|
||||
.collect();
|
||||
let map: serde_json::Map<String, serde_json::Value> =
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WidgetDescriptor> = 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();
|
||||
|
||||
|
||||
103
crates/adapters/tcp-server/src/conversions.rs
Normal file
103
crates/adapters/tcp-server/src/conversions.rs
Normal file
@@ -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(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod broadcaster;
|
||||
mod client_tracker;
|
||||
mod conversions;
|
||||
mod error;
|
||||
mod event_bus;
|
||||
mod server;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use domain::{AuthPort, ConfigRepository, PasswordHashPort, User};
|
||||
use domain::{AuthPort, PasswordHashPort, User, UserRepository};
|
||||
|
||||
pub enum AuthError<E> {
|
||||
InvalidCredentials,
|
||||
@@ -26,7 +26,7 @@ pub async fn login<C, A, H>(
|
||||
password: &str,
|
||||
) -> Result<String, AuthError<C::Error>>
|
||||
where
|
||||
C: ConfigRepository,
|
||||
C: UserRepository,
|
||||
A: AuthPort,
|
||||
H: PasswordHashPort,
|
||||
{
|
||||
@@ -55,7 +55,7 @@ pub async fn register<C, H>(
|
||||
password: &str,
|
||||
) -> Result<(), AuthError<C::Error>>
|
||||
where
|
||||
C: ConfigRepository,
|
||||
C: UserRepository,
|
||||
H: PasswordHashPort,
|
||||
{
|
||||
let count = config.count_users().await.map_err(AuthError::Repository)?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
271
crates/application/src/polling_service.rs
Normal file
271
crates/application/src/polling_service.rs
Normal file
@@ -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<C, B, P, F>(
|
||||
config: Arc<C>,
|
||||
broadcaster: Arc<B>,
|
||||
projection: Arc<DataProjection>,
|
||||
poller: Arc<P>,
|
||||
) where
|
||||
C: ConfigRepository + WidgetStateCache + Send + Sync + 'static,
|
||||
<C as ConfigRepository>::Error: std::fmt::Display + Send,
|
||||
<C as WidgetStateCache>::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<Output = Result<Value, anyhow::Error>> + Send,
|
||||
{
|
||||
let mut running: HashMap<u16, JoinHandle<()>> = HashMap::new();
|
||||
let mut static_done: HashSet<u16> = 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<u16> = 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<C, B, P, F>(
|
||||
poller: &P,
|
||||
source: &DataSource,
|
||||
config: &Arc<C>,
|
||||
broadcaster: &Arc<B>,
|
||||
projection: &Arc<DataProjection>,
|
||||
) where
|
||||
C: ConfigRepository + WidgetStateCache,
|
||||
<C as ConfigRepository>::Error: std::fmt::Display,
|
||||
<C as WidgetStateCache>::Error: std::fmt::Display,
|
||||
B: BroadcastPort,
|
||||
B::Error: std::fmt::Display,
|
||||
P: Fn(&DataSource) -> F,
|
||||
F: Future<Output = Result<Value, anyhow::Error>>,
|
||||
{
|
||||
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<C, B, P, F>(
|
||||
source: DataSource,
|
||||
config: Arc<C>,
|
||||
broadcaster: Arc<B>,
|
||||
projection: Arc<DataProjection>,
|
||||
poller: Arc<P>,
|
||||
) where
|
||||
C: ConfigRepository + WidgetStateCache,
|
||||
<C as ConfigRepository>::Error: std::fmt::Display,
|
||||
<C as WidgetStateCache>::Error: std::fmt::Display,
|
||||
B: BroadcastPort,
|
||||
B::Error: std::fmt::Display,
|
||||
P: Fn(&DataSource) -> F,
|
||||
F: Future<Output = Result<Value, anyhow::Error>>,
|
||||
{
|
||||
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<C, B>(
|
||||
source: &DataSource,
|
||||
result: &Value,
|
||||
widgets: &[WidgetConfig],
|
||||
broadcaster: &Arc<B>,
|
||||
projection: &Arc<DataProjection>,
|
||||
config: &Arc<C>,
|
||||
) 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<B>(
|
||||
source: &DataSource,
|
||||
widgets: &[WidgetConfig],
|
||||
broadcaster: &Arc<B>,
|
||||
projection: &Arc<DataProjection>,
|
||||
) 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");
|
||||
}
|
||||
}
|
||||
@@ -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<Option<User>, Self::Error> {
|
||||
Ok(None)
|
||||
@@ -135,6 +140,10 @@ impl ConfigRepository for InMemoryConfigRepository {
|
||||
async fn count_users(&self) -> Result<u32, Self::Error> {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetStateCache for InMemoryConfigRepository {
|
||||
type Error = Never;
|
||||
|
||||
async fn save_widget_states(
|
||||
&self,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<u16, JoinHandle<()>> = HashMap::new();
|
||||
let mut static_done: std::collections::HashSet<u16> = 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<u16> = 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<SqliteConfigStore>,
|
||||
broadcaster: &Arc<TcpBroadcaster>,
|
||||
projection: &Arc<DataProjection>,
|
||||
) {
|
||||
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<SqliteConfigStore>,
|
||||
broadcaster: Arc<TcpBroadcaster>,
|
||||
projection: Arc<DataProjection>,
|
||||
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<TcpBroadcaster>,
|
||||
projection: &Arc<DataProjection>,
|
||||
config: &Arc<SqliteConfigStore>,
|
||||
) {
|
||||
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<TcpBroadcaster>,
|
||||
projection: &Arc<DataProjection>,
|
||||
) {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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<WidgetDescriptor>,
|
||||
) -> Vec<RepaintCommand> {
|
||||
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();
|
||||
|
||||
42
crates/client-application/src/connection_loop.rs
Normal file
42
crates/client-application/src/connection_loop.rs
Normal file
@@ -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<N: NetworkPort>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
100
crates/client-application/src/conversions.rs
Normal file
100
crates/client-application/src/conversions.rs
Normal file
@@ -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(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
151
crates/client-application/tests/conversion_tests.rs
Normal file
151
crates/client-application/tests/conversion_tests.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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::<ServerMessage>();
|
||||
|
||||
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<RepaintRequest> = 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<WidgetError> =
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
113
crates/client-domain/src/widget_renderer.rs
Normal file
113
crates/client-domain/src/widget_renderer.rs
Normal file
@@ -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<DrawCommand>,
|
||||
}
|
||||
|
||||
struct WidgetCache {
|
||||
hint: DisplayHint,
|
||||
data: Vec<(String, Value)>,
|
||||
error: Option<WidgetError>,
|
||||
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<WidgetError>,
|
||||
}
|
||||
|
||||
pub struct WidgetRenderer {
|
||||
widgets: HashMap<u16, WidgetCache>,
|
||||
}
|
||||
|
||||
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<RepaintRequest>,
|
||||
) -> Vec<RenderUpdate> {
|
||||
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<RenderUpdate> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
31
crates/client-esp32/Cargo.lock
generated
31
crates/client-esp32/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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<RenderEvent>) {
|
||||
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<RenderEvent>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WidgetError>,
|
||||
bounds: BoundingBox,
|
||||
scroll: ScrollState,
|
||||
fn to_repaint_requests(repaints: &[RepaintCommand]) -> Vec<RepaintRequest> {
|
||||
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<u16, WidgetCache> = 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<WidgetError> = 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Output = Result<(), Self::Error>> + Send;
|
||||
|
||||
fn get_user_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> impl Future<Output = Result<Option<User>, Self::Error>> + Send;
|
||||
fn save_user(&self, user: &User) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||
fn count_users(&self) -> impl Future<Output = Result<u32, Self::Error>> + Send;
|
||||
|
||||
fn save_widget_states(
|
||||
&self,
|
||||
states: &[(WidgetId, WidgetState)],
|
||||
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||
fn load_widget_states(
|
||||
&self,
|
||||
) -> impl Future<Output = Result<Vec<(WidgetId, WidgetState)>, Self::Error>> + Send;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
13
crates/domain/src/ports/user_repository.rs
Normal file
13
crates/domain/src/ports/user_repository.rs
Normal file
@@ -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<Output = Result<Option<User>, Self::Error>> + Send;
|
||||
fn save_user(&self, user: &User) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||
fn count_users(&self) -> impl Future<Output = Result<u32, Self::Error>> + Send;
|
||||
}
|
||||
15
crates/domain/src/ports/widget_state_cache.rs
Normal file
15
crates/domain/src/ports/widget_state_cache.rs
Normal file
@@ -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<Output = Result<(), Self::Error>> + Send;
|
||||
fn load_widget_states(
|
||||
&self,
|
||||
) -> impl Future<Output = Result<Vec<(WidgetId, WidgetState)>, Self::Error>> + Send;
|
||||
}
|
||||
@@ -1,5 +1,39 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[cfg(feature = "json")]
|
||||
impl From<serde_json::Value> 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,
|
||||
|
||||
@@ -4,7 +4,6 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
serde.workspace = true
|
||||
postcard.workspace = true
|
||||
|
||||
|
||||
@@ -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<String, WireValue>),
|
||||
}
|
||||
|
||||
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<WireValue> 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<WireWidgetError> 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<WireWidgetError>,
|
||||
}
|
||||
|
||||
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<WireWidgetState> 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<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 {
|
||||
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 {
|
||||
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<WireSizing> 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<WireDirection> 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<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,
|
||||
@@ -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<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,
|
||||
@@ -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<WireLayoutNode> 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(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user