use std::collections::HashMap;
use dotzuki_engine::render::Rgba;
use serde::de::{self, Deserializer};
use serde::Deserialize;
use thiserror::Error;
pub type FontRegistry = HashMap<String, ()>;
pub type TilesetRegistry = HashMap<String, ()>;
#[derive(Clone, Debug, Default)]
pub struct ImageData {
pub width: u32,
pub height: u32,
pub pixels: Vec<Rgba>,
}
impl ImageData {
pub fn new(width: u32, height: u32, pixels: Vec<Rgba>) -> Self {
Self { width, height, pixels }
}
pub fn is_empty(&self) -> bool {
self.width == 0 || self.height == 0 || self.pixels.is_empty()
}
#[inline]
pub fn pixel(&self, x: u32, y: u32) -> Rgba {
if x >= self.width || y >= self.height {
return Rgba::TRANSPARENT;
}
self.pixels
.get((y * self.width + x) as usize)
.copied()
.unwrap_or(Rgba::TRANSPARENT)
}
#[cfg(feature = "image-assets")]
pub fn load(path: &std::path::Path) -> Result<Self, String> {
let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
let img = image::load_from_memory(&bytes)
.map_err(|e| format!("decode {}: {e}", path.display()))?
.to_rgba8();
let (w, h) = img.dimensions();
let pixels = img
.pixels()
.map(|p| Rgba::new(p.0[0], p.0[1], p.0[2], p.0[3]))
.collect();
Ok(Self::new(w, h, pixels))
}
}
pub type ImageRegistry = HashMap<String, ImageData>;
pub fn empty_image_registry() -> &'static ImageRegistry {
static EMPTY: std::sync::OnceLock<ImageRegistry> = std::sync::OnceLock::new();
EMPTY.get_or_init(ImageRegistry::new)
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct ScreenLayout {
pub schema_version: u8,
pub screen: String,
#[serde(default)]
pub theme: Theme,
pub elements: Vec<LayoutElement>,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct Theme {
pub bg_color: String,
#[serde(default = "default_font_name")]
pub default_font: String,
#[serde(default)]
pub text_mode: TextMode,
#[serde(default)]
pub ink: Option<String>,
#[serde(default)]
pub panel_bg: Option<String>,
#[serde(default)]
pub panel_border: Option<String>,
#[serde(default)]
pub cursor_color: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TextMode {
#[default]
Tile,
Proportional,
}
fn default_font_name() -> String {
"default".to_string()
}
impl Default for Theme {
fn default() -> Self {
Self {
bg_color: "#FFFFFF".to_string(),
default_font: "default".to_string(),
text_mode: TextMode::Tile,
ink: None,
panel_bg: None,
panel_border: None,
cursor_color: None,
}
}
}
impl Theme {
pub fn ink_color(&self) -> dotzuki_engine::render::Rgba {
self.ink
.as_deref()
.map(crate::layout_engine::elements::text::parse_color)
.unwrap_or(dotzuki_engine::render::Rgba::INK_BLACK)
}
pub fn cursor_ink(&self) -> dotzuki_engine::render::Rgba {
self.cursor_color
.as_deref()
.map(crate::layout_engine::elements::text::parse_color)
.unwrap_or_else(|| self.ink_color())
}
pub fn proportional(&self, painter_supports: bool) -> bool {
self.text_mode == TextMode::Proportional && painter_supports
}
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct LayoutElement {
#[serde(default)]
pub id: String,
#[serde(rename = "type")]
pub element_type: String,
pub rect: ElementRect,
#[serde(default)]
pub visible: Visibility,
#[serde(default)]
pub z_index: i32,
#[serde(flatten)]
pub params: ElementParams,
}
#[derive(Debug, Clone)]
pub enum Visibility {
Static(bool),
Template(String),
}
impl Default for Visibility {
fn default() -> Self {
Visibility::Static(true)
}
}
impl Visibility {
pub fn eval(&self, ctx: &DataContext) -> bool {
match self {
Visibility::Static(b) => *b,
Visibility::Template(t) => {
let trimmed = t.trim();
let key = trimmed
.strip_prefix('{')
.and_then(|r| r.strip_suffix('}'))
.unwrap_or(trimmed)
.trim();
ctx.is_truthy(key)
}
}
}
}
impl<'de> Deserialize<'de> for Visibility {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct VisibilityVisitor;
impl<'de> serde::de::Visitor<'de> for VisibilityVisitor {
type Value = Visibility;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a bool or a {template} condition string")
}
fn visit_bool<E: de::Error>(self, b: bool) -> Result<Visibility, E> {
Ok(Visibility::Static(b))
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<Visibility, E> {
match s.trim().to_lowercase().as_str() {
"true" => Ok(Visibility::Static(true)),
"false" => Ok(Visibility::Static(false)),
_ => Ok(Visibility::Template(s.to_string())),
}
}
}
d.deserialize_any(VisibilityVisitor)
}
}
#[derive(Debug, Clone)]
pub enum Coord {
Literal(u32),
Template(String),
}
impl Coord {
pub fn resolve(&self, ctx: &DataContext) -> u32 {
match self {
Coord::Literal(v) => *v,
Coord::Template(tpl) => {
let resolved = ctx.resolve(tpl);
resolved.trim().parse::<u32>().unwrap_or(0)
}
}
}
pub fn as_literal(&self) -> Option<u32> {
match self {
Coord::Literal(v) => Some(*v),
Coord::Template(_) => None,
}
}
}
impl From<u32> for Coord {
fn from(v: u32) -> Self {
Coord::Literal(v)
}
}
impl Default for Coord {
fn default() -> Self {
Coord::Literal(0)
}
}
impl<'de> Deserialize<'de> for Coord {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct CoordVisitor;
impl<'de> serde::de::Visitor<'de> for CoordVisitor {
type Value = Coord;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a u32 number or a {template} string")
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Coord, E> {
Ok(Coord::Literal(v as u32))
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Coord, E> {
if v < 0 {
return Err(de::Error::custom(format!(
"negative coordinate {} not allowed", v
)));
}
Ok(Coord::Literal(v as u32))
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Coord, E> {
if v.contains('{') {
Ok(Coord::Template(v.to_string()))
} else {
v.parse::<u32>()
.map(Coord::Literal)
.map_err(de::Error::custom)
}
}
}
d.deserialize_any(CoordVisitor)
}
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct ElementRect {
#[serde(default)]
pub tx: Coord,
#[serde(default)]
pub ty: Coord,
#[serde(default)]
pub tw: Option<u32>,
#[serde(default)]
pub th: Option<u32>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum ElementParams {
Border(BorderParams),
Text(TextParams),
Tile(TileParams),
Divider(DividerParams),
Image(ImageParams),
List(ListParams),
FlexList(FlexListParams),
Group(GroupParams),
Cursor(CursorParams),
Bracket(BracketParams),
PixelRect(PixelRectParams),
Custom(serde_json::Value),
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct BracketParams {
pub color: Option<String>,
#[serde(default)]
pub left: bool,
#[serde(default)]
pub right: bool,
#[serde(default)]
pub top: bool,
#[serde(default)]
pub bottom: bool,
#[serde(default)]
pub with_arrow: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct PixelRectParams {
pub color: Option<String>,
pub px: u32,
pub py: u32,
pub pw: u32,
pub ph: u32,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct CursorParams {
#[serde(default)]
pub glyph: Option<String>,
pub color: Option<String>,
#[serde(default)]
pub col: Coord,
#[serde(default)]
pub row: Coord,
#[serde(default)]
pub col_step: u32,
#[serde(default)]
pub row_step: u32,
}
impl CursorParams {
pub fn glyph_char(&self) -> char {
self.glyph
.as_deref()
.and_then(|g| g.chars().next())
.unwrap_or('\u{25B6}')
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct BorderParams {
#[serde(default, deserialize_with = "deserialize_optional_border_style")]
pub style: Option<BorderStyle>,
pub tileset: Option<String>,
#[serde(default)]
pub children: Vec<LayoutElement>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub enum BorderStyle {
#[default]
Single,
Double,
}
fn deserialize_optional_border_style<'de, D>(d: D) -> Result<Option<BorderStyle>, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(d)?;
match value {
serde_json::Value::Null => Ok(None),
serde_json::Value::String(s) => match s.to_lowercase().as_str() {
"default" | "single" => Ok(Some(BorderStyle::Single)),
"double" => Ok(Some(BorderStyle::Double)),
other => Err(de::Error::custom(format!("unknown border style: {other}"))),
},
serde_json::Value::Object(_) => Ok(Some(BorderStyle::Single)),
other => Err(de::Error::custom(format!(
"border style must be a string or object, got {other}"
))),
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum LocalizedValue {
Plain(String),
Localized(std::collections::BTreeMap<String, String>),
}
impl LocalizedValue {
pub fn get(&self, lang: &str) -> &str {
match self {
LocalizedValue::Plain(s) => s,
LocalizedValue::Localized(map) => map
.get(lang)
.or_else(|| map.get("en"))
.or_else(|| map.values().next())
.map(|s| s.as_str())
.unwrap_or(""),
}
}
}
impl Default for LocalizedValue {
fn default() -> Self {
LocalizedValue::Plain(String::new())
}
}
impl From<&str> for LocalizedValue {
fn from(s: &str) -> Self {
LocalizedValue::Plain(s.to_string())
}
}
impl From<String> for LocalizedValue {
fn from(s: String) -> Self {
LocalizedValue::Plain(s)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct TextParams {
pub value: LocalizedValue,
pub format: Option<String>,
pub color: Option<String>,
pub align: Option<TextAlign>,
pub font: Option<String>,
pub wrap: Option<String>,
pub line_spacing: Option<u32>,
pub scale: Option<u32>,
}
#[derive(Debug, Clone)]
pub enum TextAlign {
Left,
Center,
Right,
}
impl<'de> Deserialize<'de> for TextAlign {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
match s.to_lowercase().as_str() {
"left" => Ok(TextAlign::Left),
"center" => Ok(TextAlign::Center),
"right" => Ok(TextAlign::Right),
_ => Err(de::Error::custom(format!("unknown TextAlign: {s}"))),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct TileParams {
pub tile_id: serde_json::Value,
#[serde(default)]
pub flip_x: bool,
#[serde(default)]
pub flip_y: bool,
pub palette: Option<String>,
pub repeat: Option<u32>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct DividerParams {
pub tiles: Vec<u16>,
pub repeat: u32,
#[serde(default)]
pub orientation: Direction,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct ImageParams {
#[serde(alias = "src")]
pub source: String,
#[serde(default)]
pub flip_x: bool,
#[serde(default)]
pub flip_y: bool,
pub palette: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct ListParams {
pub items: String,
pub item_template: ItemTemplate,
#[serde(default)]
pub cursor: ListCursor,
#[serde(default)]
pub selected: Option<Coord>,
pub max_visible: Option<usize>,
pub footer: Option<String>,
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct ListCursor {
pub tile: Option<u32>,
pub position: Option<String>,
}
impl<'de> Deserialize<'de> for ListCursor {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct CursorObj {
#[serde(default)]
tile: Option<u32>,
#[serde(default)]
position: Option<String>,
}
struct CursorVisitor;
impl<'de> serde::de::Visitor<'de> for CursorVisitor {
type Value = ListCursor;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a tile id number or a {tile, position} object")
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<ListCursor, E> {
Ok(ListCursor { tile: Some(v as u32), position: None })
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<ListCursor, E> {
Ok(ListCursor { tile: Some(v.max(0) as u32), position: None })
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
map: A,
) -> Result<ListCursor, A::Error> {
let obj =
CursorObj::deserialize(de::value::MapAccessDeserializer::new(map))?;
Ok(ListCursor { tile: obj.tile, position: obj.position })
}
fn visit_unit<E: de::Error>(self) -> Result<ListCursor, E> {
Ok(ListCursor::default())
}
}
d.deserialize_any(CursorVisitor)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct FlexListParams {
pub items: String,
pub item_layout: Vec<ColumnDef>,
pub padding: EdgeInsets,
pub gap: u32,
#[serde(default)]
pub cursor: ListCursor,
#[serde(default)]
pub selected: Option<Coord>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct GroupParams {
pub layout: LayoutConfig,
#[serde(default)]
pub clip: bool,
pub children: Vec<LayoutElement>,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct LayoutConfig {
#[serde(default)]
pub direction: Option<Direction>,
#[serde(default)]
pub gap: u32,
#[serde(default)]
pub padding: EdgeInsets,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub enum Direction {
#[default]
Horizontal,
Vertical,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct ItemTemplate {
pub height: u32,
pub gap: u32,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct ColumnDef {
pub field: String,
pub width: u32,
pub align: Option<TextAlign>,
pub prefix: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct EdgeInsets {
#[serde(default)]
pub top: u32,
#[serde(default)]
pub bottom: u32,
#[serde(default)]
pub left: u32,
#[serde(default)]
pub right: u32,
}
impl Default for EdgeInsets {
fn default() -> Self {
Self {
top: 0,
bottom: 0,
left: 0,
right: 0,
}
}
}
#[derive(Debug, Clone, Deserialize, Error)]
pub enum RenderError {
#[error("invalid layout")]
InvalidLayout,
#[error("unknown element type")]
UnknownElement,
#[error("missing variable")]
MissingVariable,
#[error("render failed")]
RenderFailed,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub enum DataValue {
Str(String),
Int(i64),
Float(f64),
Bool(bool),
List(Vec<DataValue>),
TileId(u16),
}
impl From<String> for DataValue {
fn from(s: String) -> Self {
DataValue::Str(s)
}
}
impl From<&str> for DataValue {
fn from(s: &str) -> Self {
DataValue::Str(s.to_string())
}
}
impl From<i64> for DataValue {
fn from(n: i64) -> Self {
DataValue::Int(n)
}
}
impl From<u16> for DataValue {
fn from(n: u16) -> Self {
DataValue::TileId(n)
}
}
impl From<bool> for DataValue {
fn from(b: bool) -> Self {
DataValue::Bool(b)
}
}
impl From<Vec<DataValue>> for DataValue {
fn from(v: Vec<DataValue>) -> Self {
DataValue::List(v)
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct RenderContext<'a> {
pub screen: &'a str,
pub theme: &'a Theme,
pub fonts: &'a FontRegistry,
pub tilesets: &'a TilesetRegistry,
pub images: &'a ImageRegistry,
}
impl<'a> RenderContext<'a> {
pub fn new(
screen: &'a str,
theme: &'a Theme,
fonts: &'a FontRegistry,
tilesets: &'a TilesetRegistry,
) -> Self {
Self {
screen,
theme,
fonts,
tilesets,
images: empty_image_registry(),
}
}
pub fn with_images(mut self, images: &'a ImageRegistry) -> Self {
self.images = images;
self
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct DataContext {
pub(crate) values: HashMap<String, DataValue>,
}