Skip to main content

rustmotion_core/schema/
background.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::animation::EasingType;
5use super::scenario::default_transition_easing;
6use super::video::GradientType;
7
8/// Scroll direction for animated backgrounds.
9#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
10#[serde(rename_all = "snake_case")]
11pub enum ScrollDirection {
12    Up,
13    Down,
14    Left,
15    Right,
16    UpLeft,
17    UpRight,
18    DownLeft,
19    DownRight,
20    /// Clockwise rotation (gradient_shift only).
21    Cw,
22    /// Counter-clockwise rotation (gradient_shift only).
23    Ccw,
24}
25
26/// Config for the `gradient_shift` preset.
27#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
28pub struct GradientShiftConfig {
29    pub colors: Vec<String>,
30    #[serde(default = "default_bg_type")]
31    pub gradient_type: GradientType,
32}
33
34/// Config for the `grid_dots` preset.
35#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
36pub struct GridDotsConfig {
37    #[serde(default = "default_grid_dots_color")]
38    pub color: String,
39    #[serde(default = "default_bg_element_size")]
40    pub element_size: f32,
41    #[serde(default = "default_bg_spacing")]
42    pub spacing: f32,
43}
44
45fn default_grid_dots_color() -> String {
46    "#FFFFFF15".to_string()
47}
48
49/// Config for the `grid_lines` preset — a ruled grid, not a dotted one.
50///
51/// `grid_dots` marks the intersections and reads as texture; ruled lines read
52/// as structure, which is what a SaaS/data scene wants behind a chart or a
53/// code panel. Same scroll machinery (`x`/`y`/`speed`/`direction`) as the
54/// other tiled presets.
55#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
56pub struct GridLinesConfig {
57    /// Line colour (hex, alpha welcome).
58    #[serde(default = "default_grid_lines_color")]
59    pub color: String,
60    /// Cell edge in px. Small reads as graph paper, large as panels.
61    #[serde(default = "default_grid_lines_cell")]
62    pub cell: f32,
63    /// Line thickness in px.
64    #[serde(default = "default_grid_lines_weight")]
65    pub weight: f32,
66    /// Draw every Nth line at `major_weight` instead, for the
67    /// graph-paper look where the coarse grid reads through the fine one.
68    /// `0` (default) means no major lines.
69    #[serde(default)]
70    pub major_every: u32,
71    /// Thickness of a major line.
72    #[serde(default = "default_grid_lines_major_weight")]
73    pub major_weight: f32,
74}
75
76fn default_grid_lines_color() -> String {
77    "#FFFFFF14".to_string()
78}
79
80fn default_grid_lines_cell() -> f32 {
81    72.0
82}
83
84fn default_grid_lines_weight() -> f32 {
85    1.0
86}
87
88fn default_grid_lines_major_weight() -> f32 {
89    2.0
90}
91
92/// Config for the `concentric_circles` preset.
93#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
94pub struct ConcentricCirclesConfig {
95    #[serde(default = "default_concentric_color")]
96    pub color: String,
97    #[serde(default = "default_bg_element_size")]
98    pub element_size: f32,
99    #[serde(default = "default_bg_spacing")]
100    pub spacing: f32,
101    #[serde(default)]
102    pub count: Option<u32>,
103}
104
105fn default_concentric_color() -> String {
106    "#FFFFFF20".to_string()
107}
108
109/// Config for the `halo` preset.
110#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
111pub struct HaloConfig {
112    pub zones: Vec<HaloZone>,
113}
114
115/// Config for the `pixel_grid` preset: a lattice of square cells.
116///
117/// Covers two looks with one shape. `density: 1.0` with two colours gives a
118/// true checkerboard (cells alternate by `(row + col)` parity); a density
119/// below 1 with one colour gives the sparse tile field the reference piece
120/// uses — squares on a ground, some cells simply absent.
121#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
122pub struct PixelGridConfig {
123    /// Cell colours. One colour fills every drawn cell; several alternate by
124    /// `(row + col)`, which is what makes a checkerboard rather than a field.
125    #[serde(default = "default_pixel_colors")]
126    pub colors: Vec<String>,
127    /// Edge of a cell in px.
128    #[serde(default = "default_pixel_size")]
129    pub size: f32,
130    /// Lattice pitch in px — the distance between two cell origins. Clamped to
131    /// at least `size`, so cells never overlap; `spacing - size` is the gap.
132    #[serde(default = "default_pixel_spacing")]
133    pub spacing: f32,
134    /// Fraction of cells drawn, 0..1. Which cells is decided by a hash of the
135    /// cell's coordinates, so the pattern is stable from frame to frame — a
136    /// per-frame random would boil.
137    #[serde(default = "default_pixel_density")]
138    pub density: f32,
139    /// Where the field is densest. The reference piece ramps its density
140    /// across the frame rather than scattering uniformly, which is what stops
141    /// the texture reading as noise.
142    #[serde(default)]
143    pub density_ramp: PixelDensityRamp,
144    /// Corner radius of a cell in px. `0` for hard pixels.
145    #[serde(default)]
146    pub radius: f32,
147    /// Stable pattern selector: two backgrounds with the same seed and
148    /// geometry are identical, different seeds are different scatters.
149    #[serde(default = "default_pixel_seed")]
150    pub seed: u32,
151    /// How the field moves. `speed` on the background scales it.
152    #[serde(default)]
153    pub motion: PixelGridMotion,
154}
155
156/// Which way the fill density ramps across the frame.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
158#[serde(rename_all = "snake_case")]
159pub enum PixelDensityRamp {
160    /// Uniform: every cell has the same chance of being drawn.
161    #[default]
162    None,
163    Left,
164    Right,
165    Top,
166    Bottom,
167    /// Dense at the centre, thinning outwards.
168    Radial,
169    /// Dense at the frame's edges, thinning toward the centre — a vignette.
170    ///
171    /// Measured on the reference piece, in a band clear of its window, the
172    /// density runs 10.9 · 6.5 · 0.8 · 0.2 · 0.2 · 0.2 · 0.2 · 0.9 · 7.8 · 8.8 %
173    /// across the tenths of the frame: heavy at both edges, effectively empty
174    /// through the middle 60 %. That is what keeps the texture off whatever
175    /// sits in the centre, and `Radial` is its exact inverse.
176    Edges,
177}
178
179/// How a `pixel_grid` animates.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
181#[serde(rename_all = "snake_case")]
182pub enum PixelGridMotion {
183    /// Still. The lattice is a texture, not an effect.
184    #[default]
185    None,
186    /// Cells fade in and out on their own phase.
187    Twinkle,
188    /// A band of extra density travels across the field.
189    Sweep,
190}
191
192fn default_pixel_colors() -> Vec<String> {
193    vec!["#FFFFFF22".to_string()]
194}
195
196fn default_pixel_size() -> f32 {
197    10.0
198}
199
200fn default_pixel_spacing() -> f32 {
201    24.0
202}
203
204fn default_pixel_density() -> f32 {
205    0.6
206}
207
208fn default_pixel_seed() -> u32 {
209    7
210}
211
212/// Config for the `heropattern` preset.
213#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
214pub struct HeropatternConfig {
215    /// Name of the heropattern (e.g. "plus", "topography", "jigsaw").
216    pub pattern: String,
217    #[serde(default = "default_hero_color")]
218    pub color: String,
219    #[serde(default = "default_hero_opacity")]
220    pub opacity: f32,
221    #[serde(default = "default_hero_scale")]
222    pub scale: f32,
223}
224
225fn default_hero_color() -> String {
226    "#FFFFFF".to_string()
227}
228
229fn default_hero_opacity() -> f32 {
230    0.1
231}
232
233fn default_hero_scale() -> f32 {
234    1.0
235}
236
237#[derive(Debug, Clone)]
238pub enum BackgroundPreset {
239    GradientShift(GradientShiftConfig),
240    GridDots(GridDotsConfig),
241    GridLines(GridLinesConfig),
242    ConcentricCircles(ConcentricCirclesConfig),
243    Halo(HaloConfig),
244    PixelGrid(PixelGridConfig),
245    Heropattern(HeropatternConfig),
246}
247
248impl BackgroundPreset {
249    pub fn name(&self) -> &'static str {
250        match self {
251            BackgroundPreset::GradientShift(_) => "gradient_shift",
252            BackgroundPreset::GridDots(_) => "grid_dots",
253            BackgroundPreset::GridLines(_) => "grid_lines",
254            BackgroundPreset::ConcentricCircles(_) => "concentric_circles",
255            BackgroundPreset::Halo(_) => "halo",
256            BackgroundPreset::PixelGrid(_) => "pixel_grid",
257            BackgroundPreset::Heropattern(_) => "heropattern",
258        }
259    }
260}
261
262#[derive(Debug, Clone)]
263pub struct AnimatedBackground {
264    pub preset: BackgroundPreset,
265    pub x: f32,
266    pub y: f32,
267    pub speed: f32,
268    pub direction: Option<ScrollDirection>,
269}
270
271impl Serialize for AnimatedBackground {
272    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
273        use serde::ser::SerializeMap;
274        let mut map = serializer.serialize_map(None)?;
275        map.serialize_entry("preset", self.preset.name())?;
276        match &self.preset {
277            BackgroundPreset::GradientShift(cfg) => map.serialize_entry("gradient_shift", cfg)?,
278            BackgroundPreset::GridDots(cfg) => map.serialize_entry("grid_dots", cfg)?,
279            BackgroundPreset::GridLines(cfg) => map.serialize_entry("grid_lines", cfg)?,
280            BackgroundPreset::ConcentricCircles(cfg) => {
281                map.serialize_entry("concentric_circles", cfg)?
282            }
283            BackgroundPreset::Halo(cfg) => map.serialize_entry("halo", cfg)?,
284            BackgroundPreset::PixelGrid(cfg) => map.serialize_entry("pixel_grid", cfg)?,
285            BackgroundPreset::Heropattern(cfg) => map.serialize_entry("heropattern", cfg)?,
286        }
287        map.serialize_entry("speed", &self.speed)?;
288        if self.x != 0.0 {
289            map.serialize_entry("x", &self.x)?;
290        }
291        if self.y != 0.0 {
292            map.serialize_entry("y", &self.y)?;
293        }
294        if let Some(ref dir) = self.direction {
295            map.serialize_entry("direction", dir)?;
296        }
297        map.end()
298    }
299}
300
301const KNOWN_BACKGROUND_PRESETS: &[&str] = &[
302    "gradient_shift",
303    "grid_dots",
304    "grid_lines",
305    "concentric_circles",
306    "halo",
307    "pixel_grid",
308    "heropattern",
309];
310
311impl<'de> Deserialize<'de> for AnimatedBackground {
312    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
313        let map: serde_json::Map<String, serde_json::Value> =
314            serde_json::Map::deserialize(deserializer)?;
315
316        let x = map.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
317        let y = map.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
318        let direction: Option<ScrollDirection> = match map.get("direction") {
319            Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| {
320                serde::de::Error::custom(format!("animated-background.direction: {e}"))
321            })?),
322            None => None,
323        };
324
325        let preset_str = map.get("preset").and_then(|v| v.as_str()).unwrap_or("");
326        if !KNOWN_BACKGROUND_PRESETS.contains(&preset_str) {
327            return Err(serde::de::Error::custom(format!(
328                "unknown animated-background preset '{preset_str}': expected one of {}",
329                KNOWN_BACKGROUND_PRESETS.join(", ")
330            )));
331        }
332
333        let is_new_format = map.get(preset_str).is_some_and(|v| v.is_object());
334
335        let (preset, speed) = if is_new_format {
336            let sub = map.get(preset_str).unwrap().clone();
337            let speed = map.get("speed").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
338            let preset = deserialize_preset_config::<D::Error>(preset_str, sub)?;
339            (preset, speed)
340        } else {
341            let legacy_speed = map.get("speed").and_then(|v| v.as_f64()).unwrap_or(30.0) as f32;
342            let preset = match preset_str {
343                "grid_dots" => {
344                    let color = map
345                        .get("colors")
346                        .and_then(|v| v.as_array())
347                        .and_then(|a| a.first())
348                        .and_then(|v| v.as_str())
349                        .unwrap_or("#FFFFFF15")
350                        .to_string();
351                    let element_size = map
352                        .get("element_size")
353                        .and_then(|v| v.as_f64())
354                        .unwrap_or(4.0) as f32;
355                    let spacing =
356                        map.get("spacing").and_then(|v| v.as_f64()).unwrap_or(60.0) as f32;
357                    BackgroundPreset::GridDots(GridDotsConfig {
358                        color,
359                        element_size,
360                        spacing,
361                    })
362                }
363                "concentric_circles" => {
364                    let color = map
365                        .get("colors")
366                        .and_then(|v| v.as_array())
367                        .and_then(|a| a.first())
368                        .and_then(|v| v.as_str())
369                        .unwrap_or("#FFFFFF20")
370                        .to_string();
371                    let element_size = map
372                        .get("element_size")
373                        .and_then(|v| v.as_f64())
374                        .unwrap_or(4.0) as f32;
375                    let spacing =
376                        map.get("spacing").and_then(|v| v.as_f64()).unwrap_or(60.0) as f32;
377                    let count = map.get("count").and_then(|v| v.as_u64()).map(|n| n as u32);
378                    BackgroundPreset::ConcentricCircles(ConcentricCirclesConfig {
379                        color,
380                        element_size,
381                        spacing,
382                        count,
383                    })
384                }
385                "halo" => {
386                    let mut obj = serde_json::Map::new();
387                    if let Some(z) = map.get("zones") {
388                        obj.insert("zones".to_string(), z.clone());
389                    }
390                    let cfg: HaloConfig = serde_json::from_value(serde_json::Value::Object(obj))
391                        .map_err(|e| {
392                            serde::de::Error::custom(format!("animated-background.zones: {e}"))
393                        })?;
394                    BackgroundPreset::Halo(cfg)
395                }
396                "heropattern" => {
397                    let mut obj = serde_json::Map::new();
398                    for key in ["pattern", "color", "opacity", "scale"] {
399                        if let Some(v) = map.get(key) {
400                            obj.insert(key.to_string(), v.clone());
401                        }
402                    }
403                    let cfg: HeropatternConfig =
404                        serde_json::from_value(serde_json::Value::Object(obj)).map_err(|e| {
405                            serde::de::Error::custom(format!(
406                                "animated-background.heropattern: {e}"
407                            ))
408                        })?;
409                    BackgroundPreset::Heropattern(cfg)
410                }
411                "gradient_shift" => {
412                    let mut obj = serde_json::Map::new();
413                    if let Some(c) = map.get("colors") {
414                        obj.insert("colors".to_string(), c.clone());
415                    }
416                    if let Some(g) = map.get("gradient_type") {
417                        obj.insert("gradient_type".to_string(), g.clone());
418                    }
419                    let cfg: GradientShiftConfig =
420                        serde_json::from_value(serde_json::Value::Object(obj)).map_err(|e| {
421                            serde::de::Error::custom(format!(
422                                "animated-background.colors/gradient_type: {e}"
423                            ))
424                        })?;
425                    BackgroundPreset::GradientShift(cfg)
426                }
427                other => {
428                    return Err(serde::de::Error::custom(format!(
429                        "internal error: unhandled animated-background preset '{other}'"
430                    )))
431                }
432            };
433            (preset, legacy_speed)
434        };
435
436        let direction = direction.or({
437            if speed > 0.0 && !is_new_format {
438                match &preset {
439                    BackgroundPreset::GradientShift(_) => Some(ScrollDirection::Cw),
440                    BackgroundPreset::GridDots(_) => Some(ScrollDirection::Up),
441                    _ => None,
442                }
443            } else {
444                None
445            }
446        });
447
448        Ok(AnimatedBackground {
449            preset,
450            x,
451            y,
452            speed,
453            direction,
454        })
455    }
456}
457
458fn deserialize_preset_config<E: serde::de::Error>(
459    preset_str: &str,
460    sub: serde_json::Value,
461) -> Result<BackgroundPreset, E> {
462    match preset_str {
463        "grid_dots" => Ok(BackgroundPreset::GridDots(
464            serde_json::from_value(sub).map_err(E::custom)?,
465        )),
466        "grid_lines" => Ok(BackgroundPreset::GridLines(
467            serde_json::from_value(sub).map_err(E::custom)?,
468        )),
469        "concentric_circles" => Ok(BackgroundPreset::ConcentricCircles(
470            serde_json::from_value(sub).map_err(E::custom)?,
471        )),
472        "halo" => Ok(BackgroundPreset::Halo(
473            serde_json::from_value(sub).map_err(E::custom)?,
474        )),
475        "pixel_grid" => Ok(BackgroundPreset::PixelGrid(
476            serde_json::from_value(sub).map_err(E::custom)?,
477        )),
478        "heropattern" => Ok(BackgroundPreset::Heropattern(
479            serde_json::from_value(sub).map_err(E::custom)?,
480        )),
481        "gradient_shift" => Ok(BackgroundPreset::GradientShift(
482            serde_json::from_value(sub).map_err(E::custom)?,
483        )),
484        other => Err(E::custom(format!(
485            "internal error: unhandled animated-background preset '{other}'"
486        ))),
487    }
488}
489
490impl JsonSchema for AnimatedBackground {
491    fn schema_name() -> String {
492        "AnimatedBackground".to_string()
493    }
494
495    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
496        use schemars::schema::*;
497
498        let mut props = schemars::Map::new();
499        props.insert("preset".to_string(), gen.subschema_for::<String>());
500        props.insert("x".to_string(), gen.subschema_for::<f32>());
501        props.insert("y".to_string(), gen.subschema_for::<f32>());
502        props.insert("speed".to_string(), gen.subschema_for::<f32>());
503        props.insert(
504            "direction".to_string(),
505            gen.subschema_for::<Option<ScrollDirection>>(),
506        );
507        props.insert(
508            "gradient_shift".to_string(),
509            gen.subschema_for::<Option<GradientShiftConfig>>(),
510        );
511        props.insert(
512            "grid_dots".to_string(),
513            gen.subschema_for::<Option<GridDotsConfig>>(),
514        );
515        props.insert(
516            "grid_lines".to_string(),
517            gen.subschema_for::<Option<GridLinesConfig>>(),
518        );
519        props.insert(
520            "concentric_circles".to_string(),
521            gen.subschema_for::<Option<ConcentricCirclesConfig>>(),
522        );
523        props.insert(
524            "halo".to_string(),
525            gen.subschema_for::<Option<HaloConfig>>(),
526        );
527        props.insert(
528            "heropattern".to_string(),
529            gen.subschema_for::<Option<HeropatternConfig>>(),
530        );
531
532        SchemaObject {
533            instance_type: Some(InstanceType::Object.into()),
534            object: Some(Box::new(ObjectValidation {
535                properties: props,
536                required: ["preset".to_string()].into_iter().collect(),
537                ..Default::default()
538            })),
539            ..Default::default()
540        }
541        .into()
542    }
543}
544
545/// A single glow zone for the "halo" animated-background preset.
546#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
547pub struct HaloZone {
548    /// Zone color (hex string). May itself carry an alpha channel
549    /// (`#rrggbbaa`); see [`HaloZone::opacity`] for how the two combine.
550    pub color: String,
551    /// X position as a fraction of the surface the halo is painted on:
552    /// the viewport in a `slide` view, the world the camera travels in a
553    /// `world` view (`WorldTimeline::world_extent`). 0.0 = left, 1.0 = right.
554    #[serde(default = "default_half")]
555    pub x: f32,
556    /// Y position as a fraction of that same surface. 0.0 = top, 1.0 = bottom.
557    #[serde(default = "default_half")]
558    pub y: f32,
559    /// Radius as a fraction of that surface's `max(width, height)` — so the
560    /// same value covers proportionally the same area whichever view it is in.
561    /// This is also the fallback for `radius_x`/`radius_y` when either is
562    /// omitted, which is what keeps a zone written before those fields
563    /// existed a perfect, unrotated circle.
564    #[serde(default = "default_halo_radius")]
565    pub radius: f32,
566    /// Horizontal radius, same fraction-of-surface units as
567    /// [`HaloZone::radius`]. Omitted (the default) falls back to `radius`.
568    /// Set it together with `radius_y` to draw an ellipse instead of a
569    /// circle — a wide, thin light band wants `radius_x` far larger than
570    /// `radius_y`.
571    #[serde(default)]
572    pub radius_x: Option<f32>,
573    /// Vertical radius, same fraction-of-surface units as
574    /// [`HaloZone::radius`]. Omitted (the default) falls back to `radius` —
575    /// see [`HaloZone::radius_x`].
576    #[serde(default)]
577    pub radius_y: Option<f32>,
578    /// Rotation of the ellipse in degrees, clockwise about its own center.
579    /// Ignored on a circular zone (`radius_x == radius_y`, which includes
580    /// every zone that only sets `radius`) — a rotated circle is a circle,
581    /// so it is never worth the extra draw call.
582    #[serde(default)]
583    pub rotation: f32,
584    /// Zone opacity, multiplied with any alpha already encoded in `color`.
585    ///
586    /// Default `1.0` is a true no-op: it leaves `color`'s own alpha (opaque
587    /// or hex-encoded) untouched, so scenarios written before this field
588    /// existed — including ones that hid alpha inside the hex string, e.g.
589    /// `#1E3A8A55` — keep rendering identically. Values are clamped to
590    /// `0.0..=1.0`.
591    #[serde(default = "default_halo_opacity")]
592    pub opacity: f32,
593}
594
595impl HaloZone {
596    pub fn effective_radius_x(&self) -> f32 {
597        self.radius_x.unwrap_or(self.radius)
598    }
599
600    pub fn effective_radius_y(&self) -> f32 {
601        self.radius_y.unwrap_or(self.radius)
602    }
603
604    pub fn is_circular(&self) -> bool {
605        self.effective_radius_x() == self.effective_radius_y()
606    }
607}
608
609/// Transition configuration for background interpolation between scenes.
610#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
611pub struct BackgroundTransition {
612    pub duration: f64,
613    #[serde(default = "default_transition_easing")]
614    pub easing: EasingType,
615}
616
617#[derive(Debug, Clone, Serialize, Deserialize)]
618pub struct BackgroundEntry {
619    #[serde(rename = "$ref", default)]
620    pub template_ref: Option<String>,
621    #[serde(default)]
622    pub transition: Option<BackgroundTransition>,
623    #[serde(flatten)]
624    pub overrides: serde_json::Map<String, serde_json::Value>,
625}
626
627impl JsonSchema for BackgroundEntry {
628    fn schema_name() -> String {
629        "BackgroundEntry".to_string()
630    }
631
632    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
633        use schemars::schema::*;
634
635        let mut props = schemars::Map::new();
636        props.insert("$ref".to_string(), gen.subschema_for::<Option<String>>());
637        props.insert(
638            "transition".to_string(),
639            gen.subschema_for::<Option<BackgroundTransition>>(),
640        );
641
642        SchemaObject {
643            instance_type: Some(InstanceType::Object.into()),
644            object: Some(Box::new(ObjectValidation {
645                properties: props,
646                additional_properties: Some(Box::new(Schema::Bool(true))),
647                ..Default::default()
648            })),
649            ..Default::default()
650        }
651        .into()
652    }
653}
654
655#[derive(Debug, Clone)]
656pub enum BackgroundValue {
657    Color(String),
658    Single(BackgroundEntry),
659    Multiple(Vec<BackgroundEntry>),
660}
661
662impl Serialize for BackgroundValue {
663    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
664    where
665        S: serde::Serializer,
666    {
667        match self {
668            BackgroundValue::Color(s) => serializer.serialize_str(s),
669            BackgroundValue::Single(entry) => entry.serialize(serializer),
670            BackgroundValue::Multiple(entries) => entries.serialize(serializer),
671        }
672    }
673}
674
675impl JsonSchema for BackgroundValue {
676    fn schema_name() -> String {
677        "BackgroundValue".to_string()
678    }
679
680    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
681        use schemars::schema::*;
682
683        let string_schema = gen.subschema_for::<String>();
684        let entry_schema = gen.subschema_for::<BackgroundEntry>();
685        let array_schema: Schema = SchemaObject {
686            instance_type: Some(InstanceType::Array.into()),
687            array: Some(Box::new(ArrayValidation {
688                items: Some(SingleOrVec::Single(Box::new(entry_schema.clone()))),
689                ..Default::default()
690            })),
691            ..Default::default()
692        }
693        .into();
694
695        SchemaObject {
696            subschemas: Some(Box::new(SubschemaValidation {
697                one_of: Some(vec![string_schema, entry_schema, array_schema]),
698                ..Default::default()
699            })),
700            ..Default::default()
701        }
702        .into()
703    }
704}
705
706#[derive(Debug, Clone, Default, Serialize)]
707pub struct ResolvedBackground {
708    pub color: Option<String>,
709    pub animated: Vec<AnimatedBackground>,
710    pub transition: Option<BackgroundTransition>,
711}
712
713fn default_half() -> f32 {
714    0.5
715}
716
717fn default_halo_radius() -> f32 {
718    0.4
719}
720
721fn default_halo_opacity() -> f32 {
722    1.0
723}
724
725fn default_bg_element_size() -> f32 {
726    4.0
727}
728
729fn default_bg_spacing() -> f32 {
730    60.0
731}
732
733#[allow(dead_code)]
734fn default_bg_speed() -> f32 {
735    30.0
736}
737
738fn default_bg_type() -> GradientType {
739    GradientType::Linear
740}
741
742pub(crate) fn deserialize_animated_backgrounds<'de, D>(
743    deserializer: D,
744) -> Result<Vec<AnimatedBackground>, D::Error>
745where
746    D: serde::Deserializer<'de>,
747{
748    use serde::de;
749
750    struct OneOrMany;
751
752    impl<'de> de::Visitor<'de> for OneOrMany {
753        type Value = Vec<AnimatedBackground>;
754
755        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
756            f.write_str("a single animated background or an array of animated backgrounds")
757        }
758
759        fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
760        where
761            A: de::SeqAccess<'de>,
762        {
763            Vec::deserialize(de::value::SeqAccessDeserializer::new(seq))
764        }
765
766        fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
767        where
768            M: de::MapAccess<'de>,
769        {
770            let bg = AnimatedBackground::deserialize(de::value::MapAccessDeserializer::new(map))?;
771            Ok(vec![bg])
772        }
773    }
774
775    deserializer.deserialize_any(OneOrMany)
776}
777
778pub(crate) fn deserialize_background_value<'de, D>(
779    deserializer: D,
780) -> Result<Option<BackgroundValue>, D::Error>
781where
782    D: serde::Deserializer<'de>,
783{
784    use serde::de;
785
786    struct BgVisitor;
787
788    impl<'de> de::Visitor<'de> for BgVisitor {
789        type Value = Option<BackgroundValue>;
790
791        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
792            f.write_str("a color string, a background object, or an array of background objects")
793        }
794
795        fn visit_none<E>(self) -> Result<Self::Value, E>
796        where
797            E: de::Error,
798        {
799            Ok(None)
800        }
801
802        fn visit_unit<E>(self) -> Result<Self::Value, E>
803        where
804            E: de::Error,
805        {
806            Ok(None)
807        }
808
809        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
810        where
811            E: de::Error,
812        {
813            Ok(Some(BackgroundValue::Color(v.to_string())))
814        }
815
816        fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
817        where
818            E: de::Error,
819        {
820            Ok(Some(BackgroundValue::Color(v)))
821        }
822
823        fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
824        where
825            M: de::MapAccess<'de>,
826        {
827            let entry = BackgroundEntry::deserialize(de::value::MapAccessDeserializer::new(map))?;
828            Ok(Some(BackgroundValue::Single(entry)))
829        }
830
831        fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
832        where
833            A: de::SeqAccess<'de>,
834        {
835            let entries =
836                Vec::<BackgroundEntry>::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
837            Ok(Some(BackgroundValue::Multiple(entries)))
838        }
839    }
840
841    deserializer.deserialize_any(BgVisitor)
842}
843
844#[cfg(test)]
845mod halo_zone_opacity_tests {
846    use super::*;
847
848    #[test]
849    fn halo_zone_opacity_defaults_to_1_when_omitted() {
850        let zone: HaloZone =
851            serde_json::from_value(serde_json::json!({ "color": "#1E3A8A55" })).unwrap();
852        assert_eq!(zone.opacity, 1.0);
853    }
854
855    #[test]
856    fn halo_zone_opacity_respects_explicit_value() {
857        let zone: HaloZone = serde_json::from_value(serde_json::json!({
858            "color": "#1E3A8A",
859            "opacity": 0.35
860        }))
861        .unwrap();
862        assert_eq!(zone.opacity, 0.35);
863    }
864
865    #[test]
866    fn halo_zone_serializes_opacity() {
867        let zone = HaloZone {
868            color: "#1E3A8A".to_string(),
869            x: 0.5,
870            y: 0.5,
871            radius: 0.4,
872            radius_x: None,
873            radius_y: None,
874            rotation: 0.0,
875            opacity: 0.6,
876        };
877        let v = serde_json::to_value(&zone).unwrap();
878        let got = v["opacity"]
879            .as_f64()
880            .expect("opacity must serialize as a number");
881        assert!((got - 0.6).abs() < 1e-6, "got {got}");
882    }
883
884    #[test]
885    fn animated_background_new_format_halo_zone_defaults_opacity() {
886        let bg: AnimatedBackground = serde_json::from_value(serde_json::json!({
887            "preset": "halo",
888            "halo": { "zones": [{ "color": "#1E3A8A55", "x": 0.5, "y": 0.5, "radius": 0.4 }] },
889            "speed": 0
890        }))
891        .unwrap();
892        match bg.preset {
893            BackgroundPreset::Halo(cfg) => {
894                assert_eq!(cfg.zones.len(), 1);
895                assert_eq!(cfg.zones[0].opacity, 1.0);
896                assert_eq!(cfg.zones[0].color, "#1E3A8A55");
897            }
898            _ => panic!("expected Halo preset"),
899        }
900    }
901
902    #[test]
903    fn animated_background_legacy_flat_format_halo_zone_defaults_opacity() {
904        let bg: AnimatedBackground = serde_json::from_value(serde_json::json!({
905            "preset": "halo",
906            "zones": [{ "color": "#1E3A8A55", "x": 0.5, "y": 0.5, "radius": 0.4 }]
907        }))
908        .unwrap();
909        match bg.preset {
910            BackgroundPreset::Halo(cfg) => {
911                assert_eq!(cfg.zones[0].opacity, 1.0);
912            }
913            _ => panic!("expected Halo preset"),
914        }
915    }
916}
917
918#[cfg(test)]
919mod halo_zone_ellipse_tests {
920    use super::*;
921
922    fn zone_with_radius_only(radius: f32) -> HaloZone {
923        serde_json::from_value(serde_json::json!({ "color": "#FFFFFF", "radius": radius })).unwrap()
924    }
925
926    #[test]
927    fn radius_x_and_radius_y_default_to_none_when_omitted() {
928        let zone = zone_with_radius_only(0.4);
929        assert_eq!(zone.radius_x, None);
930        assert_eq!(zone.radius_y, None);
931        assert_eq!(zone.rotation, 0.0);
932    }
933
934    #[test]
935    fn effective_radius_falls_back_to_radius_when_axis_radii_are_absent() {
936        let zone = zone_with_radius_only(0.4);
937        assert_eq!(zone.effective_radius_x(), 0.4);
938        assert_eq!(zone.effective_radius_y(), 0.4);
939    }
940
941    #[test]
942    fn effective_radius_honours_explicit_axis_values() {
943        let zone: HaloZone = serde_json::from_value(serde_json::json!({
944            "color": "#FFFFFF",
945            "radius": 0.4,
946            "radius_x": 0.8,
947            "radius_y": 0.05
948        }))
949        .unwrap();
950        assert_eq!(zone.effective_radius_x(), 0.8);
951        assert_eq!(zone.effective_radius_y(), 0.05);
952    }
953
954    #[test]
955    fn a_radius_only_zone_is_circular() {
956        assert!(zone_with_radius_only(0.4).is_circular());
957    }
958
959    #[test]
960    fn explicit_equal_radius_x_and_radius_y_is_still_circular() {
961        let zone: HaloZone = serde_json::from_value(serde_json::json!({
962            "color": "#FFFFFF",
963            "radius": 0.4,
964            "radius_x": 0.4,
965            "radius_y": 0.4
966        }))
967        .unwrap();
968        assert!(zone.is_circular());
969    }
970
971    #[test]
972    fn differing_axis_radii_are_not_circular() {
973        let zone: HaloZone = serde_json::from_value(serde_json::json!({
974            "color": "#FFFFFF",
975            "radius": 0.4,
976            "radius_x": 0.8,
977            "radius_y": 0.1
978        }))
979        .unwrap();
980        assert!(!zone.is_circular());
981    }
982
983    #[test]
984    fn a_nonzero_rotation_does_not_affect_circularity() {
985        let zone: HaloZone = serde_json::from_value(serde_json::json!({
986            "color": "#FFFFFF",
987            "radius": 0.4,
988            "rotation": 45.0
989        }))
990        .unwrap();
991        assert!(
992            zone.is_circular(),
993            "rotation has no visible effect on a circle, so it must not change how it renders"
994        );
995    }
996
997    #[test]
998    fn rotation_defaults_to_zero_degrees() {
999        let zone: HaloZone = serde_json::from_value(serde_json::json!({
1000            "color": "#FFFFFF",
1001            "radius": 0.4,
1002            "radius_x": 0.8,
1003            "radius_y": 0.1
1004        }))
1005        .unwrap();
1006        assert_eq!(zone.rotation, 0.0);
1007    }
1008}
1009
1010#[cfg(test)]
1011mod animated_background_silent_sink_tests {
1012    use super::*;
1013    use serde_json::json;
1014
1015    #[test]
1016    fn known_preset_gradient_shift_still_works() {
1017        let bg: AnimatedBackground = serde_json::from_value(json!({
1018            "preset": "gradient_shift",
1019            "colors": ["#111111", "#222222"],
1020            "gradient_type": "radial",
1021            "speed": 10
1022        }))
1023        .unwrap();
1024        match bg.preset {
1025            BackgroundPreset::GradientShift(cfg) => {
1026                assert_eq!(cfg.colors, vec!["#111111", "#222222"]);
1027                assert!(matches!(cfg.gradient_type, GradientType::Radial));
1028            }
1029            other => panic!("expected GradientShift, got {other:?}"),
1030        }
1031    }
1032
1033    #[test]
1034    fn unknown_preset_name_is_a_named_error_not_a_silent_black_gradient() {
1035        let err = serde_json::from_value::<AnimatedBackground>(json!({
1036            "preset": "starfield",
1037            "speed": 10
1038        }))
1039        .expect_err("an unknown preset must be rejected, not silently treated as gradient_shift");
1040        let msg = err.to_string();
1041        assert!(
1042            msg.contains("starfield"),
1043            "error must name the offending preset value, got: {msg}"
1044        );
1045    }
1046
1047    #[test]
1048    fn missing_preset_key_is_a_named_error() {
1049        let err = serde_json::from_value::<AnimatedBackground>(json!({ "speed": 10 }))
1050            .expect_err("a missing `preset` must be rejected, not silently treated as gradient_shift with colors: []");
1051        assert!(
1052            err.to_string().to_lowercase().contains("preset"),
1053            "got: {err}"
1054        );
1055    }
1056
1057    #[test]
1058    fn legacy_halo_zones_still_work() {
1059        let bg: AnimatedBackground = serde_json::from_value(json!({
1060            "preset": "halo",
1061            "zones": [{ "color": "#1E3A8A", "x": 0.1, "y": 0.2, "radius": 0.3 }]
1062        }))
1063        .unwrap();
1064        match bg.preset {
1065            BackgroundPreset::Halo(cfg) => assert_eq!(cfg.zones.len(), 1),
1066            other => panic!("expected Halo, got {other:?}"),
1067        }
1068    }
1069
1070    #[test]
1071    fn legacy_halo_malformed_zones_is_a_named_error_not_a_silent_empty_zones() {
1072        let err = serde_json::from_value::<AnimatedBackground>(json!({
1073            "preset": "halo",
1074            "zones": [{ "color": "#1E3A8A", "x": "not-a-number" }]
1075        }))
1076        .expect_err("a malformed zones entry must be rejected, not silently emptied");
1077        assert!(
1078            err.to_string().contains("zones") || err.to_string().contains("x"),
1079            "error should point at the offending field, got: {err}"
1080        );
1081    }
1082
1083    #[test]
1084    fn legacy_halo_missing_zones_is_a_named_error_not_a_silent_empty_zones() {
1085        let err = serde_json::from_value::<AnimatedBackground>(json!({ "preset": "halo" }))
1086            .expect_err("missing zones must be rejected, not silently treated as an empty halo");
1087        assert!(err.to_string().contains("zones"), "got: {err}");
1088    }
1089
1090    #[test]
1091    fn legacy_gradient_shift_missing_colors_is_a_named_error_not_a_silent_black_gradient() {
1092        let err = serde_json::from_value::<AnimatedBackground>(json!({
1093            "preset": "gradient_shift",
1094            "speed": 5
1095        }))
1096        .expect_err("missing colors must error, not silently produce an empty (black) gradient");
1097        assert!(err.to_string().contains("colors"), "got: {err}");
1098    }
1099
1100    #[test]
1101    fn legacy_heropattern_is_recognised_not_silently_turned_into_gradient_shift() {
1102        let bg: AnimatedBackground = serde_json::from_value(json!({
1103            "preset": "heropattern",
1104            "pattern": "plus",
1105            "color": "#ffffff",
1106            "opacity": 0.2,
1107            "scale": 1.5
1108        }))
1109        .unwrap();
1110        match bg.preset {
1111            BackgroundPreset::Heropattern(cfg) => {
1112                assert_eq!(cfg.pattern, "plus");
1113                assert_eq!(cfg.scale, 1.5);
1114            }
1115            other => panic!("expected Heropattern, got {other:?}"),
1116        }
1117    }
1118
1119    #[test]
1120    fn legacy_heropattern_missing_pattern_is_a_named_error() {
1121        let err = serde_json::from_value::<AnimatedBackground>(json!({
1122            "preset": "heropattern"
1123        }))
1124        .expect_err("heropattern with no pattern name must error");
1125        assert!(err.to_string().contains("pattern"), "got: {err}");
1126    }
1127
1128    #[test]
1129    fn direction_typo_is_a_named_error_not_a_silently_dropped_none() {
1130        let err = serde_json::from_value::<AnimatedBackground>(json!({
1131            "preset": "grid_dots",
1132            "colors": ["#fff"],
1133            "direction": "diagonal"
1134        }))
1135        .expect_err("an unrecognised direction must be rejected, not silently dropped to None");
1136        assert!(err.to_string().contains("direction"), "got: {err}");
1137    }
1138
1139    #[test]
1140    fn direction_still_works_when_valid() {
1141        let bg: AnimatedBackground = serde_json::from_value(json!({
1142            "preset": "grid_dots",
1143            "colors": ["#fff"],
1144            "direction": "up"
1145        }))
1146        .unwrap();
1147        assert!(matches!(bg.direction, Some(ScrollDirection::Up)));
1148    }
1149}