pane_ui 0.1.0

A RON-driven, hot-reloadable wgpu UI library with spring animations and consistent scaling
Documentation
// ── Style Registry ────────────────────────────────────────────────────────────
//
// Loads RON style definitions and registers them into `StyleRegistry`.
//
// Each RON style defines idle/hovered/pressed/disabled visual states.
// These map directly to a `Style` with four `VisualState`s.
//
// In release builds (default), built-in styles are baked in via include_str!.
// In dev builds (--features dev), built-in styles are loaded from disk.
// User style dirs are always scanned at runtime.

use crate::draw::{Color, ShaderId, TextAlign};
use crate::styles::{Shape, Style, StyleId, StyleRegistry, VisualState};
use crate::textures::{GifMode, TextureId};
use serde::Deserialize;
use std::collections::HashMap;

// ── RON Style Definition ──────────────────────────────────────────────────────

#[derive(Deserialize)]
pub struct StyleDef {
    pub shader: String,
    pub idle: VisualStateDef,
    pub hovered: VisualStateDef,
    pub pressed: VisualStateDef,
    pub disabled: VisualStateDef,
}

#[derive(Deserialize)]
pub struct VisualStateDef {
    pub shape: RonShape,
    pub color: Color,
    pub corner_radius: f32,
    pub border_width: f32,
    pub border_color: Color,
    pub highlight_color: Color,
    pub shadow_size: f32,
    pub shadow_color: Color,
    pub opacity: f32,
    pub scale: f32,
    #[serde(default)]
    pub shader: Option<String>,
    #[serde(default)]
    pub texture: Option<TextureDef>,
    #[serde(default = "default_cast_shadow")]
    pub cast_shadow: bool,
    #[serde(default)]
    pub text_color: Option<Color>,
    #[serde(default)]
    pub font_size: Option<f32>,
    #[serde(default)]
    pub text_align: TextAlign,
    #[serde(default)]
    pub text_offset_x: f32,
    #[serde(default)]
    pub text_offset_y: f32,
    #[serde(default)]
    pub font: Option<String>,
    #[serde(default)]
    pub bold: bool,
    #[serde(default)]
    pub italic: bool,
}

/// The shape variants that appear in RON. Mirrors `styles::Shape` exactly;
/// kept separate so RON files don't depend on internal naming.
#[derive(Deserialize, Clone, Copy)]
pub enum RonShape {
    Rectangle,
    RoundedRectangle,
    Pill,
    Circle,
}

impl From<RonShape> for Shape {
    fn from(r: RonShape) -> Self {
        match r {
            RonShape::Rectangle => Self::Rectangle,
            RonShape::RoundedRectangle => Self::RoundedRectangle,
            RonShape::Pill => Self::Pill,
            RonShape::Circle => Self::Circle,
        }
    }
}

const fn default_cast_shadow() -> bool {
    true
}

#[derive(Deserialize)]
pub struct TextureDef {
    pub path: String,
    #[serde(default)]
    pub gif_mode: Option<GifMode>,
}

// ── VisualStateDef → VisualState ──────────────────────────────────────────────

impl VisualStateDef {
    fn to_visual_state(
        &self,
        shader_map: &HashMap<String, ShaderId>,
        texture_loader: &mut dyn FnMut(&str, Option<GifMode>) -> Option<TextureId>,
        fallback_shader: ShaderId,
        style_name: &str,
        state_name: &str,
    ) -> VisualState {
        let shader = self.shader.as_ref().and_then(|s| if let Some(&id) = shader_map.get(s) { Some(id) } else {
            eprintln!(
                "[pane_ui] Style '{style_name}' state '{state_name}' references unknown shader '{s}', ignoring"
            );
            None
        });

        let texture = self.texture.as_ref().and_then(|t| {
            let gif_mode = if t.path.to_lowercase().ends_with(".gif") {
                Some(t.gif_mode.unwrap_or(GifMode::Loop))
            } else {
                None
            };
            texture_loader(&t.path, gif_mode)
        });

        VisualState {
            shape: self.shape.into(),
            color: self.color,
            corner_radius: self.corner_radius,
            border_width: self.border_width,
            border_color: self.border_color,
            highlight_color: self.highlight_color,
            shadow_size: self.shadow_size,
            shadow_color: self.shadow_color,
            opacity: self.opacity,
            scale: self.scale,
            shader: shader.or(Some(fallback_shader)),
            texture,
            cast_shadow: self.cast_shadow,
            text_color: self.text_color,
            font_size: self.font_size,
            text_align: self.text_align,
            text_offset_x: self.text_offset_x,
            text_offset_y: self.text_offset_y,
            font: self.font.clone(),
            bold: self.bold,
            italic: self.italic,
        }
    }
}

