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

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