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:
2026-06-19 18:12:50 +02:00
parent 1c854d127f
commit 7001b5e911
46 changed files with 1063 additions and 951 deletions

6
Cargo.lock generated
View File

@@ -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",
]

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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 })
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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())
}
}

View File

@@ -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();

View 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(),
}),
}
}

View File

@@ -1,5 +1,6 @@
mod broadcaster;
mod client_tracker;
mod conversions;
mod error;
mod event_bus;
mod server;

View File

@@ -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),
});
}
}

View File

@@ -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 }

View File

@@ -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)?;

View File

@@ -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;

View 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");
}
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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(())
}

View File

@@ -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();

View 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);
}
}
}
}

View 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(),
}),
}
}

View File

@@ -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;

View 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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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};

View 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();
}
}

View File

@@ -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"

View File

@@ -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);
}
}
}
}

View File

@@ -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();
}
}

View File

@@ -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]

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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;

View 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;
}

View 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;
}

View File

@@ -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,

View File

@@ -4,7 +4,6 @@ version = "0.1.0"
edition = "2024"
[dependencies]
domain.workspace = true
serde.workspace = true
postcard.workspace = true

View File

@@ -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(),
}),
}
}
}

View File

@@ -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);
}
}