arch: push wire types out of ClientApp, extract event_service, cleanup dead code

- ClientApp stores domain types, RepaintCommand carries DisplayHint + Vec<(String,Value)>
- adapters no longer convert Wire→Domain (eliminated duplication in esp32 + desktop)
- event_service in application layer handles LayoutChanged/WebhookDataReceived/ThemeChanged
- bootstrap event_handler reduced to 10-line dispatcher
- polling_service reuses event_service::apply_and_broadcast (deduplicated broadcast pattern)
- AppState.config_service() replaces 11 inline ConfigService::new() calls
- delete unused poll_interval_secs parameter chain
- delete unused StoragePort/ClientConfig (zero implementations)
This commit is contained in:
2026-06-19 18:30:14 +02:00
parent 7001b5e911
commit fa097771d4
21 changed files with 264 additions and 217 deletions

View File

@@ -2,7 +2,6 @@
KFRAME_DATABASE_URL=sqlite:kframe.db?mode=rwc KFRAME_DATABASE_URL=sqlite:kframe.db?mode=rwc
KFRAME_TCP_ADDR=0.0.0.0:2699 KFRAME_TCP_ADDR=0.0.0.0:2699
KFRAME_HTTP_ADDR=0.0.0.0:3000 KFRAME_HTTP_ADDR=0.0.0.0:3000
KFRAME_POLL_INTERVAL_SECS=5
# Auth (required) # Auth (required)
JWT_SECRET=change-me-to-a-random-secret JWT_SECRET=change-me-to-a-random-secret

View File

@@ -1,6 +1,7 @@
pub mod extractors; pub mod extractors;
mod routes; mod routes;
use application::ConfigService;
use axum::Router; use axum::Router;
use domain::{ use domain::{
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort, AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
@@ -36,6 +37,18 @@ impl<C, E, W, B, R, A, H> Clone for AppState<C, E, W, B, R, A, H> {
} }
} }
impl<C, E, W, B, R, A, H> AppState<C, E, W, B, R, A, H>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{
pub fn config_service(&self) -> ConfigService<'_, C, E> {
ConfigService::new(self.config.as_ref(), self.events.as_ref())
}
}
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 + UserRepository + WidgetStateCache + Send + Sync + 'static, C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,

View File

