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).
This commit is contained in:
2026-06-19 03:26:18 +02:00
parent 81a4167382
commit fe59b68c37
46 changed files with 1276 additions and 118 deletions

View File

@@ -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<HashMap<WidgetId, WidgetConfig>>,
data_sources: RwLock<HashMap<DataSourceId, DataSource>>,
layout: RwLock<Option<Layout>>,
theme: RwLock<Option<ThemeConfig>>,
presets: RwLock<HashMap<LayoutPresetId, LayoutPreset>>,
users: RwLock<Vec<User>>,
}
@@ -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<Option<ThemeConfig>, 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<Option<LayoutPreset>, Self::Error> {
let guard = self
.presets

View File

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

View File

@@ -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<Option<ThemeConfig>, 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<Option<User>, Self::Error> {
self.get_user_by_username_impl(username).await
}

View File

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

View File

@@ -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<String, SqliteConfigError> {
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<LayoutNode, SqliteConfigError
})
.collect::<Result<Vec<_>, _>>()?;
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,
}))
}

View File

@@ -1,4 +1,5 @@
pub mod data_source;
pub mod layout;
pub mod preset;
pub mod theme;
pub mod widget;

View File

@@ -0,0 +1,40 @@
use crate::error::SqliteConfigError;
use domain::{ThemeColor, ThemeConfig};
pub fn theme_to_json(theme: &ThemeConfig) -> Result<String, SqliteConfigError> {
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<ThemeConfig, SqliteConfigError> {
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<ThemeColor, SqliteConfigError> {
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,
})
}

View File

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

View File

@@ -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::<C, E, W, B, R, A, H>)
.put(layout::update_layout::<C, E, W, B, R, A, H>),
)
.route(
"/theme",
get(theme::get_theme::<C, E, W, B, R, A, H>)
.put(theme::update_theme::<C, E, W, B, R, A, H>),
)
.route(
"/presets",
get(presets::list_presets::<C, E, W, B, R, A, H>)

View File

@@ -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<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
pub async fn get_theme<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<ThemeDto>, 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<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Json(body): Json<ThemeDto>,
) -> Result<StatusCode, (StatusCode, String)>
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)
}

View File

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

View File

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

View File

@@ -21,6 +21,10 @@ pub struct LayoutNodeDto {
#[serde(skip_serializing_if = "Option::is_none")]
pub padding: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub justify_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub align_items: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub children: Option<Vec<LayoutChildDto>>,
}
@@ -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::<Result<Vec<_>, _>>()?;
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,
}))
}

View File

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

View File

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

View File

@@ -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<C::Error, E::Error>> {
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,

View File

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

View File

@@ -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<HashMap<WidgetId, WidgetConfig>>,
data_sources: Mutex<HashMap<DataSourceId, DataSource>>,
layout: Mutex<Option<Layout>>,
theme: Mutex<Option<ThemeConfig>>,
presets: Mutex<HashMap<LayoutPresetId, LayoutPreset>>,
}
@@ -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<Option<ThemeConfig>, 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<Option<LayoutPreset>, Self::Error> {
Ok(self.presets.lock().unwrap().get(&id).cloned())
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,10 @@ pub fn parse_markup(input: &str, theme: &ThemeConfig) -> Vec<TextSpan> {
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<TextSpan> {
}
if !current_text.is_empty() {
spans.push(TextSpan { text: current_text, color: current_color });
spans.push(TextSpan {
text: current_text,
color: current_color,
});
}
spans

View File

@@ -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::<Vec<_>>()
.join("\n")
}
DisplayHintKind::KeyValue => {
data.iter()
.filter_map(|(k, v)| {
let val = value_to_string(v)?;
Some(format!("{{secondary}}{k}{{/}}: {val}"))
})
.collect::<Vec<_>>()
.join("\n")
}
DisplayHintKind::TextBlock => data
.iter()
.filter_map(|(_, v)| value_to_string(v))
.collect::<Vec<_>>()
.join("\n"),
DisplayHintKind::KeyValue => data
.iter()
.filter_map(|(k, v)| {
let val = value_to_string(v)?;
Some(format!("{{primary}}{k}{{/}}: {val}"))
})
.collect::<Vec<_>>()
.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<String> {
Value::Null => None,
Value::Array(arr) => {
let items: Vec<String> = 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,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Output = Result<(), Self::Error>> + Send;
fn push_theme_update(
&self,
theme: &ThemeConfig,
) -> impl Future<Output = Result<(), Self::Error>> + Send;
}

View File

@@ -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<Output = Result<(), Self::Error>> + Send;
fn get_theme(&self) -> impl Future<Output = Result<Option<ThemeConfig>, Self::Error>> + Send;
fn save_theme(
&self,
theme: &ThemeConfig,
) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn get_user_by_username(
&self,
username: &str,

View File

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

View File

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

View File

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

View File

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