1use std::collections::HashMap;
2use dotzuki_engine::render::Rgba;
3use serde::de::{self, Deserializer};
4use serde::Deserialize;
5use thiserror::Error;
6
7pub type FontRegistry = HashMap<String, ()>;
12
13pub type TilesetRegistry = HashMap<String, ()>;
14
15#[derive(Clone, Debug, Default)]
23pub struct ImageData {
24 pub width: u32,
25 pub height: u32,
26 pub pixels: Vec<Rgba>,
27}
28
29impl ImageData {
30 pub fn new(width: u32, height: u32, pixels: Vec<Rgba>) -> Self {
31 Self { width, height, pixels }
32 }
33
34 pub fn is_empty(&self) -> bool {
35 self.width == 0 || self.height == 0 || self.pixels.is_empty()
36 }
37
38 #[inline]
40 pub fn pixel(&self, x: u32, y: u32) -> Rgba {
41 if x >= self.width || y >= self.height {
42 return Rgba::TRANSPARENT;
43 }
44 self.pixels
45 .get((y * self.width + x) as usize)
46 .copied()
47 .unwrap_or(Rgba::TRANSPARENT)
48 }
49
50 #[cfg(feature = "image-assets")]
53 pub fn load(path: &std::path::Path) -> Result<Self, String> {
54 let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
55 let img = image::load_from_memory(&bytes)
56 .map_err(|e| format!("decode {}: {e}", path.display()))?
57 .to_rgba8();
58 let (w, h) = img.dimensions();
59 let pixels = img
60 .pixels()
61 .map(|p| Rgba::new(p.0[0], p.0[1], p.0[2], p.0[3]))
62 .collect();
63 Ok(Self::new(w, h, pixels))
64 }
65}
66
67pub type ImageRegistry = HashMap<String, ImageData>;
70
71pub fn empty_image_registry() -> &'static ImageRegistry {
74 static EMPTY: std::sync::OnceLock<ImageRegistry> = std::sync::OnceLock::new();
75 EMPTY.get_or_init(ImageRegistry::new)
76}
77
78#[derive(Debug, Clone, Deserialize)]
83#[non_exhaustive]
84pub struct ScreenLayout {
85 pub schema_version: u8,
86
87 pub screen: String,
88
89 #[serde(default)]
90 pub theme: Theme,
91
92 pub elements: Vec<LayoutElement>,
93}
94
95#[derive(Debug, Clone, Deserialize)]
100#[non_exhaustive]
101pub struct Theme {
102 pub bg_color: String,
103
104 #[serde(default = "default_font_name")]
105 pub default_font: String,
106
107 #[serde(default)]
113 pub text_mode: TextMode,
114
115 #[serde(default)]
118 pub ink: Option<String>,
119
120 #[serde(default)]
122 pub panel_bg: Option<String>,
123
124 #[serde(default)]
126 pub panel_border: Option<String>,
127
128 #[serde(default)]
130 pub cursor_color: Option<String>,
131}
132
133#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
135#[serde(rename_all = "lowercase")]
136pub enum TextMode {
137 #[default]
139 Tile,
140 Proportional,
142}
143
144fn default_font_name() -> String {
145 "default".to_string()
146}
147
148impl Default for Theme {
149 fn default() -> Self {
150 Self {
151 bg_color: "#FFFFFF".to_string(),
152 default_font: "default".to_string(),
153 text_mode: TextMode::Tile,
154 ink: None,
155 panel_bg: None,
156 panel_border: None,
157 cursor_color: None,
158 }
159 }
160}
161
162impl Theme {
163 pub fn ink_color(&self) -> dotzuki_engine::render::Rgba {
165 self.ink
166 .as_deref()
167 .map(crate::layout_engine::elements::text::parse_color)
168 .unwrap_or(dotzuki_engine::render::Rgba::INK_BLACK)
169 }
170
171 pub fn cursor_ink(&self) -> dotzuki_engine::render::Rgba {
173 self.cursor_color
174 .as_deref()
175 .map(crate::layout_engine::elements::text::parse_color)
176 .unwrap_or_else(|| self.ink_color())
177 }
178
179 pub fn proportional(&self, painter_supports: bool) -> bool {
181 self.text_mode == TextMode::Proportional && painter_supports
182 }
183}
184
185#[derive(Debug, Clone, Deserialize)]
190#[non_exhaustive]
191pub struct LayoutElement {
192 #[serde(default)]
193 pub id: String,
194
195 #[serde(rename = "type")]
196 pub element_type: String,
197
198 pub rect: ElementRect,
199
200 #[serde(default)]
201 pub visible: Visibility,
202
203 #[serde(default)]
204 pub z_index: i32,
205
206 #[serde(flatten)]
207 pub params: ElementParams,
208}
209
210#[derive(Debug, Clone)]
218pub enum Visibility {
219 Static(bool),
221 Template(String),
223}
224
225impl Default for Visibility {
226 fn default() -> Self {
227 Visibility::Static(true)
228 }
229}
230
231impl Visibility {
232 pub fn eval(&self, ctx: &DataContext) -> bool {
234 match self {
235 Visibility::Static(b) => *b,
236 Visibility::Template(t) => {
237 let trimmed = t.trim();
238 let key = trimmed
239 .strip_prefix('{')
240 .and_then(|r| r.strip_suffix('}'))
241 .unwrap_or(trimmed)
242 .trim();
243 ctx.is_truthy(key)
244 }
245 }
246 }
247}
248
249impl<'de> Deserialize<'de> for Visibility {
250 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
251 struct VisibilityVisitor;
252
253 impl<'de> serde::de::Visitor<'de> for VisibilityVisitor {
254 type Value = Visibility;
255
256 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
257 f.write_str("a bool or a {template} condition string")
258 }
259
260 fn visit_bool<E: de::Error>(self, b: bool) -> Result<Visibility, E> {
261 Ok(Visibility::Static(b))
262 }
263
264 fn visit_str<E: de::Error>(self, s: &str) -> Result<Visibility, E> {
265 match s.trim().to_lowercase().as_str() {
266 "true" => Ok(Visibility::Static(true)),
267 "false" => Ok(Visibility::Static(false)),
268 _ => Ok(Visibility::Template(s.to_string())),
270 }
271 }
272 }
273
274 d.deserialize_any(VisibilityVisitor)
275 }
276}
277
278#[derive(Debug, Clone)]
286pub enum Coord {
287 Literal(u32),
289 Template(String),
291}
292
293impl Coord {
294 pub fn resolve(&self, ctx: &DataContext) -> u32 {
300 match self {
301 Coord::Literal(v) => *v,
302 Coord::Template(tpl) => {
303 let resolved = ctx.resolve(tpl);
304 resolved.trim().parse::<u32>().unwrap_or(0)
305 }
306 }
307 }
308
309 pub fn as_literal(&self) -> Option<u32> {
312 match self {
313 Coord::Literal(v) => Some(*v),
314 Coord::Template(_) => None,
315 }
316 }
317}
318
319impl From<u32> for Coord {
320 fn from(v: u32) -> Self {
321 Coord::Literal(v)
322 }
323}
324
325impl Default for Coord {
326 fn default() -> Self {
327 Coord::Literal(0)
328 }
329}
330
331impl<'de> Deserialize<'de> for Coord {
332 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
333 struct CoordVisitor;
334
335 impl<'de> serde::de::Visitor<'de> for CoordVisitor {
336 type Value = Coord;
337
338 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
339 f.write_str("a u32 number or a {template} string")
340 }
341
342 fn visit_u64<E: de::Error>(self, v: u64) -> Result<Coord, E> {
343 Ok(Coord::Literal(v as u32))
344 }
345
346 fn visit_i64<E: de::Error>(self, v: i64) -> Result<Coord, E> {
347 if v < 0 {
348 return Err(de::Error::custom(format!(
349 "negative coordinate {} not allowed", v
350 )));
351 }
352 Ok(Coord::Literal(v as u32))
353 }
354
355 fn visit_str<E: de::Error>(self, v: &str) -> Result<Coord, E> {
356 if v.contains('{') {
357 Ok(Coord::Template(v.to_string()))
359 } else {
360 v.parse::<u32>()
362 .map(Coord::Literal)
363 .map_err(de::Error::custom)
364 }
365 }
366 }
367
368 d.deserialize_any(CoordVisitor)
369 }
370}
371
372#[derive(Debug, Clone, Deserialize)]
377#[non_exhaustive]
378pub struct ElementRect {
379 #[serde(default)]
380 pub tx: Coord,
381
382 #[serde(default)]
383 pub ty: Coord,
384
385 #[serde(default)]
386 pub tw: Option<u32>,
387
388 #[serde(default)]
389 pub th: Option<u32>,
390}
391
392#[derive(Debug, Clone, Deserialize)]
397#[serde(untagged)]
398pub enum ElementParams {
399 Border(BorderParams),
400
401 Text(TextParams),
402
403 Tile(TileParams),
404
405 Divider(DividerParams),
406
407 Image(ImageParams),
408
409 List(ListParams),
410
411 FlexList(FlexListParams),
412
413 Group(GroupParams),
414
415 Cursor(CursorParams),
416
417 Bracket(BracketParams),
418
419 PixelRect(PixelRectParams),
420
421 Custom(serde_json::Value),
422}
423
424#[derive(Debug, Clone, Deserialize)]
431#[serde(deny_unknown_fields)]
432#[non_exhaustive]
433pub struct BracketParams {
434 pub color: Option<String>,
435 #[serde(default)]
436 pub left: bool,
437 #[serde(default)]
438 pub right: bool,
439 #[serde(default)]
440 pub top: bool,
441 #[serde(default)]
442 pub bottom: bool,
443 #[serde(default)]
444 pub with_arrow: bool,
445}
446
447#[derive(Debug, Clone, Deserialize)]
449#[serde(deny_unknown_fields)]
450#[non_exhaustive]
451pub struct PixelRectParams {
452 pub color: Option<String>,
453 pub px: u32,
454 pub py: u32,
455 pub pw: u32,
456 pub ph: u32,
457}
458
459#[derive(Debug, Clone, Deserialize)]
470#[serde(deny_unknown_fields)]
471#[non_exhaustive]
472pub struct CursorParams {
473 #[serde(default)]
475 pub glyph: Option<String>,
476
477 pub color: Option<String>,
478
479 #[serde(default)]
481 pub col: Coord,
482
483 #[serde(default)]
485 pub row: Coord,
486
487 #[serde(default)]
489 pub col_step: u32,
490
491 #[serde(default)]
493 pub row_step: u32,
494}
495
496impl CursorParams {
497 pub fn glyph_char(&self) -> char {
499 self.glyph
500 .as_deref()
501 .and_then(|g| g.chars().next())
502 .unwrap_or('\u{25B6}')
503 }
504}
505
506#[derive(Debug, Clone, Deserialize)]
511#[serde(deny_unknown_fields)]
512#[non_exhaustive]
513pub struct BorderParams {
514 #[serde(default, deserialize_with = "deserialize_optional_border_style")]
515 pub style: Option<BorderStyle>,
516
517 pub tileset: Option<String>,
518
519 #[serde(default)]
524 pub children: Vec<LayoutElement>,
525}
526
527#[derive(Debug, Clone, Default, Deserialize)]
528pub enum BorderStyle {
529 #[default]
530 Single,
531
532 Double,
533}
534
535fn deserialize_optional_border_style<'de, D>(d: D) -> Result<Option<BorderStyle>, D::Error>
536where
537 D: Deserializer<'de>,
538{
539 let value = serde_json::Value::deserialize(d)?;
543 match value {
544 serde_json::Value::Null => Ok(None),
545 serde_json::Value::String(s) => match s.to_lowercase().as_str() {
546 "default" | "single" => Ok(Some(BorderStyle::Single)),
547 "double" => Ok(Some(BorderStyle::Double)),
548 other => Err(de::Error::custom(format!("unknown border style: {other}"))),
549 },
550 serde_json::Value::Object(_) => Ok(Some(BorderStyle::Single)),
551 other => Err(de::Error::custom(format!(
552 "border style must be a string or object, got {other}"
553 ))),
554 }
555}
556
557#[derive(Debug, Clone, Deserialize)]
566#[serde(untagged)]
567pub enum LocalizedValue {
568 Plain(String),
569 Localized(std::collections::BTreeMap<String, String>),
570}
571
572impl LocalizedValue {
573 pub fn get(&self, lang: &str) -> &str {
575 match self {
576 LocalizedValue::Plain(s) => s,
577 LocalizedValue::Localized(map) => map
578 .get(lang)
579 .or_else(|| map.get("en"))
580 .or_else(|| map.values().next())
581 .map(|s| s.as_str())
582 .unwrap_or(""),
583 }
584 }
585}
586
587impl Default for LocalizedValue {
588 fn default() -> Self {
589 LocalizedValue::Plain(String::new())
590 }
591}
592
593impl From<&str> for LocalizedValue {
594 fn from(s: &str) -> Self {
595 LocalizedValue::Plain(s.to_string())
596 }
597}
598
599impl From<String> for LocalizedValue {
600 fn from(s: String) -> Self {
601 LocalizedValue::Plain(s)
602 }
603}
604
605#[derive(Debug, Clone, Deserialize)]
606#[serde(deny_unknown_fields)]
607#[non_exhaustive]
608pub struct TextParams {
609 pub value: LocalizedValue,
610
611 pub format: Option<String>,
612
613 pub color: Option<String>,
614
615 pub align: Option<TextAlign>,
616
617 pub font: Option<String>,
618
619 pub wrap: Option<String>, pub line_spacing: Option<u32>,
622
623 pub scale: Option<u32>,
627}
628
629#[derive(Debug, Clone)]
630pub enum TextAlign {
631 Left,
632
633 Center,
634
635 Right,
636}
637
638impl<'de> Deserialize<'de> for TextAlign {
639 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
640 let s = String::deserialize(d)?;
641 match s.to_lowercase().as_str() {
642 "left" => Ok(TextAlign::Left),
643 "center" => Ok(TextAlign::Center),
644 "right" => Ok(TextAlign::Right),
645 _ => Err(de::Error::custom(format!("unknown TextAlign: {s}"))),
646 }
647 }
648}
649
650#[derive(Debug, Clone, Deserialize)]
655#[serde(deny_unknown_fields)]
656#[non_exhaustive]
657pub struct TileParams {
658 pub tile_id: serde_json::Value,
659
660 #[serde(default)]
661 pub flip_x: bool,
662
663 #[serde(default)]
664 pub flip_y: bool,
665
666 pub palette: Option<String>,
667
668 pub repeat: Option<u32>,
669}
670
671#[derive(Debug, Clone, Deserialize)]
676#[serde(deny_unknown_fields)]
677#[non_exhaustive]
678pub struct DividerParams {
679 pub tiles: Vec<u16>,
680
681 pub repeat: u32,
682
683 #[serde(default)]
684 pub orientation: Direction,
685}
686
687#[derive(Debug, Clone, Deserialize)]
692#[serde(deny_unknown_fields)]
693#[non_exhaustive]
694pub struct ImageParams {
695 #[serde(alias = "src")]
699 pub source: String,
700
701 #[serde(default)]
702 pub flip_x: bool,
703
704 #[serde(default)]
705 pub flip_y: bool,
706
707 pub palette: Option<String>,
708}
709
710#[derive(Debug, Clone, Deserialize)]
715#[serde(deny_unknown_fields)]
716#[non_exhaustive]
717pub struct ListParams {
718 pub items: String,
719
720 pub item_template: ItemTemplate,
721
722 #[serde(default)]
723 pub cursor: ListCursor,
724
725 #[serde(default)]
728 pub selected: Option<Coord>,
729
730 pub max_visible: Option<usize>,
731
732 pub footer: Option<String>,
733}
734
735#[derive(Debug, Clone, Default)]
743#[non_exhaustive]
744pub struct ListCursor {
745 pub tile: Option<u32>,
747 pub position: Option<String>,
749}
750
751impl<'de> Deserialize<'de> for ListCursor {
752 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
753 #[derive(Deserialize)]
754 struct CursorObj {
755 #[serde(default)]
756 tile: Option<u32>,
757 #[serde(default)]
758 position: Option<String>,
759 }
760
761 struct CursorVisitor;
762
763 impl<'de> serde::de::Visitor<'de> for CursorVisitor {
764 type Value = ListCursor;
765
766 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
767 f.write_str("a tile id number or a {tile, position} object")
768 }
769
770 fn visit_u64<E: de::Error>(self, v: u64) -> Result<ListCursor, E> {
771 Ok(ListCursor { tile: Some(v as u32), position: None })
772 }
773
774 fn visit_i64<E: de::Error>(self, v: i64) -> Result<ListCursor, E> {
775 Ok(ListCursor { tile: Some(v.max(0) as u32), position: None })
776 }
777
778 fn visit_map<A: serde::de::MapAccess<'de>>(
779 self,
780 map: A,
781 ) -> Result<ListCursor, A::Error> {
782 let obj =
783 CursorObj::deserialize(de::value::MapAccessDeserializer::new(map))?;
784 Ok(ListCursor { tile: obj.tile, position: obj.position })
785 }
786
787 fn visit_unit<E: de::Error>(self) -> Result<ListCursor, E> {
788 Ok(ListCursor::default())
789 }
790 }
791
792 d.deserialize_any(CursorVisitor)
793 }
794}
795
796#[derive(Debug, Clone, Deserialize)]
801#[serde(deny_unknown_fields)]
802#[non_exhaustive]
803pub struct FlexListParams {
804 pub items: String,
805
806 pub item_layout: Vec<ColumnDef>,
807
808 pub padding: EdgeInsets,
809
810 pub gap: u32,
811
812 #[serde(default)]
813 pub cursor: ListCursor,
814
815 #[serde(default)]
817 pub selected: Option<Coord>,
818}
819
820#[derive(Debug, Clone, Deserialize)]
825#[serde(deny_unknown_fields)]
826#[non_exhaustive]
827pub struct GroupParams {
828 pub layout: LayoutConfig,
829
830 #[serde(default)]
831 pub clip: bool,
832
833 pub children: Vec<LayoutElement>,
834}
835
836#[derive(Debug, Clone, Deserialize)]
837#[non_exhaustive]
838pub struct LayoutConfig {
839 #[serde(default)]
840 pub direction: Option<Direction>,
841
842 #[serde(default)]
843 pub gap: u32,
844
845 #[serde(default)]
846 pub padding: EdgeInsets,
847}
848
849#[derive(Debug, Clone, Deserialize, Default)]
850pub enum Direction {
851 #[default]
852 Horizontal,
853
854 Vertical,
855}
856
857#[derive(Debug, Clone, Deserialize)]
862#[non_exhaustive]
863pub struct ItemTemplate {
864 pub height: u32,
865
866 pub gap: u32,
867}
868
869#[derive(Debug, Clone, Deserialize)]
870#[non_exhaustive]
871pub struct ColumnDef {
872 pub field: String,
873
874 pub width: u32,
875
876 pub align: Option<TextAlign>,
877
878 pub prefix: Option<String>,
879}
880
881#[derive(Debug, Clone, Deserialize)]
886#[non_exhaustive]
887pub struct EdgeInsets {
888 #[serde(default)]
889 pub top: u32,
890
891 #[serde(default)]
892 pub bottom: u32,
893
894 #[serde(default)]
895 pub left: u32,
896
897 #[serde(default)]
898 pub right: u32,
899}
900
901impl Default for EdgeInsets {
902 fn default() -> Self {
903 Self {
904 top: 0,
905 bottom: 0,
906 left: 0,
907 right: 0,
908 }
909 }
910}
911
912#[derive(Debug, Clone, Deserialize, Error)]
917pub enum RenderError {
918 #[error("invalid layout")]
919 InvalidLayout,
920
921 #[error("unknown element type")]
922 UnknownElement,
923
924 #[error("missing variable")]
925 MissingVariable,
926
927 #[error("render failed")]
928 RenderFailed,
929}
930
931#[derive(Debug, Clone, PartialEq, Deserialize)]
936pub enum DataValue {
937 Str(String),
938
939 Int(i64),
940
941 Float(f64),
942
943 Bool(bool),
944
945 List(Vec<DataValue>),
946
947 TileId(u16),
948}
949
950impl From<String> for DataValue {
955 fn from(s: String) -> Self {
956 DataValue::Str(s)
957 }
958}
959
960impl From<&str> for DataValue {
961 fn from(s: &str) -> Self {
962 DataValue::Str(s.to_string())
963 }
964}
965
966impl From<i64> for DataValue {
967 fn from(n: i64) -> Self {
968 DataValue::Int(n)
969 }
970}
971
972impl From<u16> for DataValue {
973 fn from(n: u16) -> Self {
974 DataValue::TileId(n)
975 }
976}
977
978impl From<bool> for DataValue {
979 fn from(b: bool) -> Self {
980 DataValue::Bool(b)
981 }
982}
983
984impl From<Vec<DataValue>> for DataValue {
985 fn from(v: Vec<DataValue>) -> Self {
986 DataValue::List(v)
987 }
988}
989
990#[derive(Debug, Clone)]
995#[non_exhaustive]
996pub struct RenderContext<'a> {
997 pub screen: &'a str,
998
999 pub theme: &'a Theme,
1000
1001 pub fonts: &'a FontRegistry,
1002
1003 pub tilesets: &'a TilesetRegistry,
1004
1005 pub images: &'a ImageRegistry,
1009}
1010
1011impl<'a> RenderContext<'a> {
1012 pub fn new(
1013 screen: &'a str,
1014 theme: &'a Theme,
1015 fonts: &'a FontRegistry,
1016 tilesets: &'a TilesetRegistry,
1017 ) -> Self {
1018 Self {
1019 screen,
1020 theme,
1021 fonts,
1022 tilesets,
1023 images: empty_image_registry(),
1024 }
1025 }
1026
1027 pub fn with_images(mut self, images: &'a ImageRegistry) -> Self {
1029 self.images = images;
1030 self
1031 }
1032}
1033
1034#[derive(Debug, Clone)]
1039#[non_exhaustive]
1040pub struct DataContext {
1041 pub(crate) values: HashMap<String, DataValue>,
1042}