From fe59b68c37ded7fc409ba369ec5234413cf8fb2c Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 19 Jun 2026 03:26:18 +0200 Subject: [PATCH] theme config, layout preview, container alignment Server: ThemeConfig entity + CRUD (GET/PUT /theme), SQLite persistence, ThemeUpdate broadcast to ESP32 on save and initial connect. Client: render engine uses theme colors, full-screen redraw on theme change. SPA: theme page with color pickers + presets, layout preview with TS port of layout engine, justify/align controls on containers. DisplayHint refactored to struct (kind + h_align + v_align). --- crates/adapters/config-memory/src/lib.rs | 23 +- crates/adapters/config-sqlite/src/lib.rs | 9 + .../config-sqlite/src/repository/mod.rs | 13 +- .../config-sqlite/src/repository/theme.rs | 37 +++ .../config-sqlite/src/serialization/layout.rs | 35 ++- .../config-sqlite/src/serialization/mod.rs | 1 + .../config-sqlite/src/serialization/theme.rs | 40 +++ .../config-sqlite/tests/config_store_tests.rs | 4 +- crates/adapters/http-api/src/routes/mod.rs | 6 + crates/adapters/http-api/src/routes/theme.rs | 46 +++ crates/adapters/tcp-server/src/broadcaster.rs | 26 +- crates/adapters/tcp-server/src/server.rs | 20 +- crates/api-types/src/layout.rs | 43 ++- crates/api-types/src/lib.rs | 2 + crates/api-types/src/theme.rs | 52 ++++ crates/application/src/config_service.rs | 17 +- .../application/tests/config_service_tests.rs | 4 +- crates/application/tests/support/mod.rs | 13 +- crates/bootstrap/src/event_handler.rs | 6 + crates/client-application/src/client_app.rs | 2 +- crates/client-desktop/src/main.rs | 10 +- crates/client-domain/src/lib.rs | 6 +- crates/client-domain/src/markup.rs | 10 +- crates/client-domain/src/render_engine.rs | 69 ++--- crates/client-domain/src/text_layout.rs | 17 +- .../tests/layout_engine_tests.rs | 49 +++- crates/client-domain/tests/markup_tests.rs | 93 ++++-- .../tests/render_engine_tests.rs | 4 +- .../client-domain/tests/render_tree_tests.rs | 4 +- crates/client-esp32/src/tasks/render.rs | 5 +- crates/domain/src/events/mod.rs | 3 +- crates/domain/src/lib.rs | 4 +- crates/domain/src/ports/broadcast.rs | 7 +- crates/domain/src/ports/config_repository.rs | 8 +- crates/domain/src/value_objects/mod.rs | 2 + crates/domain/src/value_objects/theme.rs | 47 +++ crates/protocol/src/lib.rs | 2 +- crates/protocol/tests/round_trip_tests.rs | 4 +- spa/src/api/theme.ts | 22 ++ spa/src/api/types.ts | 18 ++ spa/src/components/app-shell.tsx | 2 + spa/src/components/layout-preview.tsx | 103 +++++++ spa/src/lib/layout-engine.ts | 93 ++++++ spa/src/pages/layout-builder.tsx | 129 +++++++- spa/src/pages/theme.tsx | 276 ++++++++++++++++++ spa/src/router.tsx | 8 + 46 files changed, 1276 insertions(+), 118 deletions(-) create mode 100644 crates/adapters/config-sqlite/src/repository/theme.rs create mode 100644 crates/adapters/config-sqlite/src/serialization/theme.rs create mode 100644 crates/adapters/http-api/src/routes/theme.rs create mode 100644 crates/api-types/src/theme.rs create mode 100644 crates/domain/src/value_objects/theme.rs create mode 100644 spa/src/api/theme.ts create mode 100644 spa/src/components/layout-preview.tsx create mode 100644 spa/src/lib/layout-engine.ts create mode 100644 spa/src/pages/theme.tsx diff --git a/crates/adapters/config-memory/src/lib.rs b/crates/adapters/config-memory/src/lib.rs index 53735bb..b014d67 100644 --- a/crates/adapters/config-memory/src/lib.rs +++ b/crates/adapters/config-memory/src/lib.rs @@ -1,6 +1,6 @@ use domain::{ - ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, User, - WidgetConfig, WidgetId, + ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig, + User, WidgetConfig, WidgetId, }; use std::collections::HashMap; use std::sync::RwLock; @@ -15,6 +15,7 @@ pub struct MemoryConfigStore { widgets: RwLock>, data_sources: RwLock>, layout: RwLock>, + theme: RwLock>, presets: RwLock>, users: RwLock>, } @@ -25,6 +26,7 @@ impl Default for MemoryConfigStore { widgets: RwLock::new(HashMap::new()), data_sources: RwLock::new(HashMap::new()), layout: RwLock::new(None), + theme: RwLock::new(None), presets: RwLock::new(HashMap::new()), users: RwLock::new(Vec::new()), } @@ -125,6 +127,23 @@ impl ConfigRepository for MemoryConfigStore { Ok(()) } + async fn get_theme(&self) -> Result, Self::Error> { + let guard = self + .theme + .read() + .map_err(|_| MemoryConfigError::LockPoisoned)?; + Ok(guard.clone()) + } + + async fn save_theme(&self, theme: &ThemeConfig) -> Result<(), Self::Error> { + let mut guard = self + .theme + .write() + .map_err(|_| MemoryConfigError::LockPoisoned)?; + *guard = Some(theme.clone()); + Ok(()) + } + async fn get_preset(&self, id: LayoutPresetId) -> Result, Self::Error> { let guard = self .presets diff --git a/crates/adapters/config-sqlite/src/lib.rs b/crates/adapters/config-sqlite/src/lib.rs index b2a88a2..2f4557d 100644 --- a/crates/adapters/config-sqlite/src/lib.rs +++ b/crates/adapters/config-sqlite/src/lib.rs @@ -87,6 +87,15 @@ impl SqliteConfigStore { .execute(&self.pool) .await?; + sqlx::query( + "CREATE TABLE IF NOT EXISTS theme ( + id INTEGER PRIMARY KEY CHECK (id = 1), + data TEXT NOT NULL + )", + ) + .execute(&self.pool) + .await?; + Ok(()) } } diff --git a/crates/adapters/config-sqlite/src/repository/mod.rs b/crates/adapters/config-sqlite/src/repository/mod.rs index f5eb7fa..5f22b14 100644 --- a/crates/adapters/config-sqlite/src/repository/mod.rs +++ b/crates/adapters/config-sqlite/src/repository/mod.rs @@ -1,14 +1,15 @@ mod data_sources; mod layout; mod presets; +mod theme; mod users; mod widgets; use crate::SqliteConfigStore; use crate::error::SqliteConfigError; use domain::{ - ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, User, - WidgetConfig, WidgetId, + ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig, + User, WidgetConfig, WidgetId, }; impl ConfigRepository for SqliteConfigStore { @@ -70,6 +71,14 @@ impl ConfigRepository for SqliteConfigStore { self.delete_preset_impl(id).await } + async fn get_theme(&self) -> Result, Self::Error> { + self.get_theme_impl().await + } + + async fn save_theme(&self, theme: &ThemeConfig) -> Result<(), Self::Error> { + self.save_theme_impl(theme).await + } + async fn get_user_by_username(&self, username: &str) -> Result, Self::Error> { self.get_user_by_username_impl(username).await } diff --git a/crates/adapters/config-sqlite/src/repository/theme.rs b/crates/adapters/config-sqlite/src/repository/theme.rs new file mode 100644 index 0000000..f85563f --- /dev/null +++ b/crates/adapters/config-sqlite/src/repository/theme.rs @@ -0,0 +1,37 @@ +use crate::SqliteConfigStore; +use crate::error::SqliteConfigError; +use crate::serialization::theme as ser; +use domain::ThemeConfig; +use sqlx::Row; + +impl SqliteConfigStore { + pub(crate) async fn get_theme_impl(&self) -> Result, SqliteConfigError> { + let row = sqlx::query("SELECT data FROM theme WHERE id = 1") + .fetch_optional(&self.pool) + .await + .map_err(SqliteConfigError::Sql)?; + + match row { + None => Ok(None), + Some(row) => { + let json: String = row.get("data"); + Ok(Some(ser::theme_from_json(&json)?)) + } + } + } + + pub(crate) async fn save_theme_impl( + &self, + theme: &ThemeConfig, + ) -> Result<(), SqliteConfigError> { + let json = ser::theme_to_json(theme)?; + + sqlx::query("INSERT OR REPLACE INTO theme (id, data) VALUES (1, ?)") + .bind(&json) + .execute(&self.pool) + .await + .map_err(SqliteConfigError::Sql)?; + + Ok(()) + } +} diff --git a/crates/adapters/config-sqlite/src/serialization/layout.rs b/crates/adapters/config-sqlite/src/serialization/layout.rs index c2ae529..bba9b78 100644 --- a/crates/adapters/config-sqlite/src/serialization/layout.rs +++ b/crates/adapters/config-sqlite/src/serialization/layout.rs @@ -1,5 +1,7 @@ use crate::error::SqliteConfigError; -use domain::{AlignItems, ContainerNode, Direction, JustifyContent, Layout, LayoutChild, LayoutNode, Sizing}; +use domain::{ + AlignItems, ContainerNode, Direction, JustifyContent, Layout, LayoutChild, LayoutNode, Sizing, +}; pub fn layout_to_json(layout: &Layout) -> Result { let v = node_to_json(&layout.root); @@ -37,6 +39,19 @@ fn node_to_json(node: &LayoutNode) -> serde_json::Value { "direction": match c.direction { Direction::Row => "row", Direction::Column => "column" }, "gap": c.gap, "padding": c.padding, + "justify_content": match c.justify_content { + JustifyContent::Start => "start", + JustifyContent::Center => "center", + JustifyContent::End => "end", + JustifyContent::SpaceBetween => "space_between", + JustifyContent::SpaceEvenly => "space_evenly", + }, + "align_items": match c.align_items { + AlignItems::Start => "start", + AlignItems::Center => "center", + AlignItems::End => "end", + AlignItems::Stretch => "stretch", + }, "children": children, }) } @@ -93,12 +108,26 @@ fn node_from_json(v: &serde_json::Value) -> Result, _>>()?; + let justify_content = match v["justify_content"].as_str() { + Some("center") => JustifyContent::Center, + Some("end") => JustifyContent::End, + Some("space_between") => JustifyContent::SpaceBetween, + Some("space_evenly") => JustifyContent::SpaceEvenly, + _ => JustifyContent::Start, + }; + let align_items = match v["align_items"].as_str() { + Some("center") => AlignItems::Center, + Some("end") => AlignItems::End, + Some("stretch") => AlignItems::Stretch, + _ => AlignItems::Start, + }; + Ok(LayoutNode::Container(ContainerNode { direction, gap, padding, - justify_content: JustifyContent::Start, - align_items: AlignItems::Stretch, + justify_content, + align_items, children, })) } diff --git a/crates/adapters/config-sqlite/src/serialization/mod.rs b/crates/adapters/config-sqlite/src/serialization/mod.rs index f2dab06..b6c1285 100644 --- a/crates/adapters/config-sqlite/src/serialization/mod.rs +++ b/crates/adapters/config-sqlite/src/serialization/mod.rs @@ -1,4 +1,5 @@ pub mod data_source; pub mod layout; pub mod preset; +pub mod theme; pub mod widget; diff --git a/crates/adapters/config-sqlite/src/serialization/theme.rs b/crates/adapters/config-sqlite/src/serialization/theme.rs new file mode 100644 index 0000000..e77f67f --- /dev/null +++ b/crates/adapters/config-sqlite/src/serialization/theme.rs @@ -0,0 +1,40 @@ +use crate::error::SqliteConfigError; +use domain::{ThemeColor, ThemeConfig}; + +pub fn theme_to_json(theme: &ThemeConfig) -> Result { + let v = serde_json::json!({ + "primary": color_to_json(&theme.primary), + "secondary": color_to_json(&theme.secondary), + "accent": color_to_json(&theme.accent), + "text": color_to_json(&theme.text), + "background": color_to_json(&theme.background), + }); + serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string())) +} + +pub fn theme_from_json(json: &str) -> Result { + let v: serde_json::Value = + serde_json::from_str(json).map_err(|e| SqliteConfigError::Serialization(e.to_string()))?; + let err = |msg: &str| SqliteConfigError::Serialization(msg.into()); + + Ok(ThemeConfig { + primary: color_from_json(&v["primary"]).map_err(|_| err("invalid primary"))?, + secondary: color_from_json(&v["secondary"]).map_err(|_| err("invalid secondary"))?, + accent: color_from_json(&v["accent"]).map_err(|_| err("invalid accent"))?, + text: color_from_json(&v["text"]).map_err(|_| err("invalid text"))?, + background: color_from_json(&v["background"]).map_err(|_| err("invalid background"))?, + }) +} + +fn color_to_json(c: &ThemeColor) -> serde_json::Value { + serde_json::json!({ "r": c.r, "g": c.g, "b": c.b }) +} + +fn color_from_json(v: &serde_json::Value) -> Result { + let err = |msg: &str| SqliteConfigError::Serialization(msg.into()); + Ok(ThemeColor { + r: v["r"].as_u64().ok_or_else(|| err("missing r"))? as u8, + g: v["g"].as_u64().ok_or_else(|| err("missing g"))? as u8, + b: v["b"].as_u64().ok_or_else(|| err("missing b"))? as u8, + }) +} diff --git a/crates/adapters/config-sqlite/tests/config_store_tests.rs b/crates/adapters/config-sqlite/tests/config_store_tests.rs index 68462db..334433b 100644 --- a/crates/adapters/config-sqlite/tests/config_store_tests.rs +++ b/crates/adapters/config-sqlite/tests/config_store_tests.rs @@ -1,8 +1,8 @@ use config_sqlite::SqliteConfigStore; use domain::{ AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType, - Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild, LayoutNode, - LayoutPreset, Sizing, WidgetConfig, + Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild, + LayoutNode, LayoutPreset, Sizing, WidgetConfig, }; use std::time::Duration; diff --git a/crates/adapters/http-api/src/routes/mod.rs b/crates/adapters/http-api/src/routes/mod.rs index eb106bb..c6b2598 100644 --- a/crates/adapters/http-api/src/routes/mod.rs +++ b/crates/adapters/http-api/src/routes/mod.rs @@ -3,6 +3,7 @@ mod clients; mod data_sources; mod layout; mod presets; +mod theme; mod webhook; mod widgets; @@ -70,6 +71,11 @@ where get(layout::get_layout::) .put(layout::update_layout::), ) + .route( + "/theme", + get(theme::get_theme::) + .put(theme::update_theme::), + ) .route( "/presets", get(presets::list_presets::) diff --git a/crates/adapters/http-api/src/routes/theme.rs b/crates/adapters/http-api/src/routes/theme.rs new file mode 100644 index 0000000..7179aa4 --- /dev/null +++ b/crates/adapters/http-api/src/routes/theme.rs @@ -0,0 +1,46 @@ +use crate::AppState; +use crate::extractors::AuthUser; +use api_types::ThemeDto; +use application::ConfigService; +use axum::{extract::State, http::StatusCode, response::Json}; +use domain::{ConfigRepository, EventPublisher}; + +type S = State>; + +pub async fn get_theme( + _auth: AuthUser, + State(state): S, +) -> Result, StatusCode> +where + C: ConfigRepository, + C::Error: std::fmt::Debug, + E: EventPublisher, + E::Error: std::fmt::Debug, +{ + let theme = state + .config + .get_theme() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .unwrap_or_default(); + Ok(Json(ThemeDto::from(&theme))) +} + +pub async fn update_theme( + _auth: AuthUser, + State(state): S, + Json(body): Json, +) -> Result +where + C: ConfigRepository, + C::Error: std::fmt::Debug, + E: EventPublisher, + E::Error: std::fmt::Debug, +{ + let theme = body.into_domain(); + let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); + svc.update_theme(theme) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; + Ok(StatusCode::OK) +} diff --git a/crates/adapters/tcp-server/src/broadcaster.rs b/crates/adapters/tcp-server/src/broadcaster.rs index bd61fbb..dcb96dd 100644 --- a/crates/adapters/tcp-server/src/broadcaster.rs +++ b/crates/adapters/tcp-server/src/broadcaster.rs @@ -1,6 +1,6 @@ use crate::error::TcpServerError; -use domain::{BroadcastPort, DisplayHint, Layout, WidgetId, WidgetState}; -use protocol::{ServerMessage, WidgetDescriptor, WireLayoutNode, encode}; +use domain::{BroadcastPort, DisplayHint, Layout, ThemeConfig, WidgetId, WidgetState}; +use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireLayoutNode, WireTheme, encode}; use tokio::sync::broadcast; pub struct TcpBroadcaster { @@ -70,4 +70,26 @@ impl BroadcastPort for TcpBroadcaster { let frame = encode(&msg).map_err(TcpServerError::Encode)?; self.send_frame(frame) } + + async fn push_theme_update(&self, theme: &ThemeConfig) -> Result<(), Self::Error> { + let wire_theme = domain_theme_to_wire(theme); + let msg = ServerMessage::ThemeUpdate { theme: wire_theme }; + let frame = encode(&msg).map_err(TcpServerError::Encode)?; + self.send_frame(frame) + } +} + +pub(crate) fn domain_theme_to_wire(t: &ThemeConfig) -> WireTheme { + let c = |c: &domain::ThemeColor| WireColor { + r: c.r, + g: c.g, + b: c.b, + }; + WireTheme { + primary: c(&t.primary), + secondary: c(&t.secondary), + accent: c(&t.accent), + text: c(&t.text), + background: c(&t.background), + } } diff --git a/crates/adapters/tcp-server/src/server.rs b/crates/adapters/tcp-server/src/server.rs index dcdae7c..f75fed0 100644 --- a/crates/adapters/tcp-server/src/server.rs +++ b/crates/adapters/tcp-server/src/server.rs @@ -1,3 +1,4 @@ +use crate::broadcaster::domain_theme_to_wire; use crate::client_tracker::ClientTracker; use crate::error::TcpServerError; use domain::{ConfigRepository, WidgetStateReader}; @@ -103,11 +104,24 @@ where widgets: wire_widgets, }; - match encode(&msg) { - Ok(frame) => Some(frame), + let mut combined = match encode(&msg) { + Ok(frame) => frame, Err(e) => { error!(error = %e, "failed to encode initial screen update"); - None + return None; + } + }; + + if let Ok(Some(theme)) = config.get_theme().await { + let wire_theme = domain_theme_to_wire(&theme); + let theme_msg = ServerMessage::ThemeUpdate { theme: wire_theme }; + match encode(&theme_msg) { + Ok(frame) => combined.extend_from_slice(&frame), + Err(e) => { + error!(error = %e, "failed to encode initial theme update"); + } } } + + Some(combined) } diff --git a/crates/api-types/src/layout.rs b/crates/api-types/src/layout.rs index 9e85f7e..51dd366 100644 --- a/crates/api-types/src/layout.rs +++ b/crates/api-types/src/layout.rs @@ -21,6 +21,10 @@ pub struct LayoutNodeDto { #[serde(skip_serializing_if = "Option::is_none")] pub padding: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub justify_content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub align_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub children: Option>, } @@ -44,6 +48,8 @@ impl From<&LayoutNode> for LayoutNodeDto { direction: None, gap: None, padding: None, + justify_content: None, + align_items: None, children: None, }, LayoutNode::Container(c) => Self { @@ -58,6 +64,25 @@ impl From<&LayoutNode> for LayoutNodeDto { ), gap: Some(c.gap), padding: Some(c.padding), + justify_content: Some( + match c.justify_content { + JustifyContent::Start => "start", + JustifyContent::Center => "center", + JustifyContent::End => "end", + JustifyContent::SpaceBetween => "space_between", + JustifyContent::SpaceEvenly => "space_evenly", + } + .into(), + ), + align_items: Some( + match c.align_items { + AlignItems::Start => "start", + AlignItems::Center => "center", + AlignItems::End => "end", + AlignItems::Stretch => "stretch", + } + .into(), + ), children: Some( c.children .iter() @@ -109,12 +134,26 @@ impl LayoutNodeDto { }) .collect::, _>>()?; + let justify_content = match self.justify_content.as_deref() { + Some("center") => JustifyContent::Center, + Some("end") => JustifyContent::End, + Some("space_between") => JustifyContent::SpaceBetween, + Some("space_evenly") => JustifyContent::SpaceEvenly, + _ => JustifyContent::Start, + }; + let align_items = match self.align_items.as_deref() { + Some("center") => AlignItems::Center, + Some("end") => AlignItems::End, + Some("stretch") => AlignItems::Stretch, + _ => AlignItems::Start, + }; + Ok(LayoutNode::Container(ContainerNode { direction, gap: self.gap.unwrap_or(0), padding: self.padding.unwrap_or(0), - justify_content: JustifyContent::Start, - align_items: AlignItems::Stretch, + justify_content, + align_items, children, })) } diff --git a/crates/api-types/src/lib.rs b/crates/api-types/src/lib.rs index 5a470a5..9dac267 100644 --- a/crates/api-types/src/lib.rs +++ b/crates/api-types/src/lib.rs @@ -2,10 +2,12 @@ pub mod client; pub mod data_source; pub mod layout; pub mod preset; +pub mod theme; pub mod widget; pub use client::ClientDto; pub use data_source::DataSourceDto; pub use layout::{LayoutChildDto, LayoutDto, LayoutNodeDto, SizingDto}; pub use preset::{CreatePresetDto, PresetDto}; +pub use theme::{ColorDto, ThemeDto}; pub use widget::{CreateWidgetDto, KeyMappingDto, WidgetDto}; diff --git a/crates/api-types/src/theme.rs b/crates/api-types/src/theme.rs new file mode 100644 index 0000000..dddbaea --- /dev/null +++ b/crates/api-types/src/theme.rs @@ -0,0 +1,52 @@ +use domain::{ThemeColor, ThemeConfig}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct ColorDto { + pub r: u8, + pub g: u8, + pub b: u8, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ThemeDto { + pub primary: ColorDto, + pub secondary: ColorDto, + pub accent: ColorDto, + pub text: ColorDto, + pub background: ColorDto, +} + +impl From<&ThemeConfig> for ThemeDto { + fn from(t: &ThemeConfig) -> Self { + let c = |c: &ThemeColor| ColorDto { + r: c.r, + g: c.g, + b: c.b, + }; + Self { + primary: c(&t.primary), + secondary: c(&t.secondary), + accent: c(&t.accent), + text: c(&t.text), + background: c(&t.background), + } + } +} + +impl ThemeDto { + pub fn into_domain(self) -> ThemeConfig { + let c = |c: ColorDto| ThemeColor { + r: c.r, + g: c.g, + b: c.b, + }; + ThemeConfig { + primary: c(self.primary), + secondary: c(self.secondary), + accent: c(self.accent), + text: c(self.text), + background: c(self.background), + } + } +} diff --git a/crates/application/src/config_service.rs b/crates/application/src/config_service.rs index 1cd6cc5..6fac0f0 100644 --- a/crates/application/src/config_service.rs +++ b/crates/application/src/config_service.rs @@ -1,6 +1,6 @@ use domain::{ ConfigRepository, DataSource, DataSourceId, DataSourceValidationError, DomainEvent, - EventPublisher, Layout, LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId, + EventPublisher, Layout, LayoutPreset, LayoutPresetId, ThemeConfig, WidgetConfig, WidgetId, }; use std::fmt; @@ -142,6 +142,21 @@ where Ok(()) } + pub async fn update_theme( + &self, + theme: ThemeConfig, + ) -> Result<(), ConfigError> { + self.config + .save_theme(&theme) + .await + .map_err(ConfigError::Repository)?; + self.events + .publish(DomainEvent::ThemeChanged { theme }) + .await + .map_err(ConfigError::Event)?; + Ok(()) + } + pub async fn save_preset( &self, preset: LayoutPreset, diff --git a/crates/application/tests/config_service_tests.rs b/crates/application/tests/config_service_tests.rs index b4fbc48..025445d 100644 --- a/crates/application/tests/config_service_tests.rs +++ b/crates/application/tests/config_service_tests.rs @@ -3,8 +3,8 @@ mod support; use application::ConfigService; use domain::{ AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType, - Direction, DisplayHint, DisplayHintKind, DomainEvent, JustifyContent, KeyMapping, Layout, LayoutChild, - LayoutNode, LayoutPreset, Sizing, WidgetConfig, + Direction, DisplayHint, DisplayHintKind, DomainEvent, JustifyContent, KeyMapping, Layout, + LayoutChild, LayoutNode, LayoutPreset, Sizing, WidgetConfig, }; use std::time::Duration; use support::{InMemoryConfigRepository, InMemoryEventPublisher}; diff --git a/crates/application/tests/support/mod.rs b/crates/application/tests/support/mod.rs index f781f3d..d267f20 100644 --- a/crates/application/tests/support/mod.rs +++ b/crates/application/tests/support/mod.rs @@ -1,6 +1,6 @@ use domain::{ ConfigRepository, DataSource, DataSourceId, DomainEvent, EventPublisher, Layout, LayoutPreset, - LayoutPresetId, User, WidgetConfig, WidgetId, + LayoutPresetId, ThemeConfig, User, WidgetConfig, WidgetId, }; use std::collections::HashMap; use std::sync::Mutex; @@ -9,6 +9,7 @@ pub struct InMemoryConfigRepository { widgets: Mutex>, data_sources: Mutex>, layout: Mutex>, + theme: Mutex>, presets: Mutex>, } @@ -18,6 +19,7 @@ impl InMemoryConfigRepository { widgets: Mutex::new(HashMap::new()), data_sources: Mutex::new(HashMap::new()), layout: Mutex::new(None), + theme: Mutex::new(None), presets: Mutex::new(HashMap::new()), } } @@ -92,6 +94,15 @@ impl ConfigRepository for InMemoryConfigRepository { Ok(()) } + async fn get_theme(&self) -> Result, Self::Error> { + Ok(self.theme.lock().unwrap().clone()) + } + + async fn save_theme(&self, theme: &ThemeConfig) -> Result<(), Self::Error> { + *self.theme.lock().unwrap() = Some(theme.clone()); + Ok(()) + } + async fn get_preset(&self, id: LayoutPresetId) -> Result, Self::Error> { Ok(self.presets.lock().unwrap().get(&id).cloned()) } diff --git a/crates/bootstrap/src/event_handler.rs b/crates/bootstrap/src/event_handler.rs index eb6ead6..4b2f8a5 100644 --- a/crates/bootstrap/src/event_handler.rs +++ b/crates/bootstrap/src/event_handler.rs @@ -40,6 +40,12 @@ pub async fn run( info!("layout changed, pushed screen update to clients"); } + 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)) => { warn!(skipped = n, "event handler lagged, missed events"); diff --git a/crates/client-application/src/client_app.rs b/crates/client-application/src/client_app.rs index 024e69e..2747383 100644 --- a/crates/client-application/src/client_app.rs +++ b/crates/client-application/src/client_app.rs @@ -1,7 +1,7 @@ use client_domain::{BoundingBox, Color, LayoutEngine, RenderTree, ThemeConfig}; use domain::LayoutNode; use protocol::{ - ServerMessage, WireColor, WidgetDescriptor, WireDisplayHint, WireLayoutNode, WireWidgetState, + ServerMessage, WidgetDescriptor, WireColor, WireDisplayHint, WireLayoutNode, WireWidgetState, }; use std::collections::HashMap; diff --git a/crates/client-desktop/src/main.rs b/crates/client-desktop/src/main.rs index 7ee93a4..8e0cf89 100644 --- a/crates/client-desktop/src/main.rs +++ b/crates/client-desktop/src/main.rs @@ -1,4 +1,5 @@ use client_application::ClientApp; +use client_domain::NetworkPort; use client_domain::{BoundingBox, DisplayPort, FontMetrics, RenderEngine, ThemeConfig}; use display_terminal::TerminalDisplay; use domain::DisplayHint; @@ -7,7 +8,6 @@ use std::sync::mpsc; use std::thread; use std::time::Duration; use tcp_client::StdTcpClient; -use client_domain::NetworkPort; fn main() { let screen = BoundingBox::screen(240, 320); @@ -78,14 +78,18 @@ fn main() { display.fill_rect(cmd.bounds, bg).unwrap(); let hint: DisplayHint = cmd.display_hint.clone().into(); - let data: Vec<(String, domain::Value)> = cmd.state.data + let data: Vec<(String, domain::Value)> = cmd + .state + .data .iter() .map(|kv| (kv.key.clone(), kv.value.clone().into())) .collect(); let draw_cmds = engine.render_widget(&hint, &data, cmd.bounds, 0); for dc in &draw_cmds { - 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(); } } display.flush().unwrap(); diff --git a/crates/client-domain/src/lib.rs b/crates/client-domain/src/lib.rs index 1e7d8bc..e314594 100644 --- a/crates/client-domain/src/lib.rs +++ b/crates/client-domain/src/lib.rs @@ -12,14 +12,14 @@ mod text_layout; mod theme; pub use alignment::align_offset; -pub use domain::{AlignItems, DisplayHintKind, HAlign, JustifyContent, VAlign}; pub use bounding_box::BoundingBox; pub use color::Color; +pub use domain::{AlignItems, DisplayHintKind, HAlign, JustifyContent, VAlign}; pub use font::{FontMetrics, FontSize}; pub use layout_engine::LayoutEngine; -pub use markup::{parse_markup, TextSpan}; -pub use render_engine::{DrawCommand, RenderEngine}; +pub use markup::{TextSpan, parse_markup}; pub use ports::{ClientConfig, DisplayPort, NetworkPort, StoragePort}; +pub use render_engine::{DrawCommand, RenderEngine}; pub use render_tree::RenderTree; pub use scroll::ScrollState; pub use text_layout::wrap_lines; diff --git a/crates/client-domain/src/markup.rs b/crates/client-domain/src/markup.rs index c269596..a492dfd 100644 --- a/crates/client-domain/src/markup.rs +++ b/crates/client-domain/src/markup.rs @@ -28,7 +28,10 @@ pub fn parse_markup(input: &str, theme: &ThemeConfig) -> Vec { if let Some(new_color) = resolve_tag(&tag, theme) { if !current_text.is_empty() { - spans.push(TextSpan { text: current_text.clone(), color: current_color }); + spans.push(TextSpan { + text: current_text.clone(), + color: current_color, + }); current_text.clear(); } current_color = new_color; @@ -39,7 +42,10 @@ pub fn parse_markup(input: &str, theme: &ThemeConfig) -> Vec { } if !current_text.is_empty() { - spans.push(TextSpan { text: current_text, color: current_color }); + spans.push(TextSpan { + text: current_text, + color: current_color, + }); } spans diff --git a/crates/client-domain/src/render_engine.rs b/crates/client-domain/src/render_engine.rs index 73068d7..b183262 100644 --- a/crates/client-domain/src/render_engine.rs +++ b/crates/client-domain/src/render_engine.rs @@ -1,6 +1,6 @@ use crate::{ - BoundingBox, Color, FontMetrics, FontSize, ThemeConfig, - alignment::align_offset, markup::parse_markup, text_layout::wrap_lines, + BoundingBox, Color, FontMetrics, FontSize, ThemeConfig, alignment::align_offset, + markup::parse_markup, text_layout::wrap_lines, }; use domain::{DisplayHint, DisplayHintKind, HAlign, VAlign, Value}; @@ -62,7 +62,8 @@ impl RenderEngine { while char_pos < line_end { let (color, span_end) = self.color_at(&spans, char_pos, line_end, &plain); let segment = &plain[char_pos..span_end]; - let seg_offset = (char_pos - line_start) as u16 * self.metrics.char_width(FontSize::Small); + let seg_offset = + (char_pos - line_start) as u16 * self.metrics.char_width(FontSize::Small); cmds.push(DrawCommand { text: segment.to_string(), @@ -100,18 +101,16 @@ impl RenderEngine { cmd.y = cmd.y.saturating_sub(scroll_offset); } // Drop commands that scrolled above bounds - cmds.retain(|cmd| cmd.y + self.metrics.char_height(cmd.font) > bounds.y && cmd.y < bounds.y + bounds.height); + cmds.retain(|cmd| { + cmd.y + self.metrics.char_height(cmd.font) > bounds.y + && cmd.y < bounds.y + bounds.height + }); } cmds } - pub fn content_height( - &self, - hint: &DisplayHint, - data: &[(String, Value)], - width: u16, - ) -> u16 { + pub fn content_height(&self, hint: &DisplayHint, data: &[(String, Value)], width: u16) -> u16 { let text = self.format_widget(hint, data); let plain: String = parse_markup(&text, &self.theme) .iter() @@ -123,35 +122,33 @@ impl RenderEngine { fn format_widget(&self, hint: &DisplayHint, data: &[(String, Value)]) -> String { match hint.kind { - DisplayHintKind::TextBlock => { - data.iter() - .filter_map(|(_, v)| value_to_string(v)) - .collect::>() - .join("\n") - } - DisplayHintKind::KeyValue => { - data.iter() - .filter_map(|(k, v)| { - let val = value_to_string(v)?; - Some(format!("{{secondary}}{k}{{/}}: {val}")) - }) - .collect::>() - .join("\n") - } + DisplayHintKind::TextBlock => data + .iter() + .filter_map(|(_, v)| value_to_string(v)) + .collect::>() + .join("\n"), + DisplayHintKind::KeyValue => data + .iter() + .filter_map(|(k, v)| { + let val = value_to_string(v)?; + Some(format!("{{primary}}{k}{{/}}: {val}")) + }) + .collect::>() + .join("\n"), DisplayHintKind::IconValue => { let mut parts = Vec::new(); for (k, v) in data { - if k == "icon" { - if let Some(s) = value_to_string(v) { - parts.push(s); - } + if k == "icon" + && let Some(s) = value_to_string(v) + { + parts.push(s); } } for (k, v) in data { - if k != "icon" { - if let Some(s) = value_to_string(v) { - parts.push(s); - } + if k != "icon" + && let Some(s) = value_to_string(v) + { + parts.push(s); } } parts.join(" ") @@ -187,7 +184,11 @@ fn value_to_string(v: &Value) -> Option { Value::Null => None, Value::Array(arr) => { let items: Vec = arr.iter().filter_map(value_to_string).collect(); - if items.is_empty() { None } else { Some(items.join(", ")) } + if items.is_empty() { + None + } else { + Some(items.join(", ")) + } } Value::Object(_) => None, } diff --git a/crates/client-domain/src/text_layout.rs b/crates/client-domain/src/text_layout.rs index db4f393..0a1eb00 100644 --- a/crates/client-domain/src/text_layout.rs +++ b/crates/client-domain/src/text_layout.rs @@ -1,6 +1,11 @@ use crate::{FontMetrics, FontSize}; -pub fn wrap_lines<'a>(text: &'a str, max_width: u16, font: FontSize, metrics: &FontMetrics) -> Vec<&'a str> { +pub fn wrap_lines<'a>( + text: &'a str, + max_width: u16, + font: FontSize, + metrics: &FontMetrics, +) -> Vec<&'a str> { if text.is_empty() { return Vec::new(); } @@ -17,7 +22,9 @@ pub fn wrap_lines<'a>(text: &'a str, max_width: u16, font: FontSize, metrics: &F let mut line_end = 0; for word_start in WordStarts::new(text) { - let word_end = text[word_start..].find(' ').map_or(text.len(), |i| word_start + i); + let word_end = text[word_start..] + .find(' ') + .map_or(text.len(), |i| word_start + i); if line_start == line_end { // First word on this line @@ -83,7 +90,11 @@ struct WordStarts<'a> { impl<'a> WordStarts<'a> { fn new(text: &'a str) -> Self { - Self { text, pos: 0, started: false } + Self { + text, + pos: 0, + started: false, + } } } diff --git a/crates/client-domain/tests/layout_engine_tests.rs b/crates/client-domain/tests/layout_engine_tests.rs index b51d92e..d134cab 100644 --- a/crates/client-domain/tests/layout_engine_tests.rs +++ b/crates/client-domain/tests/layout_engine_tests.rs @@ -1,5 +1,7 @@ use client_domain::{BoundingBox, LayoutEngine, RenderTree}; -use domain::{AlignItems, ContainerNode, Direction, JustifyContent, LayoutChild, LayoutNode, Sizing}; +use domain::{ + AlignItems, ContainerNode, Direction, JustifyContent, LayoutChild, LayoutNode, Sizing, +}; fn screen() -> BoundingBox { BoundingBox::screen(240, 320) @@ -230,8 +232,14 @@ fn justify_center_centers_fixed_children_on_main_axis() { }); let tree = LayoutEngine::compute(&layout, screen()); - assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(80, 0, 40, 320))); - assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(120, 0, 40, 320))); + assert_eq!( + tree.get_widget_bounds(1), + Some(&BoundingBox::new(80, 0, 40, 320)) + ); + assert_eq!( + tree.get_widget_bounds(2), + Some(&BoundingBox::new(120, 0, 40, 320)) + ); } #[test] @@ -247,7 +255,10 @@ fn justify_end_pushes_to_end() { }); let tree = LayoutEngine::compute(&layout, screen()); - assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(200, 0, 40, 320))); + assert_eq!( + tree.get_widget_bounds(1), + Some(&BoundingBox::new(200, 0, 40, 320)) + ); } #[test] @@ -263,9 +274,18 @@ fn justify_space_between_distributes_gaps() { }); let tree = LayoutEngine::compute(&layout, screen()); - assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 40, 320))); - assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(100, 0, 40, 320))); - assert_eq!(tree.get_widget_bounds(3), Some(&BoundingBox::new(200, 0, 40, 320))); + assert_eq!( + tree.get_widget_bounds(1), + Some(&BoundingBox::new(0, 0, 40, 320)) + ); + assert_eq!( + tree.get_widget_bounds(2), + Some(&BoundingBox::new(100, 0, 40, 320)) + ); + assert_eq!( + tree.get_widget_bounds(3), + Some(&BoundingBox::new(200, 0, 40, 320)) + ); } #[test] @@ -282,8 +302,14 @@ fn justify_space_evenly_distributes_with_edges() { let tree = LayoutEngine::compute(&layout, screen()); // 160 / 3 = 53px per slot - assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(53, 0, 40, 320))); - assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(146, 0, 40, 320))); + assert_eq!( + tree.get_widget_bounds(1), + Some(&BoundingBox::new(53, 0, 40, 320)) + ); + assert_eq!( + tree.get_widget_bounds(2), + Some(&BoundingBox::new(146, 0, 40, 320)) + ); } // --- AlignItems tests --- @@ -315,5 +341,8 @@ fn align_items_center_centers_on_cross_axis() { let tree = LayoutEngine::compute(&layout, screen()); // Stretch: child gets full cross-axis height - assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 40, 320))); + assert_eq!( + tree.get_widget_bounds(1), + Some(&BoundingBox::new(0, 0, 40, 320)) + ); } diff --git a/crates/client-domain/tests/markup_tests.rs b/crates/client-domain/tests/markup_tests.rs index 02e97b6..4f138ad 100644 --- a/crates/client-domain/tests/markup_tests.rs +++ b/crates/client-domain/tests/markup_tests.rs @@ -7,39 +7,73 @@ fn theme() -> ThemeConfig { #[test] fn plain_text_produces_single_span() { let spans = parse_markup("hello world", &theme()); - assert_eq!(spans, vec![ - TextSpan { text: "hello world".into(), color: theme().text }, - ]); + assert_eq!( + spans, + vec![TextSpan { + text: "hello world".into(), + color: theme().text + },] + ); } #[test] fn hex_color_span() { let spans = parse_markup("temp: {#FF0000}72°F{/}", &theme()); - assert_eq!(spans, vec![ - TextSpan { text: "temp: ".into(), color: theme().text }, - TextSpan { text: "72°F".into(), color: Color(0xFF, 0, 0) }, - ]); + assert_eq!( + spans, + vec![ + TextSpan { + text: "temp: ".into(), + color: theme().text + }, + TextSpan { + text: "72°F".into(), + color: Color(0xFF, 0, 0) + }, + ] + ); } #[test] fn theme_color_spans() { let t = theme(); let spans = parse_markup("{primary}hello{/} {accent}world{/}", &t); - assert_eq!(spans, vec![ - TextSpan { text: "hello".into(), color: t.primary }, - TextSpan { text: " ".into(), color: t.text }, - TextSpan { text: "world".into(), color: t.accent }, - ]); + assert_eq!( + spans, + vec![ + TextSpan { + text: "hello".into(), + color: t.primary + }, + TextSpan { + text: " ".into(), + color: t.text + }, + TextSpan { + text: "world".into(), + color: t.accent + }, + ] + ); } #[test] fn reset_returns_to_text_color() { let t = theme(); let spans = parse_markup("{accent}hi{/}bye", &t); - assert_eq!(spans, vec![ - TextSpan { text: "hi".into(), color: t.accent }, - TextSpan { text: "bye".into(), color: t.text }, - ]); + assert_eq!( + spans, + vec![ + TextSpan { + text: "hi".into(), + color: t.accent + }, + TextSpan { + text: "bye".into(), + color: t.text + }, + ] + ); } #[test] @@ -52,16 +86,29 @@ fn empty_input_produces_no_spans() { fn adjacent_color_spans_no_text_between() { let t = theme(); let spans = parse_markup("{primary}a{secondary}b{/}", &t); - assert_eq!(spans, vec![ - TextSpan { text: "a".into(), color: t.primary }, - TextSpan { text: "b".into(), color: t.secondary }, - ]); + assert_eq!( + spans, + vec![ + TextSpan { + text: "a".into(), + color: t.primary + }, + TextSpan { + text: "b".into(), + color: t.secondary + }, + ] + ); } #[test] fn unknown_tag_treated_as_literal() { let spans = parse_markup("{unknown}text", &theme()); - assert_eq!(spans, vec![ - TextSpan { text: "text".into(), color: theme().text }, - ]); + assert_eq!( + spans, + vec![TextSpan { + text: "text".into(), + color: theme().text + },] + ); } diff --git a/crates/client-domain/tests/render_engine_tests.rs b/crates/client-domain/tests/render_engine_tests.rs index 4768ef1..876deb2 100644 --- a/crates/client-domain/tests/render_engine_tests.rs +++ b/crates/client-domain/tests/render_engine_tests.rs @@ -1,6 +1,6 @@ use client_domain::{ - BoundingBox, Color, DrawCommand, FontMetrics, FontSize, HAlign, RenderEngine, - ThemeConfig, VAlign, + BoundingBox, Color, DrawCommand, FontMetrics, FontSize, HAlign, RenderEngine, ThemeConfig, + VAlign, }; fn metrics() -> FontMetrics { diff --git a/crates/client-domain/tests/render_tree_tests.rs b/crates/client-domain/tests/render_tree_tests.rs index 7bae4e0..93d8719 100644 --- a/crates/client-domain/tests/render_tree_tests.rs +++ b/crates/client-domain/tests/render_tree_tests.rs @@ -1,5 +1,7 @@ use client_domain::{BoundingBox, LayoutEngine}; -use domain::{AlignItems, ContainerNode, Direction, JustifyContent, LayoutChild, LayoutNode, Sizing}; +use domain::{ + AlignItems, ContainerNode, Direction, JustifyContent, LayoutChild, LayoutNode, Sizing, +}; fn screen() -> BoundingBox { BoundingBox::screen(240, 320) diff --git a/crates/client-esp32/src/tasks/render.rs b/crates/client-esp32/src/tasks/render.rs index ab9f0ed..99ac912 100644 --- a/crates/client-esp32/src/tasks/render.rs +++ b/crates/client-esp32/src/tasks/render.rs @@ -44,11 +44,12 @@ pub fn run( let is_screen_update = matches!(msg, ServerMessage::ScreenUpdate { .. }); let repaints = app.handle_message(msg); - if app.take_theme_changed() { + let theme_changed = app.take_theme_changed(); + if theme_changed { engine.set_theme(app.theme().clone()); } - if !repaints.is_empty() && (first_update || is_screen_update) { + if !repaints.is_empty() && (first_update || is_screen_update || theme_changed) { let bg = engine.theme().background; display.fill_rect(screen, bg).unwrap(); first_update = false; diff --git a/crates/domain/src/events/mod.rs b/crates/domain/src/events/mod.rs index 05d6e6b..b37aef9 100644 --- a/crates/domain/src/events/mod.rs +++ b/crates/domain/src/events/mod.rs @@ -1,5 +1,5 @@ use crate::entities::{DataSourceId, LayoutPresetId, WidgetId}; -use crate::value_objects::Layout; +use crate::value_objects::{Layout, ThemeConfig}; #[derive(Debug, Clone)] pub enum DomainEvent { @@ -10,6 +10,7 @@ pub enum DomainEvent { DataSourceUpdated { id: DataSourceId }, DataSourceRemoved { id: DataSourceId }, LayoutChanged { layout: Layout }, + ThemeChanged { theme: ThemeConfig }, LayoutPresetSaved { id: LayoutPresetId }, LayoutPresetLoaded { id: LayoutPresetId }, LayoutPresetDeleted { id: LayoutPresetId }, diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 1bf22bf..18a4224 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -16,6 +16,6 @@ pub use ports::{ }; pub use value_objects::{ AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent, - KeyMapping, Layout, LayoutChild, LayoutNode, LayoutValidationError, Sizing, VAlign, Value, - WidgetError, WidgetState, + KeyMapping, Layout, LayoutChild, LayoutNode, LayoutValidationError, Sizing, ThemeColor, + ThemeConfig, VAlign, Value, WidgetError, WidgetState, }; diff --git a/crates/domain/src/ports/broadcast.rs b/crates/domain/src/ports/broadcast.rs index 2c4cd18..50f9fb5 100644 --- a/crates/domain/src/ports/broadcast.rs +++ b/crates/domain/src/ports/broadcast.rs @@ -1,5 +1,5 @@ use crate::entities::WidgetId; -use crate::value_objects::{DisplayHint, Layout, WidgetState}; +use crate::value_objects::{DisplayHint, Layout, ThemeConfig, WidgetState}; use std::future::Future; pub trait BroadcastPort { @@ -15,4 +15,9 @@ pub trait BroadcastPort { &self, updates: &[(WidgetId, DisplayHint, WidgetState)], ) -> impl Future> + Send; + + fn push_theme_update( + &self, + theme: &ThemeConfig, + ) -> impl Future> + Send; } diff --git a/crates/domain/src/ports/config_repository.rs b/crates/domain/src/ports/config_repository.rs index 27842b9..6a53966 100644 --- a/crates/domain/src/ports/config_repository.rs +++ b/crates/domain/src/ports/config_repository.rs @@ -1,7 +1,7 @@ use crate::entities::{ DataSource, DataSourceId, LayoutPreset, LayoutPresetId, User, WidgetConfig, WidgetId, }; -use crate::value_objects::Layout; +use crate::value_objects::{Layout, ThemeConfig}; use std::future::Future; pub trait ConfigRepository { @@ -51,6 +51,12 @@ pub trait ConfigRepository { id: LayoutPresetId, ) -> impl Future> + Send; + fn get_theme(&self) -> impl Future, Self::Error>> + Send; + fn save_theme( + &self, + theme: &ThemeConfig, + ) -> impl Future> + Send; + fn get_user_by_username( &self, username: &str, diff --git a/crates/domain/src/value_objects/mod.rs b/crates/domain/src/value_objects/mod.rs index 921892d..592f54d 100644 --- a/crates/domain/src/value_objects/mod.rs +++ b/crates/domain/src/value_objects/mod.rs @@ -1,5 +1,6 @@ mod key_mapping; mod layout; +mod theme; mod value; mod widget_state; @@ -8,5 +9,6 @@ pub use layout::{ AlignItems, ContainerNode, Direction, JustifyContent, Layout, LayoutChild, LayoutNode, LayoutValidationError, Sizing, }; +pub use theme::{ThemeColor, ThemeConfig}; pub use value::Value; pub use widget_state::{DisplayHint, DisplayHintKind, HAlign, VAlign, WidgetError, WidgetState}; diff --git a/crates/domain/src/value_objects/theme.rs b/crates/domain/src/value_objects/theme.rs new file mode 100644 index 0000000..5c6673d --- /dev/null +++ b/crates/domain/src/value_objects/theme.rs @@ -0,0 +1,47 @@ +#[derive(Debug, Clone, PartialEq)] +pub struct ThemeColor { + pub r: u8, + pub g: u8, + pub b: u8, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ThemeConfig { + pub primary: ThemeColor, + pub secondary: ThemeColor, + pub accent: ThemeColor, + pub text: ThemeColor, + pub background: ThemeColor, +} + +impl Default for ThemeConfig { + fn default() -> Self { + Self { + primary: ThemeColor { + r: 0x00, + g: 0x7A, + b: 0xCC, + }, + secondary: ThemeColor { + r: 0x88, + g: 0x88, + b: 0x88, + }, + accent: ThemeColor { + r: 0xE9, + g: 0x45, + b: 0x60, + }, + text: ThemeColor { + r: 0xFF, + g: 0xFF, + b: 0xFF, + }, + background: ThemeColor { + r: 0x00, + g: 0x00, + b: 0x00, + }, + } + } +} diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index a6038e9..704407c 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -2,7 +2,7 @@ mod frame; mod wire; pub use frame::{ - ClientMessage, MAX_FRAME_SIZE, ServerMessage, WireColor, WireTheme, WidgetDescriptor, + ClientMessage, MAX_FRAME_SIZE, ServerMessage, WidgetDescriptor, WireColor, WireTheme, decode_client_message, decode_server_message, encode, encode_client, }; pub use wire::{ diff --git a/crates/protocol/tests/round_trip_tests.rs b/crates/protocol/tests/round_trip_tests.rs index 5b1ca63..1196830 100644 --- a/crates/protocol/tests/round_trip_tests.rs +++ b/crates/protocol/tests/round_trip_tests.rs @@ -1,8 +1,8 @@ use protocol::{ ClientMessage, ServerMessage, WidgetDescriptor, WireAlignItems, WireContainerNode, WireDirection, WireDisplayHint, WireDisplayHintKind, WireJustifyContent, WireKeyValue, - WireLayoutChild, WireLayoutNode, WireSizing, WireValue, WireWidgetState, - decode_client_message, decode_server_message, encode, encode_client, + WireLayoutChild, WireLayoutNode, WireSizing, WireValue, WireWidgetState, decode_client_message, + decode_server_message, encode, encode_client, }; #[test] diff --git a/spa/src/api/theme.ts b/spa/src/api/theme.ts new file mode 100644 index 0000000..a47d9e3 --- /dev/null +++ b/spa/src/api/theme.ts @@ -0,0 +1,22 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { api } from "./client" +import type { ThemeConfig } from "./types" + +const KEYS = { + current: ["theme"] as const, +} + +export function useTheme() { + return useQuery({ + queryKey: KEYS.current, + queryFn: () => api.get("/theme"), + }) +} + +export function useUpdateTheme() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (theme: ThemeConfig) => api.put("/theme", theme), + onSuccess: () => qc.invalidateQueries({ queryKey: KEYS.current }), + }) +} diff --git a/spa/src/api/types.ts b/spa/src/api/types.ts index 67bc4bd..fe44303 100644 --- a/spa/src/api/types.ts +++ b/spa/src/api/types.ts @@ -2,6 +2,8 @@ export type DisplayHint = "icon_value" | "text_block" | "key_value" export type SourceType = "weather" | "media" | "rss" | "http_json" | "webhook" export type SizingType = "fixed" | "flex" export type Direction = "row" | "column" +export type JustifyContent = "start" | "center" | "end" | "space_between" | "space_evenly" +export type AlignItemsType = "start" | "center" | "end" | "stretch" export interface KeyMapping { source_path: string @@ -40,6 +42,8 @@ export interface LayoutNode { direction?: Direction gap?: number padding?: number + justify_content?: JustifyContent + align_items?: AlignItemsType children?: LayoutChild[] } @@ -62,3 +66,17 @@ export interface ClientInfo { addr: string connected_at: number } + +export interface ThemeColor { + r: number + g: number + b: number +} + +export interface ThemeConfig { + primary: ThemeColor + secondary: ThemeColor + accent: ThemeColor + text: ThemeColor + background: ThemeColor +} diff --git a/spa/src/components/app-shell.tsx b/spa/src/components/app-shell.tsx index b76bd04..7e5c7e2 100644 --- a/spa/src/components/app-shell.tsx +++ b/spa/src/components/app-shell.tsx @@ -20,6 +20,7 @@ import { Database, Box, Layers, + Palette, Save, BookOpen, LogOut, @@ -30,6 +31,7 @@ const NAV = [ { to: "/data-sources", label: "Data Sources", icon: Database }, { to: "/widgets", label: "Widgets", icon: Box }, { to: "/layout", label: "Layout", icon: Layers }, + { to: "/theme", label: "Theme", icon: Palette }, { to: "/presets", label: "Presets", icon: Save }, { to: "/guide", label: "Guide", icon: BookOpen }, ] as const diff --git a/spa/src/components/layout-preview.tsx b/spa/src/components/layout-preview.tsx new file mode 100644 index 0000000..d496127 --- /dev/null +++ b/spa/src/components/layout-preview.tsx @@ -0,0 +1,103 @@ +import { useMemo, useRef } from "react" +import type { LayoutNode, ThemeConfig, Widget } from "@/api/types" +import { computeLayout } from "@/lib/layout-engine" + +interface LayoutPreviewProps { + layout: LayoutNode + screenWidth: number + screenHeight: number + theme: ThemeConfig + widgets: Widget[] +} + +function colorToCSS(c: { r: number; g: number; b: number }) { + return `rgb(${c.r},${c.g},${c.b})` +} + +function collectWidgetIds(node: LayoutNode): number[] { + if (node.type === "leaf") return node.widget_id !== undefined ? [node.widget_id] : [] + return (node.children ?? []).flatMap((c) => collectWidgetIds(c.node)) +} + +export function LayoutPreview({ + layout, + screenWidth, + screenHeight, + theme, + widgets, +}: LayoutPreviewProps) { + const containerRef = useRef(null) + + const bounds = useMemo( + () => computeLayout(layout, { x: 0, y: 0, width: screenWidth, height: screenHeight }), + [layout, screenWidth, screenHeight], + ) + + const widgetIds = useMemo(() => collectWidgetIds(layout), [layout]) + const maxWidth = 600 + const scale = Math.min(1, maxWidth / screenWidth) + + return ( +
+
+ {widgetIds.map((wid) => { + const box = bounds.get(wid) + if (!box) return null + const w = widgets.find((w) => w.id === wid) + return ( +
+ + {w?.name ?? `#${wid}`} + + {w && ( + + {w.display_hint} + + )} +
+ ) + })} +
+
+ ) +} diff --git a/spa/src/lib/layout-engine.ts b/spa/src/lib/layout-engine.ts new file mode 100644 index 0000000..726ed56 --- /dev/null +++ b/spa/src/lib/layout-engine.ts @@ -0,0 +1,93 @@ +import type { LayoutNode, LayoutChild } from "@/api/types" + +export interface BoundingBox { + x: number + y: number + width: number + height: number +} + +export function computeLayout( + layout: LayoutNode, + bounds: BoundingBox, +): Map { + const out = new Map() + computeNode(layout, bounds, out) + return out +} + +function computeNode( + node: LayoutNode, + bounds: BoundingBox, + out: Map, +) { + if (node.type === "leaf") { + if (node.widget_id !== undefined) { + out.set(node.widget_id, bounds) + } + } else { + computeContainer(node, bounds, out) + } +} + +function computeContainer( + container: LayoutNode, + bounds: BoundingBox, + out: Map, +) { + const pad = container.padding ?? 0 + const inner: BoundingBox = { + x: bounds.x + pad, + y: bounds.y + pad, + width: Math.max(0, bounds.width - pad * 2), + height: Math.max(0, bounds.height - pad * 2), + } + + const children = container.children ?? [] + if (children.length === 0) return + + const isRow = container.direction === "row" + const totalAxis = isRow ? inner.width : inner.height + const gap = container.gap ?? 0 + const totalGap = gap * Math.max(0, children.length - 1) + const available = Math.max(0, totalAxis - totalGap) + + let fixedTotal = 0 + let flexTotal = 0 + for (const child of children) { + if (child.sizing.type === "fixed") { + fixedTotal += child.sizing.value + } else { + flexTotal += child.sizing.value + } + } + + const flexSpace = Math.max(0, available - fixedTotal) + + const childSizes: number[] = children.map((child: LayoutChild) => { + if (child.sizing.type === "fixed") return child.sizing.value + if (flexTotal > 0) { + return Math.floor((flexSpace * child.sizing.value) / flexTotal) + } + return 0 + }) + + const childrenTotal = childSizes.reduce((a, b) => a + b, 0) + const remaining = Math.max(0, totalAxis - childrenTotal - totalGap) + + // Default justify: Start + let offset = 0 + let justifyGap = gap + // SpaceBetween / SpaceEvenly could be added here if justify_content is on the DTO + void remaining // unused for now, Start justification + + for (let i = 0; i < children.length; i++) { + const childSize = childSizes[i] + const childBounds: BoundingBox = isRow + ? { x: inner.x + offset, y: inner.y, width: childSize, height: inner.height } + : { x: inner.x, y: inner.y + offset, width: inner.width, height: childSize } + + computeNode(children[i].node, childBounds, out) + offset += childSize + justifyGap + } +} diff --git a/spa/src/pages/layout-builder.tsx b/spa/src/pages/layout-builder.tsx index eb8bd11..b1ec104 100644 --- a/spa/src/pages/layout-builder.tsx +++ b/spa/src/pages/layout-builder.tsx @@ -1,7 +1,9 @@ import { useState, useCallback } from "react" import { useLayout, useUpdateLayout } from "@/api/layout" import { useWidgets } from "@/api/widgets" +import { useTheme } from "@/api/theme" import type { LayoutNode, LayoutChild, Direction } from "@/api/types" +import { LayoutPreview } from "@/components/layout-preview" import { Button } from "@/components/ui/button" import { Card, @@ -37,6 +39,8 @@ import { Trash2, Save, GripVertical, + Eye, + EyeOff, } from "lucide-react" import { toast } from "sonner" @@ -115,12 +119,16 @@ function removeAtPath(root: LayoutNode, path: Path): LayoutNode { export function LayoutBuilderPage() { const { data: currentLayout, isLoading } = useLayout() const { data: widgets = [] } = useWidgets() + const { data: theme } = useTheme() const updateLayout = useUpdateLayout() const [root, setRoot] = useState(null) const [selected, setSelected] = useState(null) const [initialized, setInitialized] = useState(false) const [pendingDelete, setPendingDelete] = useState(null) + const [showPreview, setShowPreview] = useState(false) + const [screenWidth, setScreenWidth] = useState(320) + const [screenHeight, setScreenHeight] = useState(240) if (!initialized && currentLayout?.root) { setRoot(structuredClone(currentLayout.root)) @@ -170,7 +178,7 @@ export function LayoutBuilderPage() { function updateContainerProp( path: Path, - prop: "gap" | "padding" | "direction", + prop: "gap" | "padding" | "direction" | "justify_content" | "align_items", value: number | string, ) { setRoot((r) => @@ -223,10 +231,23 @@ export function LayoutBuilderPage() { Compose the display layout tree

- +
+ + +
@@ -328,6 +349,65 @@ export function LayoutBuilderPage() { + + {showPreview && root && theme && ( + + + Layout Preview + + +
+
+ + setScreenWidth(Number(e.target.value))} + className="w-20" + min={1} + /> +
+ x +
+ + setScreenHeight(Number(e.target.value))} + className="w-20" + min={1} + /> +
+
+ {([ + [320, 240], + [240, 135], + [128, 64], + ] as const).map(([w, h]) => ( + + ))} +
+
+ +
+
+ )}
) } @@ -420,7 +500,7 @@ function ContainerProps({ }: { node: LayoutNode path: Path - onUpdateProp: (path: Path, prop: "gap" | "padding" | "direction", value: number | string) => void + onUpdateProp: (path: Path, prop: "gap" | "padding" | "direction" | "justify_content" | "align_items", value: number | string) => void onAddWidget: (path: Path, widgetId: number) => void onAddContainer: (path: Path, direction: Direction) => void onRemove: () => void @@ -469,6 +549,43 @@ function ContainerProps({ /> +
+
+ + +
+
+ + +
+
{!isRoot && }
diff --git a/spa/src/pages/theme.tsx b/spa/src/pages/theme.tsx new file mode 100644 index 0000000..c666d6b --- /dev/null +++ b/spa/src/pages/theme.tsx @@ -0,0 +1,276 @@ +import { useState, useEffect } from "react" +import { useTheme, useUpdateTheme } from "@/api/theme" +import type { ThemeColor, ThemeConfig } from "@/api/types" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Badge } from "@/components/ui/badge" +import { Save } from "lucide-react" +import { toast } from "sonner" + +function colorToHex(c: ThemeColor): string { + const r = Math.min(255, Math.max(0, c.r)).toString(16).padStart(2, "0") + const g = Math.min(255, Math.max(0, c.g)).toString(16).padStart(2, "0") + const b = Math.min(255, Math.max(0, c.b)).toString(16).padStart(2, "0") + return `#${r}${g}${b}` +} + +function hexToColor(hex: string): ThemeColor | null { + const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i) + if (!m) return null + return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) } +} + +const PRESETS: Record = { + "Default Dark": { + primary: { r: 0, g: 122, b: 204 }, + secondary: { r: 136, g: 136, b: 136 }, + accent: { r: 233, g: 69, b: 96 }, + text: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + }, + Light: { + primary: { r: 0, g: 102, b: 170 }, + secondary: { r: 102, g: 102, b: 102 }, + accent: { r: 214, g: 59, b: 80 }, + text: { r: 26, g: 26, b: 26 }, + background: { r: 245, g: 245, b: 245 }, + }, + "Solarized Dark": { + primary: { r: 38, g: 139, b: 210 }, + secondary: { r: 131, g: 148, b: 150 }, + accent: { r: 203, g: 75, b: 22 }, + text: { r: 147, g: 161, b: 161 }, + background: { r: 0, g: 43, b: 54 }, + }, + Nord: { + primary: { r: 136, g: 192, b: 208 }, + secondary: { r: 129, g: 161, b: 193 }, + accent: { r: 191, g: 97, b: 106 }, + text: { r: 236, g: 239, b: 244 }, + background: { r: 46, g: 52, b: 64 }, + }, +} + +const COLOR_FIELDS: (keyof ThemeConfig)[] = [ + "primary", + "secondary", + "accent", + "text", + "background", +] + +function ColorPicker({ + label, + color, + onChange, +}: { + label: string + color: ThemeColor + onChange: (c: ThemeColor) => void +}) { + const hex = colorToHex(color) + return ( +
+ +
+ { + const c = hexToColor(e.target.value) + if (c) onChange(c) + }} + className="h-9 w-12 cursor-pointer rounded border p-0.5" + /> + { + const c = hexToColor(e.target.value) + if (c) onChange(c) + }} + className="font-mono" + maxLength={7} + /> +
+
+ ) +} + +export function ThemePage() { + const { data: serverTheme, isLoading } = useTheme() + const updateTheme = useUpdateTheme() + const [theme, setTheme] = useState(null) + const [initialized, setInitialized] = useState(false) + + useEffect(() => { + if (!initialized && serverTheme) { + setTheme(structuredClone(serverTheme)) + setInitialized(true) + } + }, [serverTheme, initialized]) + + function setColor(field: keyof ThemeConfig, c: ThemeColor) { + setTheme((t) => (t ? { ...t, [field]: c } : t)) + } + + function applyPreset(name: string) { + setTheme(structuredClone(PRESETS[name])) + } + + async function save() { + if (!theme) return + try { + await updateTheme.mutateAsync(theme) + toast.success("Theme saved & pushed to clients") + } catch (e) { + toast.error(String(e)) + } + } + + if (isLoading) return
Loading...
+ + if (!theme) { + return ( +
+
+

Theme

+

Configure display colors

+
+ + +

No theme configured. Pick a preset to start:

+
+ {Object.keys(PRESETS).map((name) => ( + + ))} +
+
+
+
+ ) + } + + return ( +
+
+
+

Theme

+

Configure display colors

+
+ +
+ +
+ {/* Color pickers */} + + + Colors + + + {COLOR_FIELDS.map((field) => ( + setColor(field, c)} + /> + ))} + + + + {/* Right column: presets + preview */} +
+ + + Presets + + + {Object.keys(PRESETS).map((name) => ( + + ))} + + + + + + Preview + + + {/* Color swatches */} +
+ {COLOR_FIELDS.map((field) => ( +
+
+ + {field} + +
+ ))} +
+ + {/* Sample display */} +
+

+ Primary Heading +

+

+ Sample text content displayed on the device. +

+
+ + Accent + + + Secondary + +
+
+ + +
+
+
+ ) +} diff --git a/spa/src/router.tsx b/spa/src/router.tsx index d21c1cb..201b72f 100644 --- a/spa/src/router.tsx +++ b/spa/src/router.tsx @@ -10,6 +10,7 @@ import { DashboardPage } from "@/pages/dashboard" import { DataSourcesPage } from "@/pages/data-sources" import { WidgetsPage } from "@/pages/widgets" import { LayoutBuilderPage } from "@/pages/layout-builder" +import { ThemePage } from "@/pages/theme" import { PresetsPage } from "@/pages/presets" import { GuidePage } from "@/pages/guide" import { LoginPage } from "@/pages/login" @@ -72,6 +73,12 @@ const layoutRoute = createRoute({ component: LayoutBuilderPage, }) +const themeRoute = createRoute({ + getParentRoute: () => authenticatedRoute, + path: "/theme", + component: ThemePage, +}) + const presetsRoute = createRoute({ getParentRoute: () => authenticatedRoute, path: "/presets", @@ -91,6 +98,7 @@ const routeTree = rootRoute.addChildren([ dataSourcesRoute, widgetsRoute, layoutRoute, + themeRoute, presetsRoute, guideRoute, ]),