@@ -1,7 +1,7 @@
use crate::AppState; use crate::AppState;
use crate::extractors::AuthUser; use crate::extractors::AuthUser;
use api_types::DataSourceDto; use api_types::DataSourceDto;
use application::ConfigService;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
@@ -65,7 +65,7 @@ where
let source = body let source = body
.into_domain() .into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?; .map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = state.config_service();
svc.create_data_source(source) svc.create_data_source(source)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
@@ -87,7 +87,7 @@ where
let source = body let source = body
.into_domain() .into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?; .map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = state.config_service();
svc.update_data_source(source) svc.update_data_source(source)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
@@ -105,7 +105,7 @@ where
E: EventPublisher, E: EventPublisher,
E::Error: std::fmt::Debug, E::Error: std::fmt::Debug,
{ {
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = state.config_service();
svc.delete_data_source(id) svc.delete_data_source(id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

View File

@@ -1,7 +1,7 @@
use crate::AppState; use crate::AppState;
use crate::extractors::AuthUser; use crate::extractors::AuthUser;
use api_types::LayoutDto; use api_types::LayoutDto;
use application::ConfigService;
use axum::{extract::State, http::StatusCode, response::Json}; use axum::{extract::State, http::StatusCode, response::Json};
use domain::{ConfigRepository, EventPublisher}; use domain::{ConfigRepository, EventPublisher};
@@ -39,7 +39,7 @@ where
let layout = body let layout = body
.into_domain() .into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?; .map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = state.config_service();
svc.update_layout(layout) svc.update_layout(layout)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;

View File

@@ -1,7 +1,7 @@
use crate::AppState; use crate::AppState;
use crate::extractors::AuthUser; use crate::extractors::AuthUser;
use api_types::{CreatePresetDto, PresetDto}; use api_types::{CreatePresetDto, PresetDto};
use application::ConfigService;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
@@ -65,7 +65,7 @@ where
let preset = body let preset = body
.into_domain() .into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?; .map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = state.config_service();
svc.save_preset(preset) svc.save_preset(preset)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
@@ -83,7 +83,7 @@ where
E: EventPublisher, E: EventPublisher,
E::Error: std::fmt::Debug, E::Error: std::fmt::Debug,
{ {
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = state.config_service();
svc.delete_preset(id) svc.delete_preset(id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -101,7 +101,7 @@ where
E: EventPublisher, E: EventPublisher,
E::Error: std::fmt::Debug, E::Error: std::fmt::Debug,
{ {
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = state.config_service();
svc.load_preset(id) svc.load_preset(id)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;

View File

@@ -1,7 +1,7 @@
use crate::AppState; use crate::AppState;
use crate::extractors::AuthUser; use crate::extractors::AuthUser;
use api_types::ThemeDto; use api_types::ThemeDto;
use application::ConfigService;
use axum::{extract::State, http::StatusCode, response::Json}; use axum::{extract::State, http::StatusCode, response::Json};
use domain::{ConfigRepository, EventPublisher}; use domain::{ConfigRepository, EventPublisher};
@@ -38,7 +38,7 @@ where
E::Error: std::fmt::Debug, E::Error: std::fmt::Debug,
{ {
let theme = body.into_domain(); let theme = body.into_domain();
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = state.config_service();
svc.update_theme(theme) svc.update_theme(theme)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;

View File

@@ -1,7 +1,7 @@
use crate::AppState; use crate::AppState;
use crate::extractors::AuthUser; use crate::extractors::AuthUser;
use api_types::{CreateWidgetDto, WidgetDto}; use api_types::{CreateWidgetDto, WidgetDto};
use application::ConfigService;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
@@ -65,7 +65,7 @@ where
let widget = body let widget = body
.into_domain() .into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?; .map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = state.config_service();
svc.create_widget(widget) svc.create_widget(widget)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
@@ -87,7 +87,7 @@ where
let widget = body let widget = body
.into_domain() .into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?; .map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = state.config_service();
svc.update_widget(widget) svc.update_widget(widget)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
@@ -105,7 +105,7 @@ where
E: EventPublisher, E: EventPublisher,
E::Error: std::fmt::Debug, E::Error: std::fmt::Debug,
{ {
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = state.config_service();
svc.delete_widget(id) svc.delete_widget(id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

View File

@@ -0,0 +1,123 @@
use crate::DataProjection;
use domain::{
BroadcastPort, ConfigRepository, DomainEvent, Layout, Value, WidgetConfig, WidgetState,
};
use std::sync::Arc;
use tracing::{error, info, warn};
pub async fn handle_event<C, B>(
event: DomainEvent,
config: &Arc<C>,
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) where
C: ConfigRepository,
C::Error: std::fmt::Display,
B: BroadcastPort,
B::Error: std::fmt::Display,
{
match event {
DomainEvent::LayoutChanged { layout } => {
handle_layout_changed(&layout, config, broadcaster, projection).await;
}
DomainEvent::WebhookDataReceived { source_id, data } => {
handle_webhook_data(source_id, &data, config, broadcaster, projection).await;
}
DomainEvent::ThemeChanged { theme } => {
if let Err(e) = broadcaster.push_theme_update(&theme).await {
error!(error = %e, "failed to push theme update");
}
info!("theme changed, pushed update to clients");
}
_ => {}
}
}
async fn handle_layout_changed<C, B>(
layout: &Layout,
config: &Arc<C>,
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) where
C: ConfigRepository,
C::Error: std::fmt::Display,
B: BroadcastPort,
B::Error: std::fmt::Display,
{
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
error!(error = %e, "failed to fetch widgets for screen update");
return;
}
};
let mut widget_states = Vec::new();
for w in &widgets {
if let Some(s) = projection.get_state(w.id).await {
widget_states.push((w.id, w.display_hint.clone(), s));
}
}
if let Err(e) = broadcaster.push_screen_update(layout, &widget_states).await {
error!(error = %e, "failed to push screen update");
}
info!("layout changed, pushed screen update to clients");
}
async fn handle_webhook_data<C, B>(
source_id: u16,
data: &Value,
config: &Arc<C>,
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) where
C: ConfigRepository,
C::Error: std::fmt::Display,
B: BroadcastPort,
B::Error: std::fmt::Display,
{
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
error!(error = %e, "failed to fetch widgets for webhook");
return;
}
};
let changed = apply_and_broadcast(source_id, data, &widgets, broadcaster, projection).await;
if !changed.is_empty() {
info!(source_id, count = changed.len(), "webhook data pushed");
}
}
pub async fn apply_and_broadcast<B>(
source_id: u16,
data: &Value,
widgets: &[WidgetConfig],
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) -> Vec<(u16, WidgetState)>
where
B: BroadcastPort,
B::Error: std::fmt::Display,
{
let changed: Vec<(u16, WidgetState)> =
projection.apply_poll_result(source_id, data, 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");
}
}
changed
}

View File

@@ -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 event_service;
pub mod polling_service; pub mod polling_service;
pub use config_service::ConfigService; pub use config_service::ConfigService;

View File

@@ -130,7 +130,20 @@ async fn poll_and_broadcast<C, B, P, F>(
return; return;
} }
}; };
broadcast_changes(source, &result, &widgets, broadcaster, projection, config).await; let changed = crate::event_service::apply_and_broadcast(
source.id,
&result,
&widgets,
broadcaster,
projection,
)
.await;
if !changed.is_empty() {
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 poll_loop<C, B, P, F>( async fn poll_loop<C, B, P, F>(
@@ -176,53 +189,23 @@ async fn poll_loop<C, B, P, F>(
last_refresh = tokio::time::Instant::now(); last_refresh = tokio::time::Instant::now();
} }
broadcast_changes( let changed = crate::event_service::apply_and_broadcast(
&source, source.id,
&result, &result,
&widgets, &widgets,
&broadcaster, &broadcaster,
&projection, &projection,
&config,
) )
.await; .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() { 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 { if let Err(e) = config.save_widget_states(&changed).await {
warn!(error = %e, "failed to cache widget states"); warn!(error = %e, "failed to cache widget states");
} }
info!(source = %source.name, count = changed.len(), "pushed widget updates"); info!(source = %source.name, count = changed.len(), "pushed widget updates");
} }
tokio::time::sleep(interval).await;
}
} }
async fn broadcast_errors<B>( async fn broadcast_errors<B>(

View File

@@ -4,7 +4,6 @@ pub struct ServerConfig {
pub database_url: String, pub database_url: String,
pub tcp_addr: String, pub tcp_addr: String,
pub http_addr: String, pub http_addr: String,
pub poll_interval_secs: u64,
pub spa_dir: Option<String>, pub spa_dir: Option<String>,
} }
@@ -15,10 +14,6 @@ impl ServerConfig {
.unwrap_or_else(|_| "sqlite:kframe.db?mode=rwc".into()), .unwrap_or_else(|_| "sqlite:kframe.db?mode=rwc".into()),
tcp_addr: env::var("KFRAME_TCP_ADDR").unwrap_or_else(|_| "0.0.0.0:2699".into()), tcp_addr: env::var("KFRAME_TCP_ADDR").unwrap_or_else(|_| "0.0.0.0:2699".into()),
http_addr: env::var("KFRAME_HTTP_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".into()), http_addr: env::var("KFRAME_HTTP_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".into()),
poll_interval_secs: env::var("KFRAME_POLL_INTERVAL_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5),
spa_dir: env::var("KFRAME_SPA_DIR").ok(), spa_dir: env::var("KFRAME_SPA_DIR").ok(),
} }
} }

View File

@@ -1,9 +1,8 @@
use application::DataProjection; use application::DataProjection;
use config_sqlite::SqliteConfigStore; use config_sqlite::SqliteConfigStore;
use domain::{BroadcastPort, ConfigRepository, DomainEvent};
use std::sync::Arc; use std::sync::Arc;
use tcp_server::{TcpBroadcaster, TcpEventBus}; use tcp_server::{TcpBroadcaster, TcpEventBus};
use tracing::{error, info, warn}; use tracing::{error, warn};
pub async fn run( pub async fn run(
event_bus: Arc<TcpEventBus>, event_bus: Arc<TcpEventBus>,
@@ -15,69 +14,10 @@ pub async fn run(
loop { loop {
match rx.recv().await { match rx.recv().await {
Ok(DomainEvent::LayoutChanged { layout }) => { Ok(event) => {
let widgets = match config.list_widgets().await { application::event_service::handle_event(event, &config, &broadcaster, &projection)
Ok(w) => w,
Err(e) => {
error!(error = %e, "failed to fetch widgets for screen update");
continue;
}
};
let mut widget_states = Vec::new();
for w in &widgets {
if let Some(s) = projection.get_state(w.id).await {
widget_states.push((w.id, w.display_hint.clone(), s));
}
}
if let Err(e) = broadcaster
.push_screen_update(&layout, &widget_states)
.await
{
error!(error = %e, "failed to push screen update");
}
info!("layout changed, pushed screen update to clients");
}
Ok(DomainEvent::WebhookDataReceived { source_id, data }) => {
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
error!(error = %e, "failed to fetch widgets for webhook");
continue;
}
};
let changed = projection
.apply_poll_result(source_id, &data, &widgets)
.await; .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 {
error!(error = %e, "failed to push webhook data update");
} }
info!(
source_id,
count = changed.len(),
"webhook data received, pushed update"
);
}
}
Ok(DomainEvent::ThemeChanged { theme }) => {
if let Err(e) = broadcaster.push_theme_update(&theme).await {
error!(error = %e, "failed to push theme update");
}
info!("theme changed, pushed update to clients");
}
Ok(_) => {}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
warn!(skipped = n, "event handler lagged, missed events"); warn!(skipped = n, "event handler lagged, missed events");
} }

View File

@@ -90,11 +90,5 @@ async fn main() -> Result<()> {
event_handler::run(ev_bus, ev_config, ev_bc, ev_proj).await; event_handler::run(ev_bus, ev_config, ev_bc, ev_proj).await;
}); });
polling::run( polling::run(config_store, broadcaster, projection).await
config_store,
broadcaster,
projection,
cfg.poll_interval_secs,
)
.await
} }

