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"
|
name = "application"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"domain",
|
"domain",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -526,6 +528,9 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "domain"
|
name = "domain"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dotenvy"
|
name = "dotenvy"
|
||||||
@@ -1687,7 +1692,6 @@ dependencies = [
|
|||||||
name = "protocol"
|
name = "protocol"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"domain",
|
|
||||||
"postcard",
|
"postcard",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use domain::{
|
use domain::{
|
||||||
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
|
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
|
||||||
User, WidgetConfig, WidgetId, WidgetState,
|
User, UserRepository, WidgetConfig, WidgetId, WidgetState, WidgetStateCache,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
@@ -177,6 +177,10 @@ impl ConfigRepository for MemoryConfigStore {
|
|||||||
guard.remove(&id);
|
guard.remove(&id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserRepository for MemoryConfigStore {
|
||||||
|
type Error = MemoryConfigError;
|
||||||
|
|
||||||
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
|
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
|
||||||
let guard = self
|
let guard = self
|
||||||
@@ -203,6 +207,10 @@ impl ConfigRepository for MemoryConfigStore {
|
|||||||
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
Ok(guard.len() as u32)
|
Ok(guard.len() as u32)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WidgetStateCache for MemoryConfigStore {
|
||||||
|
type Error = MemoryConfigError;
|
||||||
|
|
||||||
async fn save_widget_states(
|
async fn save_widget_states(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain.workspace = true
|
domain = { workspace = true, features = ["json"] }
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::SqliteConfigStore;
|
|||||||
use crate::error::SqliteConfigError;
|
use crate::error::SqliteConfigError;
|
||||||
use domain::{
|
use domain::{
|
||||||
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
|
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
|
||||||
User, WidgetConfig, WidgetId, WidgetState,
|
User, UserRepository, WidgetConfig, WidgetId, WidgetState, WidgetStateCache,
|
||||||
};
|
};
|
||||||
|
|
||||||
impl ConfigRepository for SqliteConfigStore {
|
impl ConfigRepository for SqliteConfigStore {
|
||||||
@@ -79,6 +79,10 @@ impl ConfigRepository for SqliteConfigStore {
|
|||||||
async fn save_theme(&self, theme: &ThemeConfig) -> Result<(), Self::Error> {
|
async fn save_theme(&self, theme: &ThemeConfig) -> Result<(), Self::Error> {
|
||||||
self.save_theme_impl(theme).await
|
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> {
|
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
|
||||||
self.get_user_by_username_impl(username).await
|
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> {
|
async fn count_users(&self) -> Result<u32, Self::Error> {
|
||||||
self.count_users_impl().await
|
self.count_users_impl().await
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WidgetStateCache for SqliteConfigStore {
|
||||||
|
type Error = SqliteConfigError;
|
||||||
|
|
||||||
async fn save_widget_states(
|
async fn save_widget_states(
|
||||||
&self,
|
&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> {
|
fn domain_state_to_json(state: &WidgetState) -> Result<String, serde_json::Error> {
|
||||||
let data: serde_json::Map<String, serde_json::Value> = state
|
let data: serde_json::Map<String, serde_json::Value> = state
|
||||||
.data
|
.data
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
|
.map(|(k, v)| (k.clone(), v.into()))
|
||||||
.collect();
|
.collect();
|
||||||
serde_json::to_string(&data)
|
serde_json::to_string(&data)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn json_to_domain_state(json: &str) -> Result<WidgetState, serde_json::Error> {
|
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 map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(json)?;
|
||||||
let data: BTreeMap<String, Value> = map
|
let data: BTreeMap<String, Value> = map.into_iter().map(|(k, v)| (k, v.into())).collect();
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), json_value_to_domain(v)))
|
|
||||||
.collect();
|
|
||||||
Ok(WidgetState { data, error: None })
|
Ok(WidgetState { data, error: None })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use config_sqlite::SqliteConfigStore;
|
|||||||
use domain::{
|
use domain::{
|
||||||
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
|
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
|
||||||
Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild,
|
Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild,
|
||||||
LayoutNode, LayoutPreset, Sizing, WidgetConfig,
|
LayoutNode, LayoutPreset, Sizing, UserRepository, WidgetConfig, WidgetStateCache,
|
||||||
};
|
};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain.workspace = true
|
domain = { workspace = true, features = ["json"] }
|
||||||
application.workspace = true
|
application.workspace = true
|
||||||
api-types.workspace = true
|
api-types.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ mod routes;
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
use domain::{
|
use domain::{
|
||||||
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
|
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
|
||||||
WidgetStateReader,
|
UserRepository, WidgetStateCache, WidgetStateReader,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::cors::CorsLayer;
|
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
|
pub fn router<C, E, W, B, R, A, H>(state: AppState<C, E, W, B, R, A, H>) -> Router
|
||||||
where
|
where
|
||||||
C: ConfigRepository + Send + Sync + 'static,
|
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
|
||||||
C::Error: std::fmt::Debug + Send,
|
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
|
||||||
|
<C as UserRepository>::Error: std::fmt::Debug + Send,
|
||||||
E: EventPublisher + Send + Sync + 'static,
|
E: EventPublisher + Send + Sync + 'static,
|
||||||
E::Error: std::fmt::Debug + Send,
|
E::Error: std::fmt::Debug + Send,
|
||||||
W: WidgetStateReader + Send + Sync + 'static,
|
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>,
|
state: AppState<C, E, W, B, R, A, H>,
|
||||||
) -> Result<(), std::io::Error>
|
) -> Result<(), std::io::Error>
|
||||||
where
|
where
|
||||||
C: ConfigRepository + Send + Sync + 'static,
|
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
|
||||||
C::Error: std::fmt::Debug + Send,
|
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
|
||||||
|
<C as UserRepository>::Error: std::fmt::Debug + Send,
|
||||||
E: EventPublisher + Send + Sync + 'static,
|
E: EventPublisher + Send + Sync + 'static,
|
||||||
E::Error: std::fmt::Debug + Send,
|
E::Error: std::fmt::Debug + Send,
|
||||||
W: WidgetStateReader + Send + Sync + 'static,
|
W: WidgetStateReader + Send + Sync + 'static,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use crate::AppState;
|
|||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::Json;
|
use axum::response::Json;
|
||||||
use domain::{AuthPort, ConfigRepository, PasswordHashPort};
|
use domain::{AuthPort, PasswordHashPort, UserRepository};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
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>,
|
Json(body): Json<LoginRequest>,
|
||||||
) -> Result<Json<LoginResponse>, (StatusCode, String)>
|
) -> Result<Json<LoginResponse>, (StatusCode, String)>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: UserRepository,
|
||||||
C::Error: std::fmt::Debug,
|
C::Error: std::fmt::Debug,
|
||||||
A: AuthPort,
|
A: AuthPort,
|
||||||
H: PasswordHashPort,
|
H: PasswordHashPort,
|
||||||
@@ -51,7 +51,7 @@ pub async fn register<C, E, W, B, R, A, H>(
|
|||||||
Json(body): Json<LoginRequest>,
|
Json(body): Json<LoginRequest>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)>
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: UserRepository,
|
||||||
C::Error: std::fmt::Debug,
|
C::Error: std::fmt::Debug,
|
||||||
H: PasswordHashPort,
|
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>,
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
) -> Result<Json<StatusResponse>, StatusCode>
|
) -> Result<Json<StatusResponse>, StatusCode>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: UserRepository,
|
||||||
C::Error: std::fmt::Debug,
|
C::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let count = state
|
let count = state
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ use axum::Router;
|
|||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use domain::{
|
use domain::{
|
||||||
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
|
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>>
|
pub fn api_routes<C, E, W, B, R, A, H>() -> Router<AppState<C, E, W, B, R, A, H>>
|
||||||
where
|
where
|
||||||
C: ConfigRepository + Send + Sync + 'static,
|
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
|
||||||
C::Error: std::fmt::Debug + Send,
|
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
|
||||||
|
<C as UserRepository>::Error: std::fmt::Debug + Send,
|
||||||
E: EventPublisher + Send + Sync + 'static,
|
E: EventPublisher + Send + Sync + 'static,
|
||||||
E::Error: std::fmt::Debug + Send,
|
E::Error: std::fmt::Debug + Send,
|
||||||
W: WidgetStateReader + Send + Sync + 'static,
|
W: WidgetStateReader + Send + Sync + 'static,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ where
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = json_to_domain_value(body);
|
let data: domain::Value = body.into();
|
||||||
|
|
||||||
state
|
state
|
||||||
.events
|
.events
|
||||||
@@ -41,22 +41,3 @@ where
|
|||||||
|
|
||||||
Ok(StatusCode::OK)
|
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 {
|
match state.widget_states.get_widget_state(id).await {
|
||||||
Some(ws) => {
|
Some(ws) => {
|
||||||
let map: serde_json::Map<String, serde_json::Value> = ws
|
let map: serde_json::Map<String, serde_json::Value> =
|
||||||
.data
|
ws.data.iter().map(|(k, v)| (k.clone(), v.into())).collect();
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
|
|
||||||
.collect();
|
|
||||||
Ok(Json(serde_json::Value::Object(map)))
|
Ok(Json(serde_json::Value::Object(map)))
|
||||||
}
|
}
|
||||||
None => Err(StatusCode::NOT_FOUND),
|
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"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain.workspace = true
|
domain = { workspace = true, features = ["json"] }
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.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 {
|
impl DataSourcePort for HttpJsonAdapter {
|
||||||
type Error = HttpJsonError;
|
type Error = HttpJsonError;
|
||||||
|
|
||||||
@@ -70,6 +55,6 @@ impl DataSourcePort for HttpJsonAdapter {
|
|||||||
let resp = req.send().await.map_err(HttpJsonError::Request)?;
|
let resp = req.send().await.map_err(HttpJsonError::Request)?;
|
||||||
let json: serde_json::Value = resp.json().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 crate::error::TcpServerError;
|
||||||
use domain::{BroadcastPort, DisplayHint, Layout, ThemeConfig, WidgetId, WidgetState};
|
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;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
pub struct TcpBroadcaster {
|
pub struct TcpBroadcaster {
|
||||||
@@ -31,13 +32,13 @@ impl BroadcastPort for TcpBroadcaster {
|
|||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
widgets: &[(WidgetId, DisplayHint, WidgetState)],
|
widgets: &[(WidgetId, DisplayHint, WidgetState)],
|
||||||
) -> Result<(), Self::Error> {
|
) -> Result<(), Self::Error> {
|
||||||
let wire_layout: WireLayoutNode = (&layout.root).into();
|
let wire_layout = layout_to_wire(&layout.root);
|
||||||
let wire_widgets: Vec<WidgetDescriptor> = widgets
|
let wire_widgets: Vec<WidgetDescriptor> = widgets
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(id, hint, state)| WidgetDescriptor {
|
.map(|(id, hint, state)| WidgetDescriptor {
|
||||||
id: *id,
|
id: *id,
|
||||||
display_hint: hint.into(),
|
display_hint: display_hint_to_wire(hint),
|
||||||
state: state.into(),
|
state: widget_state_to_wire(state),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -58,8 +59,8 @@ impl BroadcastPort for TcpBroadcaster {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|(id, hint, state)| WidgetDescriptor {
|
.map(|(id, hint, state)| WidgetDescriptor {
|
||||||
id: *id,
|
id: *id,
|
||||||
display_hint: hint.into(),
|
display_hint: display_hint_to_wire(hint),
|
||||||
state: state.into(),
|
state: widget_state_to_wire(state),
|
||||||
})
|
})
|
||||||
.collect();
|
.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 broadcaster;
|
||||||
mod client_tracker;
|
mod client_tracker;
|
||||||
|
mod conversions;
|
||||||
mod error;
|
mod error;
|
||||||
mod event_bus;
|
mod event_bus;
|
||||||
mod server;
|
mod server;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use crate::broadcaster::domain_theme_to_wire;
|
use crate::broadcaster::domain_theme_to_wire;
|
||||||
use crate::client_tracker::ClientTracker;
|
use crate::client_tracker::ClientTracker;
|
||||||
|
use crate::conversions::{display_hint_to_wire, layout_to_wire, widget_state_to_wire};
|
||||||
use crate::error::TcpServerError;
|
use crate::error::TcpServerError;
|
||||||
use domain::{ConfigRepository, WidgetStateReader};
|
use domain::{ConfigRepository, WidgetStateReader};
|
||||||
use protocol::{ServerMessage, WidgetDescriptor, WireLayoutNode, encode};
|
use protocol::{ServerMessage, WidgetDescriptor, encode};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio::net::TcpListener;
|
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();
|
let mut wire_widgets = Vec::new();
|
||||||
for w in &widgets {
|
for w in &widgets {
|
||||||
if let Some(s) = widget_states.get_widget_state(w.id).await {
|
if let Some(s) = widget_states.get_widget_state(w.id).await {
|
||||||
wire_widgets.push(WidgetDescriptor {
|
wire_widgets.push(WidgetDescriptor {
|
||||||
id: w.id,
|
id: w.id,
|
||||||
display_hint: (&w.display_hint).into(),
|
display_hint: display_hint_to_wire(&w.display_hint),
|
||||||
state: (&s).into(),
|
state: widget_state_to_wire(&s),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ edition = "2024"
|
|||||||
domain.workspace = true
|
domain.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use domain::{AuthPort, ConfigRepository, PasswordHashPort, User};
|
use domain::{AuthPort, PasswordHashPort, User, UserRepository};
|
||||||
|
|
||||||
pub enum AuthError<E> {
|
pub enum AuthError<E> {
|
||||||
InvalidCredentials,
|
InvalidCredentials,
|
||||||
@@ -26,7 +26,7 @@ pub async fn login<C, A, H>(
|
|||||||
password: &str,
|
password: &str,
|
||||||
) -> Result<String, AuthError<C::Error>>
|
) -> Result<String, AuthError<C::Error>>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: UserRepository,
|
||||||
A: AuthPort,
|
A: AuthPort,
|
||||||
H: PasswordHashPort,
|
H: PasswordHashPort,
|
||||||
{
|
{
|
||||||
@@ -55,7 +55,7 @@ pub async fn register<C, H>(
|
|||||||
password: &str,
|
password: &str,
|
||||||
) -> Result<(), AuthError<C::Error>>
|
) -> Result<(), AuthError<C::Error>>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: UserRepository,
|
||||||
H: PasswordHashPort,
|
H: PasswordHashPort,
|
||||||
{
|
{
|
||||||
let count = config.count_users().await.map_err(AuthError::Repository)?;
|
let count = config.count_users().await.map_err(AuthError::Repository)?;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod auth_service;
|
pub mod auth_service;
|
||||||
mod config_service;
|
mod config_service;
|
||||||
mod data_projection;
|
mod data_projection;
|
||||||
|
pub mod polling_service;
|
||||||
|
|
||||||
pub use config_service::ConfigService;
|
pub use config_service::ConfigService;
|
||||||
pub use data_projection::DataProjection;
|
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::{
|
use domain::{
|
||||||
ConfigRepository, DataSource, DataSourceId, DomainEvent, EventPublisher, Layout, LayoutPreset,
|
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::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -123,6 +124,10 @@ impl ConfigRepository for InMemoryConfigRepository {
|
|||||||
self.presets.lock().unwrap().remove(&id);
|
self.presets.lock().unwrap().remove(&id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserRepository for InMemoryConfigRepository {
|
||||||
|
type Error = Never;
|
||||||
|
|
||||||
async fn get_user_by_username(&self, _username: &str) -> Result<Option<User>, Self::Error> {
|
async fn get_user_by_username(&self, _username: &str) -> Result<Option<User>, Self::Error> {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
@@ -135,6 +140,10 @@ impl ConfigRepository for InMemoryConfigRepository {
|
|||||||
async fn count_users(&self) -> Result<u32, Self::Error> {
|
async fn count_users(&self) -> Result<u32, Self::Error> {
|
||||||
Ok(0)
|
Ok(0)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WidgetStateCache for InMemoryConfigRepository {
|
||||||
|
type Error = Never;
|
||||||
|
|
||||||
async fn save_widget_states(
|
async fn save_widget_states(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ mod polling;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use application::DataProjection;
|
use application::DataProjection;
|
||||||
use config_sqlite::SqliteConfigStore;
|
use config_sqlite::SqliteConfigStore;
|
||||||
use domain::ConfigRepository;
|
use domain::WidgetStateCache;
|
||||||
use http_api::AppState;
|
use http_api::AppState;
|
||||||
use kframe_auth::{Argon2Hasher, AuthConfig, JwtAuthService};
|
use kframe_auth::{Argon2Hasher, AuthConfig, JwtAuthService};
|
||||||
use secret_store::AesSecretStore;
|
use secret_store::AesSecretStore;
|
||||||
|
|||||||
@@ -2,21 +2,12 @@ use anyhow::Result;
|
|||||||
use application::DataProjection;
|
use application::DataProjection;
|
||||||
use config_sqlite::SqliteConfigStore;
|
use config_sqlite::SqliteConfigStore;
|
||||||
use data_generators::{ClockGenerator, StaticTextGenerator};
|
use data_generators::{ClockGenerator, StaticTextGenerator};
|
||||||
use domain::{
|
use domain::{DataSource, DataSourcePort, DataSourceType, Value};
|
||||||
BroadcastPort, ConfigRepository, DataSource, DataSourcePort, DataSourceType, Value,
|
|
||||||
WidgetError, WidgetState,
|
|
||||||
};
|
|
||||||
use http_json::HttpJsonAdapter;
|
use http_json::HttpJsonAdapter;
|
||||||
use media_adapter::MediaAdapter;
|
use media_adapter::MediaAdapter;
|
||||||
use rss_adapter::RssAdapter;
|
use rss_adapter::RssAdapter;
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
|
||||||
use tcp_server::TcpBroadcaster;
|
use tcp_server::TcpBroadcaster;
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
use tracing::{debug, info, warn};
|
|
||||||
|
|
||||||
const SOURCE_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct Adapters {
|
struct Adapters {
|
||||||
@@ -76,219 +67,12 @@ pub async fn run(
|
|||||||
static_text: Arc::new(StaticTextGenerator::new()),
|
static_text: Arc::new(StaticTextGenerator::new()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut running: HashMap<u16, JoinHandle<()>> = HashMap::new();
|
let poller = Arc::new(move |source: &DataSource| {
|
||||||
let mut static_done: std::collections::HashSet<u16> = std::collections::HashSet::new();
|
let adapters = adapters.clone();
|
||||||
|
let source = source.clone();
|
||||||
|
async move { adapters.poll(&source).await }
|
||||||
|
});
|
||||||
|
|
||||||
info!("polling manager started");
|
application::polling_service::run(config, broadcaster, projection, poller).await;
|
||||||
|
Ok(())
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
use crate::conversions::wire_to_layout;
|
||||||
use client_domain::{BoundingBox, Color, LayoutEngine, RenderTree, ThemeConfig};
|
use client_domain::{BoundingBox, Color, LayoutEngine, RenderTree, ThemeConfig};
|
||||||
use domain::LayoutNode;
|
|
||||||
use protocol::{
|
use protocol::{
|
||||||
ServerMessage, WidgetDescriptor, WireColor, WireDisplayHint, WireLayoutNode, WireWidgetState,
|
ServerMessage, WidgetDescriptor, WireColor, WireDisplayHint, WireLayoutNode, WireWidgetState,
|
||||||
};
|
};
|
||||||
@@ -73,7 +73,7 @@ impl ClientApp {
|
|||||||
wire_layout: WireLayoutNode,
|
wire_layout: WireLayoutNode,
|
||||||
widgets: Vec<WidgetDescriptor>,
|
widgets: Vec<WidgetDescriptor>,
|
||||||
) -> Vec<RepaintCommand> {
|
) -> Vec<RepaintCommand> {
|
||||||
let layout: LayoutNode = wire_layout.into();
|
let layout = wire_to_layout(wire_layout);
|
||||||
let new_tree = LayoutEngine::compute(&layout, self.screen);
|
let new_tree = LayoutEngine::compute(&layout, self.screen);
|
||||||
|
|
||||||
self.widget_states.clear();
|
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 client_app;
|
||||||
|
mod connection_loop;
|
||||||
|
pub mod conversions;
|
||||||
|
|
||||||
pub use client_app::{ClientApp, RepaintCommand};
|
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_application::{ClientApp, conversions, run_connection_loop};
|
||||||
use client_domain::NetworkPort;
|
use client_domain::{
|
||||||
use client_domain::{BoundingBox, DisplayPort, FontMetrics, RenderEngine, ThemeConfig};
|
BoundingBox, DisplayPort, FontMetrics, RenderEngine, RepaintRequest, ThemeConfig,
|
||||||
|
WidgetRenderer,
|
||||||
|
};
|
||||||
use display_terminal::TerminalDisplay;
|
use display_terminal::TerminalDisplay;
|
||||||
use domain::{DisplayHint, WidgetError};
|
use protocol::ServerMessage;
|
||||||
use protocol::decode_server_message;
|
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
use tcp_client::StdTcpClient;
|
use tcp_client::StdTcpClient;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -18,52 +19,33 @@ fn main() {
|
|||||||
large: (10, 20),
|
large: (10, 20),
|
||||||
};
|
};
|
||||||
let mut engine = RenderEngine::new(metrics, ThemeConfig::default());
|
let mut engine = RenderEngine::new(metrics, ThemeConfig::default());
|
||||||
|
let mut renderer = WidgetRenderer::new();
|
||||||
|
|
||||||
println!("=== K-Frame Desktop Client ===");
|
println!("=== K-Frame Desktop Client ===");
|
||||||
println!("Screen: {}x{}", screen.width, screen.height);
|
println!("Screen: {}x{}", screen.width, screen.height);
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel::<ServerMessage>();
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let server_addr = "127.0.0.1:2699";
|
|
||||||
let mut net = StdTcpClient::new();
|
let mut net = StdTcpClient::new();
|
||||||
|
let tx_clone = tx.clone();
|
||||||
loop {
|
run_connection_loop(
|
||||||
if !net.is_connected() {
|
&mut net,
|
||||||
println!("[NET] Connecting to {server_addr}...");
|
"127.0.0.1:2699",
|
||||||
match net.connect(server_addr) {
|
Duration::from_millis(50),
|
||||||
Ok(()) => println!("[NET] Connected!"),
|
Duration::from_secs(2),
|
||||||
Err(e) => {
|
move |msg| {
|
||||||
println!("[NET] Connection failed: {e}, retrying in 2s...");
|
let _ = tx_clone.send(msg);
|
||||||
thread::sleep(Duration::from_secs(2));
|
},
|
||||||
continue;
|
|_connected| {},
|
||||||
}
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
println!("[RENDER] Render loop started");
|
println!("[RENDER] Render loop started");
|
||||||
|
let mut last_tick = Instant::now();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
match rx.recv_timeout(Duration::from_millis(50)) {
|
||||||
Ok(msg) => {
|
Ok(msg) => {
|
||||||
let repaints = app.handle_message(msg);
|
let repaints = app.handle_message(msg);
|
||||||
|
|
||||||
@@ -73,23 +55,36 @@ fn main() {
|
|||||||
|
|
||||||
if !repaints.is_empty() {
|
if !repaints.is_empty() {
|
||||||
println!("\n--- Repaint ({} widgets) ---", repaints.len());
|
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;
|
let bg = engine.theme().background;
|
||||||
for cmd in &repaints {
|
let updates = renderer.apply_repaints(&engine, requests);
|
||||||
display.fill_rect(cmd.bounds, bg).unwrap();
|
for update in &updates {
|
||||||
|
display.fill_rect(update.bounds, bg).unwrap();
|
||||||
let hint: DisplayHint = cmd.display_hint.clone().into();
|
for dc in &update.commands {
|
||||||
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 {
|
|
||||||
display
|
display
|
||||||
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
|
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -101,5 +96,23 @@ fn main() {
|
|||||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
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 scroll;
|
||||||
mod text_layout;
|
mod text_layout;
|
||||||
mod theme;
|
mod theme;
|
||||||
|
mod widget_renderer;
|
||||||
|
|
||||||
pub use alignment::align_offset;
|
pub use alignment::align_offset;
|
||||||
pub use bounding_box::BoundingBox;
|
pub use bounding_box::BoundingBox;
|
||||||
@@ -24,3 +25,4 @@ pub use render_tree::RenderTree;
|
|||||||
pub use scroll::ScrollState;
|
pub use scroll::ScrollState;
|
||||||
pub use text_layout::wrap_lines;
|
pub use text_layout::wrap_lines;
|
||||||
pub use theme::ThemeConfig;
|
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",
|
"client-domain",
|
||||||
"domain",
|
"domain",
|
||||||
"embedded-graphics",
|
"embedded-graphics",
|
||||||
"embedded-hal-bus",
|
|
||||||
"embedded-text",
|
|
||||||
"embuild",
|
"embuild",
|
||||||
"esp-idf-hal",
|
"esp-idf-hal",
|
||||||
"esp-idf-svc",
|
"esp-idf-svc",
|
||||||
"esp-idf-sys",
|
"esp-idf-sys",
|
||||||
"log",
|
"log",
|
||||||
"mipidsi",
|
"mipidsi",
|
||||||
"postcard",
|
|
||||||
"protocol",
|
"protocol",
|
||||||
"serde",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -489,16 +485,6 @@ dependencies = [
|
|||||||
"embedded-hal 1.0.0",
|
"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]]
|
[[package]]
|
||||||
name = "embedded-hal-nb"
|
name = "embedded-hal-nb"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -561,17 +547,6 @@ dependencies = [
|
|||||||
"strum 0.27.2",
|
"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]]
|
[[package]]
|
||||||
name = "embuild"
|
name = "embuild"
|
||||||
version = "0.33.1"
|
version = "0.33.1"
|
||||||
@@ -1126,12 +1101,6 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
|
|||||||
@@ -1,56 +1,26 @@
|
|||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use client_domain::NetworkPort;
|
use client_application::run_connection_loop;
|
||||||
use protocol::decode_server_message;
|
|
||||||
use super::RenderEvent;
|
use super::RenderEvent;
|
||||||
use crate::config::{NET_THREAD_STACK_SIZE, NET_POLL_INTERVAL, NET_RECONNECT_DELAY};
|
use crate::config::{NET_THREAD_STACK_SIZE, NET_POLL_INTERVAL, NET_RECONNECT_DELAY};
|
||||||
use crate::adapters::network::Esp32Network;
|
use crate::adapters::network::Esp32Network;
|
||||||
use log::*;
|
|
||||||
|
|
||||||
pub fn spawn(server_addr: String, tx: mpsc::Sender<RenderEvent>) {
|
pub fn spawn(server_addr: String, tx: mpsc::Sender<RenderEvent>) {
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
.stack_size(NET_THREAD_STACK_SIZE)
|
.stack_size(NET_THREAD_STACK_SIZE)
|
||||||
.name("net".into())
|
.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");
|
.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::sync::mpsc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use std::collections::HashMap;
|
|
||||||
use client_domain::{
|
use client_domain::{
|
||||||
BoundingBox, Color, DisplayPort, FontMetrics, RenderEngine, ScrollState, ThemeConfig,
|
BoundingBox, Color, DisplayPort, FontMetrics, RenderEngine, RepaintRequest, ThemeConfig,
|
||||||
|
WidgetRenderer,
|
||||||
};
|
};
|
||||||
use client_application::{ClientApp, RepaintCommand};
|
use client_application::{ClientApp, RepaintCommand, conversions};
|
||||||
use domain::{DisplayHint, Value, WidgetError};
|
|
||||||
use protocol::ServerMessage;
|
use protocol::ServerMessage;
|
||||||
use super::RenderEvent;
|
use super::RenderEvent;
|
||||||
use crate::config::RENDER_POLL_INTERVAL;
|
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_CONNECTED: Color = Color(0, 200, 0);
|
||||||
const COLOR_DISCONNECTED: Color = Color(200, 0, 0);
|
const COLOR_DISCONNECTED: Color = Color(200, 0, 0);
|
||||||
|
|
||||||
struct WidgetCache {
|
fn to_repaint_requests(repaints: &[RepaintCommand]) -> Vec<RepaintRequest> {
|
||||||
hint: DisplayHint,
|
repaints
|
||||||
data: Vec<(String, Value)>,
|
.iter()
|
||||||
error: Option<WidgetError>,
|
.map(|cmd| RepaintRequest {
|
||||||
bounds: BoundingBox,
|
widget_id: cmd.widget_id,
|
||||||
scroll: ScrollState,
|
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(
|
pub fn run(
|
||||||
@@ -37,7 +41,7 @@ pub fn run(
|
|||||||
};
|
};
|
||||||
let mut engine = RenderEngine::new(metrics, ThemeConfig::default());
|
let mut engine = RenderEngine::new(metrics, ThemeConfig::default());
|
||||||
let mut app = ClientApp::new(screen);
|
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 first_update = true;
|
||||||
let mut last_tick = Instant::now();
|
let mut last_tick = Instant::now();
|
||||||
let mut connected = false;
|
let mut connected = false;
|
||||||
@@ -47,7 +51,7 @@ pub fn run(
|
|||||||
display.flush().unwrap();
|
display.flush().unwrap();
|
||||||
|
|
||||||
loop {
|
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 };
|
let timeout = if has_scrollers { SCROLL_TICK } else { RENDER_POLL_INTERVAL };
|
||||||
match rx.recv_timeout(timeout) {
|
match rx.recv_timeout(timeout) {
|
||||||
Ok(RenderEvent::ConnectionStatus(status)) => {
|
Ok(RenderEvent::ConnectionStatus(status)) => {
|
||||||
@@ -71,14 +75,17 @@ pub fn run(
|
|||||||
display.fill_rect(screen, bg).unwrap();
|
display.fill_rect(screen, bg).unwrap();
|
||||||
first_update = false;
|
first_update = false;
|
||||||
}
|
}
|
||||||
for cmd in &repaints {
|
|
||||||
let cache = update_cache(&engine, cmd);
|
let requests = to_repaint_requests(&repaints);
|
||||||
display.fill_rect(cache.bounds, bg).unwrap();
|
let updates = renderer.apply_repaints(&engine, requests);
|
||||||
draw_widget(&engine, &mut display, &cache);
|
for update in &updates {
|
||||||
widgets.insert(cmd.widget_id, cache);
|
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);
|
draw_indicator(&mut display, screen, connected);
|
||||||
display.flush().unwrap();
|
display.flush().unwrap();
|
||||||
}
|
}
|
||||||
@@ -94,16 +101,15 @@ pub fn run(
|
|||||||
let elapsed = now.duration_since(last_tick);
|
let elapsed = now.duration_since(last_tick);
|
||||||
last_tick = now;
|
last_tick = now;
|
||||||
|
|
||||||
let mut needs_flush = false;
|
let scroll_updates = renderer.tick_scroll(&engine, elapsed);
|
||||||
for cache in widgets.values_mut() {
|
if !scroll_updates.is_empty() {
|
||||||
if cache.scroll.tick(elapsed) {
|
let bg = engine.theme().background;
|
||||||
let bg = engine.theme().background;
|
for update in &scroll_updates {
|
||||||
display.fill_rect(cache.bounds, bg).unwrap();
|
display.fill_rect(update.bounds, bg).unwrap();
|
||||||
draw_widget(&engine, &mut display, cache);
|
for dc in &update.commands {
|
||||||
needs_flush = true;
|
display.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font).unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if needs_flush {
|
|
||||||
draw_indicator(&mut display, screen, connected);
|
draw_indicator(&mut display, screen, connected);
|
||||||
display.flush().unwrap();
|
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;
|
let y = screen.y + screen.height - INDICATOR_DIAMETER - INDICATOR_MARGIN;
|
||||||
display.fill_circle(x, y, INDICATOR_DIAMETER, color).unwrap();
|
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"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
json = ["serde_json"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
serde_json = { workspace = true, optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ pub use entities::{
|
|||||||
pub use events::DomainEvent;
|
pub use events::DomainEvent;
|
||||||
pub use ports::{
|
pub use ports::{
|
||||||
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, ConnectedClient, DataSourcePort,
|
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, ConnectedClient, DataSourcePort,
|
||||||
EventPublisher, PasswordHashPort, SecretStore, WidgetStateReader,
|
EventPublisher, PasswordHashPort, SecretStore, UserRepository, WidgetStateCache,
|
||||||
|
WidgetStateReader,
|
||||||
};
|
};
|
||||||
pub use value_objects::{
|
pub use value_objects::{
|
||||||
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent,
|
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::entities::{
|
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;
|
use std::future::Future;
|
||||||
|
|
||||||
pub trait ConfigRepository {
|
pub trait ConfigRepository {
|
||||||
@@ -56,19 +56,4 @@ pub trait ConfigRepository {
|
|||||||
&self,
|
&self,
|
||||||
theme: &ThemeConfig,
|
theme: &ThemeConfig,
|
||||||
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
) -> 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 data_source_port;
|
||||||
mod event;
|
mod event;
|
||||||
mod secret_store;
|
mod secret_store;
|
||||||
|
mod user_repository;
|
||||||
|
mod widget_state_cache;
|
||||||
mod widget_state_reader;
|
mod widget_state_reader;
|
||||||
|
|
||||||
pub use auth::{AuthPort, PasswordHashPort};
|
pub use auth::{AuthPort, PasswordHashPort};
|
||||||
@@ -14,4 +16,6 @@ pub use config_repository::ConfigRepository;
|
|||||||
pub use data_source_port::DataSourcePort;
|
pub use data_source_port::DataSourcePort;
|
||||||
pub use event::EventPublisher;
|
pub use event::EventPublisher;
|
||||||
pub use secret_store::SecretStore;
|
pub use secret_store::SecretStore;
|
||||||
|
pub use user_repository::UserRepository;
|
||||||
|
pub use widget_state_cache::WidgetStateCache;
|
||||||
pub use widget_state_reader::WidgetStateReader;
|
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;
|
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)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum Value {
|
pub enum Value {
|
||||||
Null,
|
Null,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain.workspace = true
|
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
postcard.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 serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
@@ -15,60 +11,12 @@ pub enum WireValue {
|
|||||||
Object(BTreeMap<String, 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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum WireWidgetError {
|
pub enum WireWidgetError {
|
||||||
SourceUnavailable,
|
SourceUnavailable,
|
||||||
ExtractionFailed,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct WireKeyValue {
|
pub struct WireKeyValue {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
@@ -81,35 +29,6 @@ pub struct WireWidgetState {
|
|||||||
pub error: Option<WireWidgetError>,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum WireDisplayHintKind {
|
pub enum WireDisplayHintKind {
|
||||||
IconValue,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum WireSizing {
|
pub enum WireSizing {
|
||||||
Fixed(u16),
|
Fixed(u16),
|
||||||
Flex(u8),
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum WireDirection {
|
pub enum WireDirection {
|
||||||
Row,
|
Row,
|
||||||
Column,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum WireJustifyContent {
|
pub enum WireJustifyContent {
|
||||||
Start,
|
Start,
|
||||||
@@ -285,30 +88,6 @@ pub enum WireJustifyContent {
|
|||||||
SpaceEvenly,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum WireAlignItems {
|
pub enum WireAlignItems {
|
||||||
Start,
|
Start,
|
||||||
@@ -317,28 +96,6 @@ pub enum WireAlignItems {
|
|||||||
Stretch,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct WireContainerNode {
|
pub struct WireContainerNode {
|
||||||
pub direction: WireDirection,
|
pub direction: WireDirection,
|
||||||
@@ -360,49 +117,3 @@ pub enum WireLayoutNode {
|
|||||||
Container(WireContainerNode),
|
Container(WireContainerNode),
|
||||||
Leaf(u16),
|
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