// ── Built-in Style Names ──────────────────────────────────────────────────────

pub const BUILTIN_STYLE_NAMES: &[&str] = &[
    "plain",
    "frosted_glass",
    "retro",
    "sharp_outline",
    "glass_pill",
    "emboss",
];

// ── Release Mode — baked in ───────────────────────────────────────────────────

#[cfg(not(feature = "dev"))]
pub fn load_builtin_style(name: &str) -> String {
    match name {
        "plain" => include_str!("../styles/plain.ron").to_string(),
        "frosted_glass" => include_str!("../styles/frosted_glass.ron").to_string(),
        "retro" => include_str!("../styles/retro.ron").to_string(),
        "sharp_outline" => include_str!("../styles/sharp_outline.ron").to_string(),
        "glass_pill" => include_str!("../styles/glass_pill.ron").to_string(),
        "emboss" => include_str!("../styles/emboss.ron").to_string(),
        other => panic!("[pane_ui] Unknown built-in style '{other}'"),
    }
}

// ── Dev Mode — runtime loaded ─────────────────────────────────────────────────

#[cfg(feature = "dev")]
pub fn load_builtin_style(name: &str) -> String {
    let path = format!("styles/{}.ron", name);
    std::fs::read_to_string(&path)
        .unwrap_or_else(|e| panic!("[pane_ui] Failed to load style '{}': {}", path, e))
}

// ── User Dir Scanner ──────────────────────────────────────────────────────────

pub fn scan_style_dir(dir: &str) -> Vec<(String, String)> {
    crate::shader_reg::scan_dir(dir, "ron")
}

// ── Parse and Register ────────────────────────────────────────────────────────

fn parse_and_register(
    name: &str,
    src: &str,
    registry: &mut StyleRegistry,
    shader_map: &HashMap<String, ShaderId>,
    textured_id: ShaderId,
    texture_loader: &mut dyn FnMut(&str, Option<GifMode>) -> Option<TextureId>,
) -> Option<(String, (StyleId, ShaderId))> {
    let def: StyleDef = match ron::from_str(src) {
        Ok(d) => d,
        Err(e) => {
            eprintln!("[pane_ui] Failed to parse style '{name}': {e}");
            return None;
        }
    };

    let Some(&fallback_shader) = shader_map.get(&def.shader) else {
        eprintln!(
            "[pane_ui] Style '{}' references unknown shader '{}', skipping",
            name, def.shader
        );
        return None;
    };

    let style = Style {
        shader: fallback_shader,
        textured: textured_id,
        idle: def
            .idle
            .to_visual_state(shader_map, texture_loader, fallback_shader, name, "idle"),
        hovered: def.hovered.to_visual_state(
            shader_map,
            texture_loader,
            fallback_shader,
            name,
            "hovered",
        ),
        pressed: def.pressed.to_visual_state(
            shader_map,
            texture_loader,
            fallback_shader,
            name,
            "pressed",
        ),
        disabled: def.disabled.to_visual_state(
            shader_map,
            texture_loader,
            fallback_shader,
            name,
            "disabled",
        ),
    };

    let style_id = registry.register(style);
    Some((name.to_string(), (style_id, fallback_shader)))
}

/// Load, parse, and register all built-in styles plus any from user `style_dirs`.
/// Returns a map of style name → (`StyleId`, `ShaderId`) for use in `build_logic`.
pub fn register_all(
    registry: &mut StyleRegistry,
    shader_map: &HashMap<String, ShaderId>,
    style_dirs: &[String],
    textured_id: ShaderId,
    texture_loader: &mut dyn FnMut(&str, Option<GifMode>) -> Option<TextureId>,
) -> HashMap<String, (StyleId, ShaderId)> {
    let mut map = HashMap::new();

    for name in BUILTIN_STYLE_NAMES {
        let src = load_builtin_style(name);
        if let Some((k, v)) = parse_and_register(
            name,
            &src,
            registry,
            shader_map,
            textured_id,
            texture_loader,
        ) {
            map.insert(k, v);
        }
    }

    for dir in style_dirs {
        for (name, src) in scan_style_dir(dir) {
            println!("[pane_ui] Loaded user style '{name}'");
            if let Some((k, v)) = parse_and_register(
                &name,
                &src,
                registry,
                shader_map,
                textured_id,
                texture_loader,
            ) {
                map.insert(k, v);
            }
        }
    }

    map
}