View File

@@ -57,7 +57,6 @@ pub async fn run(
config: Arc<SqliteConfigStore>, config: Arc<SqliteConfigStore>,
broadcaster: Arc<TcpBroadcaster>, broadcaster: Arc<TcpBroadcaster>,
projection: Arc<DataProjection>, projection: Arc<DataProjection>,
_poll_interval_secs: u64,
) -> Result<()> { ) -> Result<()> {
let adapters = Adapters { let adapters = Adapters {
http: Arc::new(HttpJsonAdapter::new()), http: Arc::new(HttpJsonAdapter::new()),

View File

@@ -1,24 +1,24 @@
use crate::conversions::wire_to_layout; use crate::conversions::{wire_to_display_hint, wire_to_layout, wire_to_widget_state};
use client_domain::{BoundingBox, Color, LayoutEngine, RenderTree, ThemeConfig}; use client_domain::{BoundingBox, Color, LayoutEngine, RenderTree, ThemeConfig};
use protocol::{ use domain::{DisplayHint, Value, WidgetError, WidgetState};
ServerMessage, WidgetDescriptor, WireColor, WireDisplayHint, WireLayoutNode, WireWidgetState, use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireLayoutNode};
};
use std::collections::HashMap; use std::collections::HashMap;
pub struct ClientApp { pub struct ClientApp {
screen: BoundingBox, screen: BoundingBox,
render_tree: Option<RenderTree>, render_tree: Option<RenderTree>,
widget_states: HashMap<u16, (WireDisplayHint, WireWidgetState)>, widget_states: HashMap<u16, (DisplayHint, WidgetState)>,
theme: ThemeConfig, theme: ThemeConfig,
theme_changed: bool, theme_changed: bool,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone)]
pub struct RepaintCommand { pub struct RepaintCommand {
pub widget_id: u16, pub widget_id: u16,
pub bounds: BoundingBox, pub bounds: BoundingBox,
pub display_hint: WireDisplayHint, pub display_hint: DisplayHint,
pub state: WireWidgetState, pub data: Vec<(String, Value)>,
pub error: Option<WidgetError>,
} }
impl ClientApp { impl ClientApp {
@@ -77,9 +77,10 @@ impl ClientApp {
let new_tree = LayoutEngine::compute(&layout, self.screen); let new_tree = LayoutEngine::compute(&layout, self.screen);
self.widget_states.clear(); self.widget_states.clear();
for w in &widgets { for w in widgets {
self.widget_states let hint = wire_to_display_hint(w.display_hint);
.insert(w.id, (w.display_hint.clone(), w.state.clone())); let state = wire_to_widget_state(w.state);
self.widget_states.insert(w.id, (hint, state));
} }
let repaints = self.build_repaints_for_all(&new_tree); let repaints = self.build_repaints_for_all(&new_tree);
@@ -96,21 +97,19 @@ impl ClientApp {
let mut repaints = Vec::new(); let mut repaints = Vec::new();
for w in widgets { for w in widgets {
let hint = wire_to_display_hint(w.display_hint);
let state = wire_to_widget_state(w.state);
let changed = self let changed = self
.widget_states .widget_states
.get(&w.id) .get(&w.id)
.is_none_or(|(_, prev_state)| *prev_state != w.state); .is_none_or(|(_, prev)| *prev != state);
if changed { if changed {
if let Some(bounds) = tree.get_widget_bounds(w.id) { if let Some(bounds) = tree.get_widget_bounds(w.id) {
repaints.push(RepaintCommand { repaints.push(Self::make_repaint(w.id, *bounds, &hint, &state));
widget_id: w.id,
bounds: *bounds,
display_hint: w.display_hint.clone(),
state: w.state.clone(),
});
} }
self.widget_states.insert(w.id, (w.display_hint, w.state)); self.widget_states.insert(w.id, (hint, state));
} }
} }
@@ -122,18 +121,32 @@ impl ClientApp {
for (id, (hint, state)) in &self.widget_states { for (id, (hint, state)) in &self.widget_states {
if let Some(bounds) = tree.get_widget_bounds(*id) { if let Some(bounds) = tree.get_widget_bounds(*id) {
repaints.push(RepaintCommand { repaints.push(Self::make_repaint(*id, *bounds, hint, state));
widget_id: *id,
bounds: *bounds,
display_hint: hint.clone(),
state: state.clone(),
});
} }
} }
repaints.sort_by_key(|r| r.widget_id); repaints.sort_by_key(|r| r.widget_id);
repaints repaints
} }
fn make_repaint(
id: u16,
bounds: BoundingBox,
hint: &DisplayHint,
state: &WidgetState,
) -> RepaintCommand {
RepaintCommand {
widget_id: id,
bounds,
display_hint: hint.clone(),
data: state
.data
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
error: state.error.clone(),
}
}
} }
fn wire_color(c: WireColor) -> Color { fn wire_color(c: WireColor) -> Color {

View File

@@ -1,4 +1,4 @@
use client_application::{ClientApp, RepaintCommand}; use client_application::ClientApp;
use client_domain::BoundingBox; use client_domain::BoundingBox;
use protocol::{ use protocol::{
ServerMessage, WidgetDescriptor, WireAlignItems, WireContainerNode, WireDirection, ServerMessage, WidgetDescriptor, WireAlignItems, WireContainerNode, WireDirection,
@@ -84,8 +84,8 @@ fn data_update_only_repaints_changed_widgets() {
assert_eq!(repaints.len(), 1); assert_eq!(repaints.len(), 1);
assert_eq!(repaints[0].widget_id, 1); assert_eq!(repaints[0].widget_id, 1);
assert_eq!( assert_eq!(
repaints[0].state.data[0].value, repaints[0].data[0],
WireValue::String("6.1°C".into()) ("temperature".into(), domain::Value::String("6.1°C".into()))
); );
} }

View File

@@ -1,4 +1,4 @@
use client_application::{ClientApp, conversions, run_connection_loop}; use client_application::{ClientApp, RepaintCommand, run_connection_loop};
use client_domain::{ use client_domain::{
BoundingBox, DisplayPort, FontMetrics, RenderEngine, RepaintRequest, ThemeConfig, BoundingBox, DisplayPort, FontMetrics, RenderEngine, RepaintRequest, ThemeConfig,
WidgetRenderer, WidgetRenderer,
@@ -10,6 +10,16 @@ use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tcp_client::StdTcpClient; use tcp_client::StdTcpClient;
fn to_request(cmd: &RepaintCommand) -> RepaintRequest {
RepaintRequest {
widget_id: cmd.widget_id,
bounds: cmd.bounds,
display_hint: cmd.display_hint.clone(),
data: cmd.data.clone(),
error: cmd.error.clone(),
}
}
fn main() { fn main() {
let screen = BoundingBox::screen(240, 320); let screen = BoundingBox::screen(240, 320);
let mut app = ClientApp::new(screen); let mut app = ClientApp::new(screen);
@@ -56,30 +66,7 @@ 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 let requests: Vec<_> = repaints.iter().map(to_request).collect();
.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;
let updates = renderer.apply_repaints(&engine, requests); let updates = renderer.apply_repaints(&engine, requests);
for update in &updates { for update in &updates {

View File

@@ -19,7 +19,7 @@ pub use domain::{AlignItems, DisplayHintKind, HAlign, JustifyContent, VAlign};
pub use font::{FontMetrics, FontSize}; pub use font::{FontMetrics, FontSize};
pub use layout_engine::LayoutEngine; pub use layout_engine::LayoutEngine;
pub use markup::{TextSpan, parse_markup}; pub use markup::{TextSpan, parse_markup};
pub use ports::{ClientConfig, DisplayPort, NetworkPort, StoragePort}; pub use ports::{DisplayPort, NetworkPort};
pub use render_engine::{DrawCommand, RenderEngine}; pub use render_engine::{DrawCommand, RenderEngine};
pub use render_tree::RenderTree; pub use render_tree::RenderTree;
pub use scroll::ScrollState; pub use scroll::ScrollState;

View File

@@ -1,7 +1,5 @@
mod display; mod display;
mod network; mod network;
mod storage;
pub use display::DisplayPort; pub use display::DisplayPort;
pub use network::NetworkPort; pub use network::NetworkPort;
pub use storage::{ClientConfig, StoragePort};

View File

@@ -1,11 +0,0 @@
pub struct ClientConfig {
pub wifi_ssid: String,
pub wifi_password: String,
pub server_addr: String,
}
pub trait StoragePort {
type Error;
fn load_config(&self) -> Result<ClientConfig, Self::Error>;
}

View File

@@ -1,10 +1,12 @@
use std::sync::mpsc; use std::sync::mpsc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use client_application::RepaintCommand;
use client_domain::{ use client_domain::{
BoundingBox, Color, DisplayPort, FontMetrics, RenderEngine, RepaintRequest, ThemeConfig, BoundingBox, Color, DisplayPort, FontMetrics, RenderEngine, RepaintRequest, ThemeConfig,
WidgetRenderer, WidgetRenderer,
}; };
use client_application::{ClientApp, RepaintCommand, conversions}; use client_application::ClientApp;
use protocol::ServerMessage; use protocol::ServerMessage;
use super::RenderEvent; use super::RenderEvent;
use crate::config::RENDER_POLL_INTERVAL; use crate::config::RENDER_POLL_INTERVAL;
@@ -17,17 +19,14 @@ 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);
fn to_repaint_requests(repaints: &[RepaintCommand]) -> Vec<RepaintRequest> { fn to_request(cmd: &RepaintCommand) -> RepaintRequest {
repaints RepaintRequest {
.iter()
.map(|cmd| RepaintRequest {
widget_id: cmd.widget_id, widget_id: cmd.widget_id,
bounds: cmd.bounds, bounds: cmd.bounds,
display_hint: conversions::wire_to_display_hint(cmd.display_hint.clone()), display_hint: cmd.display_hint.clone(),
data: cmd.state.data.iter().map(|kv| (kv.key.clone(), conversions::wire_to_value(kv.value.clone()))).collect(), data: cmd.data.clone(),
error: cmd.state.error.as_ref().map(|e| conversions::wire_to_widget_error(e.clone())), error: cmd.error.clone(),
}) }
.collect()
} }
pub fn run( pub fn run(
@@ -52,7 +51,11 @@ pub fn run(
loop { loop {
let has_scrollers = renderer.has_active_scrollers(); 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)) => {
if status != connected { if status != connected {
@@ -76,12 +79,14 @@ pub fn run(
first_update = false; first_update = false;
} }
let requests = to_repaint_requests(&repaints); let requests: Vec<_> = repaints.iter().map(to_request).collect();
let updates = renderer.apply_repaints(&engine, requests); let updates = renderer.apply_repaints(&engine, requests);
for update in &updates { for update in &updates {
display.fill_rect(update.bounds, bg).unwrap(); display.fill_rect(update.bounds, bg).unwrap();
for dc in &update.commands { for dc in &update.commands {
display.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font).unwrap(); display
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
.unwrap();
} }
} }
@@ -107,7 +112,9 @@ pub fn run(
for update in &scroll_updates { for update in &scroll_updates {
display.fill_rect(update.bounds, bg).unwrap(); display.fill_rect(update.bounds, bg).unwrap();
for dc in &update.commands { for dc in &update.commands {
display.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font).unwrap(); display
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
.unwrap();
} }
} }
draw_indicator(&mut display, screen, connected); draw_indicator(&mut display, screen, connected);
@@ -117,8 +124,14 @@ pub fn run(
} }
fn draw_indicator(display: &mut Esp32DisplayAdapter, screen: BoundingBox, connected: bool) { fn draw_indicator(display: &mut Esp32DisplayAdapter, screen: BoundingBox, connected: bool) {
let color = if connected { COLOR_CONNECTED } else { COLOR_DISCONNECTED }; let color = if connected {
COLOR_CONNECTED
} else {
COLOR_DISCONNECTED
};
let x = screen.x + screen.width - INDICATOR_DIAMETER - INDICATOR_MARGIN; let x = screen.x + screen.width - INDICATOR_DIAMETER - INDICATOR_MARGIN;
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();
} }