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:
@@ -1,6 +1,6 @@
|
|||||||
use domain::{
|
use domain::{
|
||||||
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, User,
|
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
|
||||||
WidgetConfig, WidgetId,
|
User, WidgetConfig, WidgetId,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
@@ -15,6 +15,7 @@ pub struct MemoryConfigStore {
|
|||||||
widgets: RwLock<HashMap<WidgetId, WidgetConfig>>,
|
widgets: RwLock<HashMap<WidgetId, WidgetConfig>>,
|
||||||
data_sources: RwLock<HashMap<DataSourceId, DataSource>>,
|
data_sources: RwLock<HashMap<DataSourceId, DataSource>>,
|
||||||
layout: RwLock<Option<Layout>>,
|
layout: RwLock<Option<Layout>>,
|
||||||
|
theme: RwLock<Option<ThemeConfig>>,
|
||||||
presets: RwLock<HashMap<LayoutPresetId, LayoutPreset>>,
|
presets: RwLock<HashMap<LayoutPresetId, LayoutPreset>>,
|
||||||
users: RwLock<Vec<User>>,
|
users: RwLock<Vec<User>>,
|
||||||
}
|
}
|
||||||
@@ -25,6 +26,7 @@ impl Default for MemoryConfigStore {
|
|||||||
widgets: RwLock::new(HashMap::new()),
|
widgets: RwLock::new(HashMap::new()),
|
||||||
data_sources: RwLock::new(HashMap::new()),
|
data_sources: RwLock::new(HashMap::new()),
|
||||||
layout: RwLock::new(None),
|
layout: RwLock::new(None),
|
||||||
|
theme: RwLock::new(None),
|
||||||
presets: RwLock::new(HashMap::new()),
|
presets: RwLock::new(HashMap::new()),
|
||||||
users: RwLock::new(Vec::new()),
|
users: RwLock::new(Vec::new()),
|
||||||
}
|
}
|
||||||
@@ -125,6 +127,23 @@ impl ConfigRepository for MemoryConfigStore {
|
|||||||
Ok(())
|
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> {
|
async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error> {
|
||||||
let guard = self
|
let guard = self
|
||||||
.presets
|
.presets
|
||||||
|
|||||||
@@ -87,6 +87,15 @@ impl SqliteConfigStore {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
mod data_sources;
|
mod data_sources;
|
||||||
mod layout;
|
mod layout;
|
||||||
mod presets;
|
mod presets;
|
||||||
|
mod theme;
|
||||||
mod users;
|
mod users;
|
||||||
mod widgets;
|
mod widgets;
|
||||||
|
|
||||||
use crate::SqliteConfigStore;
|
use crate::SqliteConfigStore;
|
||||||
use crate::error::SqliteConfigError;
|
use crate::error::SqliteConfigError;
|
||||||
use domain::{
|
use domain::{
|
||||||
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, User,
|
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
|
||||||
WidgetConfig, WidgetId,
|
User, WidgetConfig, WidgetId,
|
||||||
};
|
};
|
||||||
|
|
||||||
impl ConfigRepository for SqliteConfigStore {
|
impl ConfigRepository for SqliteConfigStore {
|
||||||
@@ -70,6 +71,14 @@ impl ConfigRepository for SqliteConfigStore {
|
|||||||
self.delete_preset_impl(id).await
|
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> {
|
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
|
||||||
self.get_user_by_username_impl(username).await
|
self.get_user_by_username_impl(username).await
|
||||||
}
|
}
|
||||||
|
|||||||
37
crates/adapters/config-sqlite/src/repository/theme.rs
Normal file
37
crates/adapters/config-sqlite/src/repository/theme.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
use crate::error::SqliteConfigError;
|
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> {
|
pub fn layout_to_json(layout: &Layout) -> Result<String, SqliteConfigError> {
|
||||||
let v = node_to_json(&layout.root);
|
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" },
|
"direction": match c.direction { Direction::Row => "row", Direction::Column => "column" },
|
||||||
"gap": c.gap,
|
"gap": c.gap,
|
||||||
"padding": c.padding,
|
"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,
|
"children": children,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -93,12 +108,26 @@ fn node_from_json(v: &serde_json::Value) -> Result<LayoutNode, SqliteConfigError
|
|||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.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 {
|
Ok(LayoutNode::Container(ContainerNode {
|
||||||
direction,
|
direction,
|
||||||
gap,
|
gap,
|
||||||
padding,
|
padding,
|
||||||
justify_content: JustifyContent::Start,
|
justify_content,
|
||||||
align_items: AlignItems::Stretch,
|
align_items,
|
||||||
children,
|
children,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod data_source;
|
pub mod data_source;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod preset;
|
pub mod preset;
|
||||||
|
pub mod theme;
|
||||||
pub mod widget;
|
pub mod widget;
|
||||||
|
|||||||
40
crates/adapters/config-sqlite/src/serialization/theme.rs
Normal file
40
crates/adapters/config-sqlite/src/serialization/theme.rs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use config_sqlite::SqliteConfigStore;
|
use config_sqlite::SqliteConfigStore;
|
||||||
use domain::{
|
use domain::{
|
||||||
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
|
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
|
||||||
Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild, LayoutNode,
|
Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild,
|
||||||
LayoutPreset, Sizing, WidgetConfig,
|
LayoutNode, LayoutPreset, Sizing, WidgetConfig,
|
||||||
};
|
};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ mod clients;
|
|||||||
mod data_sources;
|
mod data_sources;
|
||||||
mod layout;
|
mod layout;
|
||||||
mod presets;
|
mod presets;
|
||||||
|
mod theme;
|
||||||
mod webhook;
|
mod webhook;
|
||||||
mod widgets;
|
mod widgets;
|
||||||
|
|
||||||
@@ -70,6 +71,11 @@ where
|
|||||||
get(layout::get_layout::<C, E, W, B, R, A, H>)
|
get(layout::get_layout::<C, E, W, B, R, A, H>)
|
||||||
.put(layout::update_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(
|
.route(
|
||||||
"/presets",
|
"/presets",
|
||||||
get(presets::list_presets::<C, E, W, B, R, A, H>)
|
get(presets::list_presets::<C, E, W, B, R, A, H>)
|
||||||
|
|||||||
46
crates/adapters/http-api/src/routes/theme.rs
Normal file
46
crates/adapters/http-api/src/routes/theme.rs
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::error::TcpServerError;
|
use crate::error::TcpServerError;
|
||||||
use domain::{BroadcastPort, DisplayHint, Layout, WidgetId, WidgetState};
|
use domain::{BroadcastPort, DisplayHint, Layout, ThemeConfig, WidgetId, WidgetState};
|
||||||
use protocol::{ServerMessage, WidgetDescriptor, WireLayoutNode, encode};
|
use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireLayoutNode, WireTheme, encode};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
pub struct TcpBroadcaster {
|
pub struct TcpBroadcaster {
|
||||||
@@ -70,4 +70,26 @@ impl BroadcastPort for TcpBroadcaster {
|
|||||||
let frame = encode(&msg).map_err(TcpServerError::Encode)?;
|
let frame = encode(&msg).map_err(TcpServerError::Encode)?;
|
||||||
self.send_frame(frame)
|
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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::broadcaster::domain_theme_to_wire;
|
||||||
use crate::client_tracker::ClientTracker;
|
use crate::client_tracker::ClientTracker;
|
||||||
use crate::error::TcpServerError;
|
use crate::error::TcpServerError;
|
||||||
use domain::{ConfigRepository, WidgetStateReader};
|
use domain::{ConfigRepository, WidgetStateReader};
|
||||||
@@ -103,11 +104,24 @@ where
|
|||||||
widgets: wire_widgets,
|
widgets: wire_widgets,
|
||||||
};
|
};
|
||||||
|
|
||||||
match encode(&msg) {
|
let mut combined = match encode(&msg) {
|
||||||
Ok(frame) => Some(frame),
|
Ok(frame) => frame,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(error = %e, "failed to encode initial screen update");
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ pub struct LayoutNodeDto {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub padding: Option<u8>,
|
pub padding: Option<u8>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[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>>,
|
pub children: Option<Vec<LayoutChildDto>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +48,8 @@ impl From<&LayoutNode> for LayoutNodeDto {
|
|||||||
direction: None,
|
direction: None,
|
||||||
gap: None,
|
gap: None,
|
||||||
padding: None,
|
padding: None,
|
||||||
|
justify_content: None,
|
||||||
|
align_items: None,
|
||||||
children: None,
|
children: None,
|
||||||
},
|
},
|
||||||
LayoutNode::Container(c) => Self {
|
LayoutNode::Container(c) => Self {
|
||||||
@@ -58,6 +64,25 @@ impl From<&LayoutNode> for LayoutNodeDto {
|
|||||||
),
|
),
|
||||||
gap: Some(c.gap),
|
gap: Some(c.gap),
|
||||||
padding: Some(c.padding),
|
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(
|
children: Some(
|
||||||
c.children
|
c.children
|
||||||
.iter()
|
.iter()
|
||||||
@@ -109,12 +134,26 @@ impl LayoutNodeDto {
|
|||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.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 {
|
Ok(LayoutNode::Container(ContainerNode {
|
||||||
direction,
|
direction,
|
||||||
gap: self.gap.unwrap_or(0),
|
gap: self.gap.unwrap_or(0),
|
||||||
padding: self.padding.unwrap_or(0),
|
padding: self.padding.unwrap_or(0),
|
||||||
justify_content: JustifyContent::Start,
|
justify_content,
|
||||||
align_items: AlignItems::Stretch,
|
align_items,
|
||||||
children,
|
children,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ pub mod client;
|
|||||||
pub mod data_source;
|
pub mod data_source;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod preset;
|
pub mod preset;
|
||||||
|
pub mod theme;
|
||||||
pub mod widget;
|
pub mod widget;
|
||||||
|
|
||||||
pub use client::ClientDto;
|
pub use client::ClientDto;
|
||||||
pub use data_source::DataSourceDto;
|
pub use data_source::DataSourceDto;
|
||||||
pub use layout::{LayoutChildDto, LayoutDto, LayoutNodeDto, SizingDto};
|
pub use layout::{LayoutChildDto, LayoutDto, LayoutNodeDto, SizingDto};
|
||||||
pub use preset::{CreatePresetDto, PresetDto};
|
pub use preset::{CreatePresetDto, PresetDto};
|
||||||
|
pub use theme::{ColorDto, ThemeDto};
|
||||||
pub use widget::{CreateWidgetDto, KeyMappingDto, WidgetDto};
|
pub use widget::{CreateWidgetDto, KeyMappingDto, WidgetDto};
|
||||||
|
|||||||
52
crates/api-types/src/theme.rs
Normal file
52
crates/api-types/src/theme.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use domain::{
|
use domain::{
|
||||||
ConfigRepository, DataSource, DataSourceId, DataSourceValidationError, DomainEvent,
|
ConfigRepository, DataSource, DataSourceId, DataSourceValidationError, DomainEvent,
|
||||||
EventPublisher, Layout, LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
|
EventPublisher, Layout, LayoutPreset, LayoutPresetId, ThemeConfig, WidgetConfig, WidgetId,
|
||||||
};
|
};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
@@ -142,6 +142,21 @@ where
|
|||||||
Ok(())
|
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(
|
pub async fn save_preset(
|
||||||
&self,
|
&self,
|
||||||
preset: LayoutPreset,
|
preset: LayoutPreset,
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ mod support;
|
|||||||
use application::ConfigService;
|
use application::ConfigService;
|
||||||
use domain::{
|
use domain::{
|
||||||
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
|
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
|
||||||
Direction, DisplayHint, DisplayHintKind, DomainEvent, JustifyContent, KeyMapping, Layout, LayoutChild,
|
Direction, DisplayHint, DisplayHintKind, DomainEvent, JustifyContent, KeyMapping, Layout,
|
||||||
LayoutNode, LayoutPreset, Sizing, WidgetConfig,
|
LayoutChild, LayoutNode, LayoutPreset, Sizing, WidgetConfig,
|
||||||
};
|
};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use support::{InMemoryConfigRepository, InMemoryEventPublisher};
|
use support::{InMemoryConfigRepository, InMemoryEventPublisher};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use domain::{
|
use domain::{
|
||||||
ConfigRepository, DataSource, DataSourceId, DomainEvent, EventPublisher, Layout, LayoutPreset,
|
ConfigRepository, DataSource, DataSourceId, DomainEvent, EventPublisher, Layout, LayoutPreset,
|
||||||
LayoutPresetId, User, WidgetConfig, WidgetId,
|
LayoutPresetId, ThemeConfig, User, WidgetConfig, WidgetId,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -9,6 +9,7 @@ pub struct InMemoryConfigRepository {
|
|||||||
widgets: Mutex<HashMap<WidgetId, WidgetConfig>>,
|
widgets: Mutex<HashMap<WidgetId, WidgetConfig>>,
|
||||||
data_sources: Mutex<HashMap<DataSourceId, DataSource>>,
|
data_sources: Mutex<HashMap<DataSourceId, DataSource>>,
|
||||||
layout: Mutex<Option<Layout>>,
|
layout: Mutex<Option<Layout>>,
|
||||||
|
theme: Mutex<Option<ThemeConfig>>,
|
||||||
presets: Mutex<HashMap<LayoutPresetId, LayoutPreset>>,
|
presets: Mutex<HashMap<LayoutPresetId, LayoutPreset>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ impl InMemoryConfigRepository {
|
|||||||
widgets: Mutex::new(HashMap::new()),
|
widgets: Mutex::new(HashMap::new()),
|
||||||
data_sources: Mutex::new(HashMap::new()),
|
data_sources: Mutex::new(HashMap::new()),
|
||||||
layout: Mutex::new(None),
|
layout: Mutex::new(None),
|
||||||
|
theme: Mutex::new(None),
|
||||||
presets: Mutex::new(HashMap::new()),
|
presets: Mutex::new(HashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,6 +94,15 @@ impl ConfigRepository for InMemoryConfigRepository {
|
|||||||
Ok(())
|
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> {
|
async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error> {
|
||||||
Ok(self.presets.lock().unwrap().get(&id).cloned())
|
Ok(self.presets.lock().unwrap().get(&id).cloned())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ pub async fn run(
|
|||||||
|
|
||||||
info!("layout changed, pushed screen update to clients");
|
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(_) => {}
|
Ok(_) => {}
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||||
warn!(skipped = n, "event handler lagged, missed events");
|
warn!(skipped = n, "event handler lagged, missed events");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use client_domain::{BoundingBox, Color, LayoutEngine, RenderTree, ThemeConfig};
|
use client_domain::{BoundingBox, Color, LayoutEngine, RenderTree, ThemeConfig};
|
||||||
use domain::LayoutNode;
|
use domain::LayoutNode;
|
||||||
use protocol::{
|
use protocol::{
|
||||||
ServerMessage, WireColor, WidgetDescriptor, WireDisplayHint, WireLayoutNode, WireWidgetState,
|
ServerMessage, WidgetDescriptor, WireColor, WireDisplayHint, WireLayoutNode, WireWidgetState,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use client_application::ClientApp;
|
use client_application::ClientApp;
|
||||||
|
use client_domain::NetworkPort;
|
||||||
use client_domain::{BoundingBox, DisplayPort, FontMetrics, RenderEngine, ThemeConfig};
|
use client_domain::{BoundingBox, DisplayPort, FontMetrics, RenderEngine, ThemeConfig};
|
||||||
use display_terminal::TerminalDisplay;
|
use display_terminal::TerminalDisplay;
|
||||||
use domain::DisplayHint;
|
use domain::DisplayHint;
|
||||||
@@ -7,7 +8,6 @@ use std::sync::mpsc;
|
|||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tcp_client::StdTcpClient;
|
use tcp_client::StdTcpClient;
|
||||||
use client_domain::NetworkPort;
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let screen = BoundingBox::screen(240, 320);
|
let screen = BoundingBox::screen(240, 320);
|
||||||
@@ -78,14 +78,18 @@ fn main() {
|
|||||||
display.fill_rect(cmd.bounds, bg).unwrap();
|
display.fill_rect(cmd.bounds, bg).unwrap();
|
||||||
|
|
||||||
let hint: DisplayHint = cmd.display_hint.clone().into();
|
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()
|
.iter()
|
||||||
.map(|kv| (kv.key.clone(), kv.value.clone().into()))
|
.map(|kv| (kv.key.clone(), kv.value.clone().into()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let draw_cmds = engine.render_widget(&hint, &data, cmd.bounds, 0);
|
let draw_cmds = engine.render_widget(&hint, &data, cmd.bounds, 0);
|
||||||
for dc in &draw_cmds {
|
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();
|
display.flush().unwrap();
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ mod text_layout;
|
|||||||
mod theme;
|
mod theme;
|
||||||
|
|
||||||
pub use alignment::align_offset;
|
pub use alignment::align_offset;
|
||||||
pub use domain::{AlignItems, DisplayHintKind, HAlign, JustifyContent, VAlign};
|
|
||||||
pub use bounding_box::BoundingBox;
|
pub use bounding_box::BoundingBox;
|
||||||
pub use color::Color;
|
pub use color::Color;
|
||||||
|
pub use domain::{AlignItems, DisplayHintKind, HAlign, JustifyContent, VAlign};
|
||||||
pub use font::{FontMetrics, FontSize};
|
pub use font::{FontMetrics, FontSize};
|
||||||
pub use layout_engine::LayoutEngine;
|
pub use layout_engine::LayoutEngine;
|
||||||
pub use markup::{parse_markup, TextSpan};
|
pub use markup::{TextSpan, parse_markup};
|
||||||
pub use render_engine::{DrawCommand, RenderEngine};
|
|
||||||
pub use ports::{ClientConfig, DisplayPort, NetworkPort, StoragePort};
|
pub use ports::{ClientConfig, DisplayPort, NetworkPort, StoragePort};
|
||||||
|
pub use render_engine::{DrawCommand, RenderEngine};
|
||||||
pub use render_tree::RenderTree;
|
pub use render_tree::RenderTree;
|
||||||
pub use scroll::ScrollState;
|
pub use scroll::ScrollState;
|
||||||
pub use text_layout::wrap_lines;
|
pub use text_layout::wrap_lines;
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ pub fn parse_markup(input: &str, theme: &ThemeConfig) -> Vec<TextSpan> {
|
|||||||
|
|
||||||
if let Some(new_color) = resolve_tag(&tag, theme) {
|
if let Some(new_color) = resolve_tag(&tag, theme) {
|
||||||
if !current_text.is_empty() {
|
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_text.clear();
|
||||||
}
|
}
|
||||||
current_color = new_color;
|
current_color = new_color;
|
||||||
@@ -39,7 +42,10 @@ pub fn parse_markup(input: &str, theme: &ThemeConfig) -> Vec<TextSpan> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !current_text.is_empty() {
|
if !current_text.is_empty() {
|
||||||
spans.push(TextSpan { text: current_text, color: current_color });
|
spans.push(TextSpan {
|
||||||
|
text: current_text,
|
||||||
|
color: current_color,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
spans
|
spans
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
BoundingBox, Color, FontMetrics, FontSize, ThemeConfig,
|
BoundingBox, Color, FontMetrics, FontSize, ThemeConfig, alignment::align_offset,
|
||||||
alignment::align_offset, markup::parse_markup, text_layout::wrap_lines,
|
markup::parse_markup, text_layout::wrap_lines,
|
||||||
};
|
};
|
||||||
use domain::{DisplayHint, DisplayHintKind, HAlign, VAlign, Value};
|
use domain::{DisplayHint, DisplayHintKind, HAlign, VAlign, Value};
|
||||||
|
|
||||||
@@ -62,7 +62,8 @@ impl RenderEngine {
|
|||||||
while char_pos < line_end {
|
while char_pos < line_end {
|
||||||
let (color, span_end) = self.color_at(&spans, char_pos, line_end, &plain);
|
let (color, span_end) = self.color_at(&spans, char_pos, line_end, &plain);
|
||||||
let segment = &plain[char_pos..span_end];
|
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 {
|
cmds.push(DrawCommand {
|
||||||
text: segment.to_string(),
|
text: segment.to_string(),
|
||||||
@@ -100,18 +101,16 @@ impl RenderEngine {
|
|||||||
cmd.y = cmd.y.saturating_sub(scroll_offset);
|
cmd.y = cmd.y.saturating_sub(scroll_offset);
|
||||||
}
|
}
|
||||||
// Drop commands that scrolled above bounds
|
// 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
|
cmds
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn content_height(
|
pub fn content_height(&self, hint: &DisplayHint, data: &[(String, Value)], width: u16) -> u16 {
|
||||||
&self,
|
|
||||||
hint: &DisplayHint,
|
|
||||||
data: &[(String, Value)],
|
|
||||||
width: u16,
|
|
||||||
) -> u16 {
|
|
||||||
let text = self.format_widget(hint, data);
|
let text = self.format_widget(hint, data);
|
||||||
let plain: String = parse_markup(&text, &self.theme)
|
let plain: String = parse_markup(&text, &self.theme)
|
||||||
.iter()
|
.iter()
|
||||||
@@ -123,37 +122,35 @@ impl RenderEngine {
|
|||||||
|
|
||||||
fn format_widget(&self, hint: &DisplayHint, data: &[(String, Value)]) -> String {
|
fn format_widget(&self, hint: &DisplayHint, data: &[(String, Value)]) -> String {
|
||||||
match hint.kind {
|
match hint.kind {
|
||||||
DisplayHintKind::TextBlock => {
|
DisplayHintKind::TextBlock => data
|
||||||
data.iter()
|
.iter()
|
||||||
.filter_map(|(_, v)| value_to_string(v))
|
.filter_map(|(_, v)| value_to_string(v))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n")
|
.join("\n"),
|
||||||
}
|
DisplayHintKind::KeyValue => data
|
||||||
DisplayHintKind::KeyValue => {
|
.iter()
|
||||||
data.iter()
|
|
||||||
.filter_map(|(k, v)| {
|
.filter_map(|(k, v)| {
|
||||||
let val = value_to_string(v)?;
|
let val = value_to_string(v)?;
|
||||||
Some(format!("{{secondary}}{k}{{/}}: {val}"))
|
Some(format!("{{primary}}{k}{{/}}: {val}"))
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n")
|
.join("\n"),
|
||||||
}
|
|
||||||
DisplayHintKind::IconValue => {
|
DisplayHintKind::IconValue => {
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
for (k, v) in data {
|
for (k, v) in data {
|
||||||
if k == "icon" {
|
if k == "icon"
|
||||||
if let Some(s) = value_to_string(v) {
|
&& let Some(s) = value_to_string(v)
|
||||||
|
{
|
||||||
parts.push(s);
|
parts.push(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for (k, v) in data {
|
for (k, v) in data {
|
||||||
if k != "icon" {
|
if k != "icon"
|
||||||
if let Some(s) = value_to_string(v) {
|
&& let Some(s) = value_to_string(v)
|
||||||
|
{
|
||||||
parts.push(s);
|
parts.push(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
parts.join(" ")
|
parts.join(" ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +184,11 @@ fn value_to_string(v: &Value) -> Option<String> {
|
|||||||
Value::Null => None,
|
Value::Null => None,
|
||||||
Value::Array(arr) => {
|
Value::Array(arr) => {
|
||||||
let items: Vec<String> = arr.iter().filter_map(value_to_string).collect();
|
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,
|
Value::Object(_) => None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
use crate::{FontMetrics, FontSize};
|
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() {
|
if text.is_empty() {
|
||||||
return Vec::new();
|
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;
|
let mut line_end = 0;
|
||||||
|
|
||||||
for word_start in WordStarts::new(text) {
|
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 {
|
if line_start == line_end {
|
||||||
// First word on this line
|
// First word on this line
|
||||||
@@ -83,7 +90,11 @@ struct WordStarts<'a> {
|
|||||||
|
|
||||||
impl<'a> WordStarts<'a> {
|
impl<'a> WordStarts<'a> {
|
||||||
fn new(text: &'a str) -> Self {
|
fn new(text: &'a str) -> Self {
|
||||||
Self { text, pos: 0, started: false }
|
Self {
|
||||||
|
text,
|
||||||
|
pos: 0,
|
||||||
|
started: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use client_domain::{BoundingBox, LayoutEngine, RenderTree};
|
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 {
|
fn screen() -> BoundingBox {
|
||||||
BoundingBox::screen(240, 320)
|
BoundingBox::screen(240, 320)
|
||||||
@@ -230,8 +232,14 @@ fn justify_center_centers_fixed_children_on_main_axis() {
|
|||||||
});
|
});
|
||||||
let tree = LayoutEngine::compute(&layout, screen());
|
let tree = LayoutEngine::compute(&layout, screen());
|
||||||
|
|
||||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(80, 0, 40, 320)));
|
assert_eq!(
|
||||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(120, 0, 40, 320)));
|
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]
|
#[test]
|
||||||
@@ -247,7 +255,10 @@ fn justify_end_pushes_to_end() {
|
|||||||
});
|
});
|
||||||
let tree = LayoutEngine::compute(&layout, screen());
|
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]
|
#[test]
|
||||||
@@ -263,9 +274,18 @@ fn justify_space_between_distributes_gaps() {
|
|||||||
});
|
});
|
||||||
let tree = LayoutEngine::compute(&layout, screen());
|
let tree = LayoutEngine::compute(&layout, screen());
|
||||||
|
|
||||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 40, 320)));
|
assert_eq!(
|
||||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(100, 0, 40, 320)));
|
tree.get_widget_bounds(1),
|
||||||
assert_eq!(tree.get_widget_bounds(3), Some(&BoundingBox::new(200, 0, 40, 320)));
|
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]
|
#[test]
|
||||||
@@ -282,8 +302,14 @@ fn justify_space_evenly_distributes_with_edges() {
|
|||||||
let tree = LayoutEngine::compute(&layout, screen());
|
let tree = LayoutEngine::compute(&layout, screen());
|
||||||
|
|
||||||
// 160 / 3 = 53px per slot
|
// 160 / 3 = 53px per slot
|
||||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(53, 0, 40, 320)));
|
assert_eq!(
|
||||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(146, 0, 40, 320)));
|
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 ---
|
// --- AlignItems tests ---
|
||||||
@@ -315,5 +341,8 @@ fn align_items_center_centers_on_cross_axis() {
|
|||||||
let tree = LayoutEngine::compute(&layout, screen());
|
let tree = LayoutEngine::compute(&layout, screen());
|
||||||
|
|
||||||
// Stretch: child gets full cross-axis height
|
// 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))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,39 +7,73 @@ fn theme() -> ThemeConfig {
|
|||||||
#[test]
|
#[test]
|
||||||
fn plain_text_produces_single_span() {
|
fn plain_text_produces_single_span() {
|
||||||
let spans = parse_markup("hello world", &theme());
|
let spans = parse_markup("hello world", &theme());
|
||||||
assert_eq!(spans, vec![
|
assert_eq!(
|
||||||
TextSpan { text: "hello world".into(), color: theme().text },
|
spans,
|
||||||
]);
|
vec![TextSpan {
|
||||||
|
text: "hello world".into(),
|
||||||
|
color: theme().text
|
||||||
|
},]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hex_color_span() {
|
fn hex_color_span() {
|
||||||
let spans = parse_markup("temp: {#FF0000}72°F{/}", &theme());
|
let spans = parse_markup("temp: {#FF0000}72°F{/}", &theme());
|
||||||
assert_eq!(spans, vec![
|
assert_eq!(
|
||||||
TextSpan { text: "temp: ".into(), color: theme().text },
|
spans,
|
||||||
TextSpan { text: "72°F".into(), color: Color(0xFF, 0, 0) },
|
vec![
|
||||||
]);
|
TextSpan {
|
||||||
|
text: "temp: ".into(),
|
||||||
|
color: theme().text
|
||||||
|
},
|
||||||
|
TextSpan {
|
||||||
|
text: "72°F".into(),
|
||||||
|
color: Color(0xFF, 0, 0)
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn theme_color_spans() {
|
fn theme_color_spans() {
|
||||||
let t = theme();
|
let t = theme();
|
||||||
let spans = parse_markup("{primary}hello{/} {accent}world{/}", &t);
|
let spans = parse_markup("{primary}hello{/} {accent}world{/}", &t);
|
||||||
assert_eq!(spans, vec![
|
assert_eq!(
|
||||||
TextSpan { text: "hello".into(), color: t.primary },
|
spans,
|
||||||
TextSpan { text: " ".into(), color: t.text },
|
vec![
|
||||||
TextSpan { text: "world".into(), color: t.accent },
|
TextSpan {
|
||||||
]);
|
text: "hello".into(),
|
||||||
|
color: t.primary
|
||||||
|
},
|
||||||
|
TextSpan {
|
||||||
|
text: " ".into(),
|
||||||
|
color: t.text
|
||||||
|
},
|
||||||
|
TextSpan {
|
||||||
|
text: "world".into(),
|
||||||
|
color: t.accent
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_returns_to_text_color() {
|
fn reset_returns_to_text_color() {
|
||||||
let t = theme();
|
let t = theme();
|
||||||
let spans = parse_markup("{accent}hi{/}bye", &t);
|
let spans = parse_markup("{accent}hi{/}bye", &t);
|
||||||
assert_eq!(spans, vec![
|
assert_eq!(
|
||||||
TextSpan { text: "hi".into(), color: t.accent },
|
spans,
|
||||||
TextSpan { text: "bye".into(), color: t.text },
|
vec![
|
||||||
]);
|
TextSpan {
|
||||||
|
text: "hi".into(),
|
||||||
|
color: t.accent
|
||||||
|
},
|
||||||
|
TextSpan {
|
||||||
|
text: "bye".into(),
|
||||||
|
color: t.text
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -52,16 +86,29 @@ fn empty_input_produces_no_spans() {
|
|||||||
fn adjacent_color_spans_no_text_between() {
|
fn adjacent_color_spans_no_text_between() {
|
||||||
let t = theme();
|
let t = theme();
|
||||||
let spans = parse_markup("{primary}a{secondary}b{/}", &t);
|
let spans = parse_markup("{primary}a{secondary}b{/}", &t);
|
||||||
assert_eq!(spans, vec![
|
assert_eq!(
|
||||||
TextSpan { text: "a".into(), color: t.primary },
|
spans,
|
||||||
TextSpan { text: "b".into(), color: t.secondary },
|
vec![
|
||||||
]);
|
TextSpan {
|
||||||
|
text: "a".into(),
|
||||||
|
color: t.primary
|
||||||
|
},
|
||||||
|
TextSpan {
|
||||||
|
text: "b".into(),
|
||||||
|
color: t.secondary
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_tag_treated_as_literal() {
|
fn unknown_tag_treated_as_literal() {
|
||||||
let spans = parse_markup("{unknown}text", &theme());
|
let spans = parse_markup("{unknown}text", &theme());
|
||||||
assert_eq!(spans, vec![
|
assert_eq!(
|
||||||
TextSpan { text: "text".into(), color: theme().text },
|
spans,
|
||||||
]);
|
vec![TextSpan {
|
||||||
|
text: "text".into(),
|
||||||
|
color: theme().text
|
||||||
|
},]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use client_domain::{
|
use client_domain::{
|
||||||
BoundingBox, Color, DrawCommand, FontMetrics, FontSize, HAlign, RenderEngine,
|
BoundingBox, Color, DrawCommand, FontMetrics, FontSize, HAlign, RenderEngine, ThemeConfig,
|
||||||
ThemeConfig, VAlign,
|
VAlign,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn metrics() -> FontMetrics {
|
fn metrics() -> FontMetrics {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use client_domain::{BoundingBox, LayoutEngine};
|
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 {
|
fn screen() -> BoundingBox {
|
||||||
BoundingBox::screen(240, 320)
|
BoundingBox::screen(240, 320)
|
||||||
|
|||||||
@@ -44,11 +44,12 @@ pub fn run(
|
|||||||
let is_screen_update = matches!(msg, ServerMessage::ScreenUpdate { .. });
|
let is_screen_update = matches!(msg, ServerMessage::ScreenUpdate { .. });
|
||||||
let repaints = app.handle_message(msg);
|
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());
|
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;
|
let bg = engine.theme().background;
|
||||||
display.fill_rect(screen, bg).unwrap();
|
display.fill_rect(screen, bg).unwrap();
|
||||||
first_update = false;
|
first_update = false;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::entities::{DataSourceId, LayoutPresetId, WidgetId};
|
use crate::entities::{DataSourceId, LayoutPresetId, WidgetId};
|
||||||
use crate::value_objects::Layout;
|
use crate::value_objects::{Layout, ThemeConfig};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum DomainEvent {
|
pub enum DomainEvent {
|
||||||
@@ -10,6 +10,7 @@ pub enum DomainEvent {
|
|||||||
DataSourceUpdated { id: DataSourceId },
|
DataSourceUpdated { id: DataSourceId },
|
||||||
DataSourceRemoved { id: DataSourceId },
|
DataSourceRemoved { id: DataSourceId },
|
||||||
LayoutChanged { layout: Layout },
|
LayoutChanged { layout: Layout },
|
||||||
|
ThemeChanged { theme: ThemeConfig },
|
||||||
LayoutPresetSaved { id: LayoutPresetId },
|
LayoutPresetSaved { id: LayoutPresetId },
|
||||||
LayoutPresetLoaded { id: LayoutPresetId },
|
LayoutPresetLoaded { id: LayoutPresetId },
|
||||||
LayoutPresetDeleted { id: LayoutPresetId },
|
LayoutPresetDeleted { id: LayoutPresetId },
|
||||||
|
|||||||
@@ -16,6 +16,6 @@ pub use ports::{
|
|||||||
};
|
};
|
||||||
pub use value_objects::{
|
pub use value_objects::{
|
||||||
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent,
|
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent,
|
||||||
KeyMapping, Layout, LayoutChild, LayoutNode, LayoutValidationError, Sizing, VAlign, Value,
|
KeyMapping, Layout, LayoutChild, LayoutNode, LayoutValidationError, Sizing, ThemeColor,
|
||||||
WidgetError, WidgetState,
|
ThemeConfig, VAlign, Value, WidgetError, WidgetState,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::entities::WidgetId;
|
use crate::entities::WidgetId;
|
||||||
use crate::value_objects::{DisplayHint, Layout, WidgetState};
|
use crate::value_objects::{DisplayHint, Layout, ThemeConfig, WidgetState};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
|
|
||||||
pub trait BroadcastPort {
|
pub trait BroadcastPort {
|
||||||
@@ -15,4 +15,9 @@ pub trait BroadcastPort {
|
|||||||
&self,
|
&self,
|
||||||
updates: &[(WidgetId, DisplayHint, WidgetState)],
|
updates: &[(WidgetId, DisplayHint, WidgetState)],
|
||||||
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
|
|
||||||
|
fn push_theme_update(
|
||||||
|
&self,
|
||||||
|
theme: &ThemeConfig,
|
||||||
|
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::entities::{
|
use crate::entities::{
|
||||||
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, User, WidgetConfig, WidgetId,
|
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, User, WidgetConfig, WidgetId,
|
||||||
};
|
};
|
||||||
use crate::value_objects::Layout;
|
use crate::value_objects::{Layout, ThemeConfig};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
|
|
||||||
pub trait ConfigRepository {
|
pub trait ConfigRepository {
|
||||||
@@ -51,6 +51,12 @@ pub trait ConfigRepository {
|
|||||||
id: LayoutPresetId,
|
id: LayoutPresetId,
|
||||||
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
) -> 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(
|
fn get_user_by_username(
|
||||||
&self,
|
&self,
|
||||||
username: &str,
|
username: &str,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
mod key_mapping;
|
mod key_mapping;
|
||||||
mod layout;
|
mod layout;
|
||||||
|
mod theme;
|
||||||
mod value;
|
mod value;
|
||||||
mod widget_state;
|
mod widget_state;
|
||||||
|
|
||||||
@@ -8,5 +9,6 @@ pub use layout::{
|
|||||||
AlignItems, ContainerNode, Direction, JustifyContent, Layout, LayoutChild, LayoutNode,
|
AlignItems, ContainerNode, Direction, JustifyContent, Layout, LayoutChild, LayoutNode,
|
||||||
LayoutValidationError, Sizing,
|
LayoutValidationError, Sizing,
|
||||||
};
|
};
|
||||||
|
pub use theme::{ThemeColor, ThemeConfig};
|
||||||
pub use value::Value;
|
pub use value::Value;
|
||||||
pub use widget_state::{DisplayHint, DisplayHintKind, HAlign, VAlign, WidgetError, WidgetState};
|
pub use widget_state::{DisplayHint, DisplayHintKind, HAlign, VAlign, WidgetError, WidgetState};
|
||||||
|
|||||||
47
crates/domain/src/value_objects/theme.rs
Normal file
47
crates/domain/src/value_objects/theme.rs
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ mod frame;
|
|||||||
mod wire;
|
mod wire;
|
||||||
|
|
||||||
pub use frame::{
|
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,
|
decode_client_message, decode_server_message, encode, encode_client,
|
||||||
};
|
};
|
||||||
pub use wire::{
|
pub use wire::{
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use protocol::{
|
use protocol::{
|
||||||
ClientMessage, ServerMessage, WidgetDescriptor, WireAlignItems, WireContainerNode,
|
ClientMessage, ServerMessage, WidgetDescriptor, WireAlignItems, WireContainerNode,
|
||||||
WireDirection, WireDisplayHint, WireDisplayHintKind, WireJustifyContent, WireKeyValue,
|
WireDirection, WireDisplayHint, WireDisplayHintKind, WireJustifyContent, WireKeyValue,
|
||||||
WireLayoutChild, WireLayoutNode, WireSizing, WireValue, WireWidgetState,
|
WireLayoutChild, WireLayoutNode, WireSizing, WireValue, WireWidgetState, decode_client_message,
|
||||||
decode_client_message, decode_server_message, encode, encode_client,
|
decode_server_message, encode, encode_client,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
22
spa/src/api/theme.ts
Normal file
22
spa/src/api/theme.ts
Normal file
@@ -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<ThemeConfig>("/theme"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateTheme() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (theme: ThemeConfig) => api.put<ThemeConfig>("/theme", theme),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEYS.current }),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ export type DisplayHint = "icon_value" | "text_block" | "key_value"
|
|||||||
export type SourceType = "weather" | "media" | "rss" | "http_json" | "webhook"
|
export type SourceType = "weather" | "media" | "rss" | "http_json" | "webhook"
|
||||||
export type SizingType = "fixed" | "flex"
|
export type SizingType = "fixed" | "flex"
|
||||||
export type Direction = "row" | "column"
|
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 {
|
export interface KeyMapping {
|
||||||
source_path: string
|
source_path: string
|
||||||
@@ -40,6 +42,8 @@ export interface LayoutNode {
|
|||||||
direction?: Direction
|
direction?: Direction
|
||||||
gap?: number
|
gap?: number
|
||||||
padding?: number
|
padding?: number
|
||||||
|
justify_content?: JustifyContent
|
||||||
|
align_items?: AlignItemsType
|
||||||
children?: LayoutChild[]
|
children?: LayoutChild[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,3 +66,17 @@ export interface ClientInfo {
|
|||||||
addr: string
|
addr: string
|
||||||
connected_at: number
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
Box,
|
Box,
|
||||||
Layers,
|
Layers,
|
||||||
|
Palette,
|
||||||
Save,
|
Save,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -30,6 +31,7 @@ const NAV = [
|
|||||||
{ to: "/data-sources", label: "Data Sources", icon: Database },
|
{ to: "/data-sources", label: "Data Sources", icon: Database },
|
||||||
{ to: "/widgets", label: "Widgets", icon: Box },
|
{ to: "/widgets", label: "Widgets", icon: Box },
|
||||||
{ to: "/layout", label: "Layout", icon: Layers },
|
{ to: "/layout", label: "Layout", icon: Layers },
|
||||||
|
{ to: "/theme", label: "Theme", icon: Palette },
|
||||||
{ to: "/presets", label: "Presets", icon: Save },
|
{ to: "/presets", label: "Presets", icon: Save },
|
||||||
{ to: "/guide", label: "Guide", icon: BookOpen },
|
{ to: "/guide", label: "Guide", icon: BookOpen },
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
103
spa/src/components/layout-preview.tsx
Normal file
103
spa/src/components/layout-preview.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div ref={containerRef} style={{ maxWidth }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: screenWidth * scale,
|
||||||
|
height: screenHeight * scale,
|
||||||
|
position: "relative",
|
||||||
|
backgroundColor: colorToCSS(theme.background),
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: 4,
|
||||||
|
border: `1px solid ${colorToCSS(theme.secondary)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{widgetIds.map((wid) => {
|
||||||
|
const box = bounds.get(wid)
|
||||||
|
if (!box) return null
|
||||||
|
const w = widgets.find((w) => w.id === wid)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={wid}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: box.x * scale,
|
||||||
|
top: box.y * scale,
|
||||||
|
width: box.width * scale,
|
||||||
|
height: box.height * scale,
|
||||||
|
border: `1px solid ${colorToCSS(theme.secondary)}`,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
padding: 2 * scale,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10 * scale,
|
||||||
|
color: colorToCSS(theme.text),
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{w?.name ?? `#${wid}`}
|
||||||
|
</span>
|
||||||
|
{w && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 8 * scale,
|
||||||
|
color: colorToCSS(theme.accent),
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{w.display_hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
93
spa/src/lib/layout-engine.ts
Normal file
93
spa/src/lib/layout-engine.ts
Normal file
@@ -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<number, BoundingBox> {
|
||||||
|
const out = new Map<number, BoundingBox>()
|
||||||
|
computeNode(layout, bounds, out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeNode(
|
||||||
|
node: LayoutNode,
|
||||||
|
bounds: BoundingBox,
|
||||||
|
out: Map<number, BoundingBox>,
|
||||||
|
) {
|
||||||
|
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<number, BoundingBox>,
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState, useCallback } from "react"
|
import { useState, useCallback } from "react"
|
||||||
import { useLayout, useUpdateLayout } from "@/api/layout"
|
import { useLayout, useUpdateLayout } from "@/api/layout"
|
||||||
import { useWidgets } from "@/api/widgets"
|
import { useWidgets } from "@/api/widgets"
|
||||||
|
import { useTheme } from "@/api/theme"
|
||||||
import type { LayoutNode, LayoutChild, Direction } from "@/api/types"
|
import type { LayoutNode, LayoutChild, Direction } from "@/api/types"
|
||||||
|
import { LayoutPreview } from "@/components/layout-preview"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -37,6 +39,8 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Save,
|
Save,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
@@ -115,12 +119,16 @@ function removeAtPath(root: LayoutNode, path: Path): LayoutNode {
|
|||||||
export function LayoutBuilderPage() {
|
export function LayoutBuilderPage() {
|
||||||
const { data: currentLayout, isLoading } = useLayout()
|
const { data: currentLayout, isLoading } = useLayout()
|
||||||
const { data: widgets = [] } = useWidgets()
|
const { data: widgets = [] } = useWidgets()
|
||||||
|
const { data: theme } = useTheme()
|
||||||
const updateLayout = useUpdateLayout()
|
const updateLayout = useUpdateLayout()
|
||||||
|
|
||||||
const [root, setRoot] = useState<LayoutNode | null>(null)
|
const [root, setRoot] = useState<LayoutNode | null>(null)
|
||||||
const [selected, setSelected] = useState<Path | null>(null)
|
const [selected, setSelected] = useState<Path | null>(null)
|
||||||
const [initialized, setInitialized] = useState(false)
|
const [initialized, setInitialized] = useState(false)
|
||||||
const [pendingDelete, setPendingDelete] = useState<Path | null>(null)
|
const [pendingDelete, setPendingDelete] = useState<Path | null>(null)
|
||||||
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
|
const [screenWidth, setScreenWidth] = useState(320)
|
||||||
|
const [screenHeight, setScreenHeight] = useState(240)
|
||||||
|
|
||||||
if (!initialized && currentLayout?.root) {
|
if (!initialized && currentLayout?.root) {
|
||||||
setRoot(structuredClone(currentLayout.root))
|
setRoot(structuredClone(currentLayout.root))
|
||||||
@@ -170,7 +178,7 @@ export function LayoutBuilderPage() {
|
|||||||
|
|
||||||
function updateContainerProp(
|
function updateContainerProp(
|
||||||
path: Path,
|
path: Path,
|
||||||
prop: "gap" | "padding" | "direction",
|
prop: "gap" | "padding" | "direction" | "justify_content" | "align_items",
|
||||||
value: number | string,
|
value: number | string,
|
||||||
) {
|
) {
|
||||||
setRoot((r) =>
|
setRoot((r) =>
|
||||||
@@ -223,11 +231,24 @@ export function LayoutBuilderPage() {
|
|||||||
Compose the display layout tree
|
Compose the display layout tree
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowPreview((v) => !v)}
|
||||||
|
>
|
||||||
|
{showPreview ? (
|
||||||
|
<EyeOff className="mr-2 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
<Button onClick={save}>
|
<Button onClick={save}>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
Save Layout
|
Save Layout
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[1fr_300px]">
|
<div className="grid gap-4 lg:grid-cols-[1fr_300px]">
|
||||||
{/* Tree view */}
|
{/* Tree view */}
|
||||||
@@ -328,6 +349,65 @@ export function LayoutBuilderPage() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{showPreview && root && theme && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Layout Preview</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label>W</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={screenWidth}
|
||||||
|
onChange={(e) => setScreenWidth(Number(e.target.value))}
|
||||||
|
className="w-20"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">x</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label>H</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={screenHeight}
|
||||||
|
onChange={(e) => setScreenHeight(Number(e.target.value))}
|
||||||
|
className="w-20"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{([
|
||||||
|
[320, 240],
|
||||||
|
[240, 135],
|
||||||
|
[128, 64],
|
||||||
|
] as const).map(([w, h]) => (
|
||||||
|
<Button
|
||||||
|
key={`${w}x${h}`}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setScreenWidth(w)
|
||||||
|
setScreenHeight(h)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{w}x{h}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LayoutPreview
|
||||||
|
layout={root}
|
||||||
|
screenWidth={screenWidth}
|
||||||
|
screenHeight={screenHeight}
|
||||||
|
theme={theme}
|
||||||
|
widgets={widgets}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -420,7 +500,7 @@ function ContainerProps({
|
|||||||
}: {
|
}: {
|
||||||
node: LayoutNode
|
node: LayoutNode
|
||||||
path: Path
|
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
|
onAddWidget: (path: Path, widgetId: number) => void
|
||||||
onAddContainer: (path: Path, direction: Direction) => void
|
onAddContainer: (path: Path, direction: Direction) => void
|
||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
@@ -469,6 +549,43 @@ function ContainerProps({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Justify</Label>
|
||||||
|
<Select
|
||||||
|
value={node.justify_content ?? "start"}
|
||||||
|
onValueChange={(v) => onUpdateProp(path, "justify_content", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="start">Start</SelectItem>
|
||||||
|
<SelectItem value="center">Center</SelectItem>
|
||||||
|
<SelectItem value="end">End</SelectItem>
|
||||||
|
<SelectItem value="space_between">Space Between</SelectItem>
|
||||||
|
<SelectItem value="space_evenly">Space Evenly</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Align</Label>
|
||||||
|
<Select
|
||||||
|
value={node.align_items ?? "stretch"}
|
||||||
|
onValueChange={(v) => onUpdateProp(path, "align_items", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="start">Start</SelectItem>
|
||||||
|
<SelectItem value="center">Center</SelectItem>
|
||||||
|
<SelectItem value="end">End</SelectItem>
|
||||||
|
<SelectItem value="stretch">Stretch</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{!isRoot && <SizingEditor path={path} onUpdate={onUpdateSizing} />}
|
{!isRoot && <SizingEditor path={path} onUpdate={onUpdateSizing} />}
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Add Child</Label>
|
<Label>Add Child</Label>
|
||||||
|
|||||||
276
spa/src/pages/theme.tsx
Normal file
276
spa/src/pages/theme.tsx
Normal file
@@ -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<string, ThemeConfig> = {
|
||||||
|
"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 (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="capitalize">{label}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={hex}
|
||||||
|
onChange={(e) => {
|
||||||
|
const c = hexToColor(e.target.value)
|
||||||
|
if (c) onChange(c)
|
||||||
|
}}
|
||||||
|
className="h-9 w-12 cursor-pointer rounded border p-0.5"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={hex.toUpperCase()}
|
||||||
|
onChange={(e) => {
|
||||||
|
const c = hexToColor(e.target.value)
|
||||||
|
if (c) onChange(c)
|
||||||
|
}}
|
||||||
|
className="font-mono"
|
||||||
|
maxLength={7}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemePage() {
|
||||||
|
const { data: serverTheme, isLoading } = useTheme()
|
||||||
|
const updateTheme = useUpdateTheme()
|
||||||
|
const [theme, setTheme] = useState<ThemeConfig | null>(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 <div className="text-muted-foreground p-4">Loading...</div>
|
||||||
|
|
||||||
|
if (!theme) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Theme</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">Configure display colors</p>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||||
|
<p className="text-muted-foreground">No theme configured. Pick a preset to start:</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.keys(PRESETS).map((name) => (
|
||||||
|
<Button key={name} variant="outline" onClick={() => applyPreset(name)}>
|
||||||
|
{name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Theme</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">Configure display colors</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={save} disabled={updateTheme.isPending}>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
Save Theme
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1fr_300px]">
|
||||||
|
{/* Color pickers */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Colors</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4">
|
||||||
|
{COLOR_FIELDS.map((field) => (
|
||||||
|
<ColorPicker
|
||||||
|
key={field}
|
||||||
|
label={field}
|
||||||
|
color={theme[field]}
|
||||||
|
onChange={(c) => setColor(field, c)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Right column: presets + preview */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Presets</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-2 gap-2">
|
||||||
|
{Object.keys(PRESETS).map((name) => (
|
||||||
|
<Button
|
||||||
|
key={name}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => applyPreset(name)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Preview</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Color swatches */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{COLOR_FIELDS.map((field) => (
|
||||||
|
<div key={field} className="flex flex-col items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="h-8 w-8 rounded border"
|
||||||
|
style={{ backgroundColor: colorToHex(theme[field]) }}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-[10px] capitalize">
|
||||||
|
{field}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sample display */}
|
||||||
|
<div
|
||||||
|
className="rounded-md p-4"
|
||||||
|
style={{ backgroundColor: colorToHex(theme.background) }}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-sm font-medium"
|
||||||
|
style={{ color: colorToHex(theme.primary) }}
|
||||||
|
>
|
||||||
|
Primary Heading
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: colorToHex(theme.text) }}
|
||||||
|
>
|
||||||
|
Sample text content displayed on the device.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<Badge
|
||||||
|
style={{
|
||||||
|
backgroundColor: colorToHex(theme.accent),
|
||||||
|
color: colorToHex(theme.text),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Accent
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
style={{
|
||||||
|
borderColor: colorToHex(theme.secondary),
|
||||||
|
color: colorToHex(theme.secondary),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Secondary
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { DashboardPage } from "@/pages/dashboard"
|
|||||||
import { DataSourcesPage } from "@/pages/data-sources"
|
import { DataSourcesPage } from "@/pages/data-sources"
|
||||||
import { WidgetsPage } from "@/pages/widgets"
|
import { WidgetsPage } from "@/pages/widgets"
|
||||||
import { LayoutBuilderPage } from "@/pages/layout-builder"
|
import { LayoutBuilderPage } from "@/pages/layout-builder"
|
||||||
|
import { ThemePage } from "@/pages/theme"
|
||||||
import { PresetsPage } from "@/pages/presets"
|
import { PresetsPage } from "@/pages/presets"
|
||||||
import { GuidePage } from "@/pages/guide"
|
import { GuidePage } from "@/pages/guide"
|
||||||
import { LoginPage } from "@/pages/login"
|
import { LoginPage } from "@/pages/login"
|
||||||
@@ -72,6 +73,12 @@ const layoutRoute = createRoute({
|
|||||||
component: LayoutBuilderPage,
|
component: LayoutBuilderPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const themeRoute = createRoute({
|
||||||
|
getParentRoute: () => authenticatedRoute,
|
||||||
|
path: "/theme",
|
||||||
|
component: ThemePage,
|
||||||
|
})
|
||||||
|
|
||||||
const presetsRoute = createRoute({
|
const presetsRoute = createRoute({
|
||||||
getParentRoute: () => authenticatedRoute,
|
getParentRoute: () => authenticatedRoute,
|
||||||
path: "/presets",
|
path: "/presets",
|
||||||
@@ -91,6 +98,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
dataSourcesRoute,
|
dataSourcesRoute,
|
||||||
widgetsRoute,
|
widgetsRoute,
|
||||||
layoutRoute,
|
layoutRoute,
|
||||||
|
themeRoute,
|
||||||
presetsRoute,
|
presetsRoute,
|
||||||
guideRoute,
|
guideRoute,
|
||||||
]),
|
]),
|
||||||
|
|||||||
Reference in New Issue
Block a user