facett-core 0.1.4

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **facett look & feel** (the work-order architecture, §3) — one [`Theme`] struct
//! that fully describes a coherent, fast, fully-themeable look across every
//! facett component, shipped as three presets: **Windows**, **macOS**, **Device**
//! (effects-off, rugged/military). The [`Theme`] is the *single source of truth*:
//! [`Theme::apply`] installs a complete `egui::Style` (visuals + spacing + scroll)
//! plus the text scale in one call, and also publishes the derived legacy
//! [`crate::Theme`] palette so the existing custom-painted components follow with
//! no per-component wiring (COH-1).
//!
//! Everything is `serde` (ARCH-4): a host can author themes + remap keys in
//! TOML/JSON without recompiling.

use egui::Context;
use serde::{Deserialize, Serialize};

pub mod keymap;
pub mod metrics;
pub mod oklch;
pub mod palette;
pub mod policy;
pub mod scroll;
pub mod typography;

pub use keymap::{Action, KeyMap, keymap, publish_keymap};
pub use metrics::Metrics;
pub use oklch::{Oklch, contrast_ratio, relative_luminance};
pub use palette::Palette;
pub use policy::{EffectsPolicy, FocusSpec, Motion, PerfConfig, SurfaceSpec, ThemeMode};
pub use scroll::{ScrollSpec, ScrollVisibility};
pub use typography::{Typography, UiFont};

/// The one theme (ARCH-1). Every sub-struct is fully populated by every preset
/// (no leaked defaults, ARCH-3); fields are public with `with_*` builders
/// (ARCH-5); the whole thing is `serde` (ARCH-4).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Theme {
    pub name: String,
    pub mode: ThemeMode,
    pub palette: Palette,
    pub typography: Typography,
    pub metrics: Metrics,
    pub scroll: ScrollSpec,
    pub keymap: KeyMap,
    pub focus: FocusSpec,
    pub surface: SurfaceSpec,
    pub motion: Motion,
    pub effects: EffectsPolicy,
    pub perf: PerfConfig,
}

// `Palette` is `Copy` but not `serde` via derive on `Oklch`; we derive serde on
// it explicitly below so the whole `Theme` is serialisable (ARCH-4).
impl Default for Theme {
    fn default() -> Self {
        Theme::windows_dark()
    }
}

impl Theme {
    // ── presets (ARCH-3): each fully populates every sub-struct ──────────────

    /// Windows, dark.
    pub fn windows_dark() -> Self {
        Self {
            name: "windows-dark".into(),
            mode: ThemeMode::Dark,
            palette: presets::windows_dark_palette(),
            typography: Typography::default().with_font(UiFont::SegoeUi),
            metrics: Metrics::windows(),
            scroll: ScrollSpec::windows(),
            keymap: KeyMap::windows(),
            focus: FocusSpec::default(),
            surface: SurfaceSpec::Opaque,
            motion: Motion::default(),
            effects: EffectsPolicy::Full,
            perf: PerfConfig::default(),
        }
    }

    /// Windows, light.
    pub fn windows_light() -> Self {
        Self {
            name: "windows-light".into(),
            mode: ThemeMode::Light,
            palette: presets::windows_light_palette(),
            ..Theme::windows_dark()
        }
    }

    /// The default Windows theme (dark) — `windows()` for the work-order name.
    pub fn windows() -> Self {
        Theme::windows_dark()
    }

    /// macOS, dark.
    pub fn macos_dark() -> Self {
        Self {
            name: "macos-dark".into(),
            mode: ThemeMode::Dark,
            palette: presets::macos_dark_palette(),
            typography: Typography::default().with_font(UiFont::SanFrancisco),
            metrics: Metrics::macos(),
            scroll: ScrollSpec::macos(),
            keymap: KeyMap::macos(),
            focus: FocusSpec::default(),
            surface: SurfaceSpec::Opaque,
            motion: Motion::default(),
            effects: EffectsPolicy::Full,
            perf: PerfConfig::default(),
        }
    }

    /// macOS, light.
    pub fn macos_light() -> Self {
        Self {
            name: "macos-light".into(),
            mode: ThemeMode::Light,
            palette: presets::macos_light_palette(),
            ..Theme::macos_dark()
        }
    }

    /// The default macOS theme (dark).
    pub fn macos() -> Self {
        Theme::macos_dark()
    }

    /// Device — barren, effects-off, sunlight-legible, crisp (§23). GPU still
    /// allowed (PerfConfig.prefer_wgpu) — just no eye-candy.
    pub fn device() -> Self {
        Self {
            name: "device".into(),
            mode: ThemeMode::Dark,
            palette: presets::device_palette(),
            typography: Typography::default().with_font(UiFont::System),
            metrics: Metrics::device(),
            scroll: ScrollSpec::device(),
            keymap: KeyMap::device(),
            focus: FocusSpec { hints_enabled: false, revolver_enabled: false, ..FocusSpec::default() },
            surface: SurfaceSpec::Opaque,
            motion: Motion { duration: 0.0, fast: 0.0 }, // no decorative motion
            effects: EffectsPolicy::None,
            perf: PerfConfig { prefer_wgpu: true, ..PerfConfig::default() },
        }
    }

    /// Pick a preset from the running OS (ARCH-3). Falls back to Windows-dark on
    /// Linux/unknown (documented default).
    pub fn from_os(os: egui::os::OperatingSystem) -> Self {
        use egui::os::OperatingSystem as Os;
        match os {
            Os::Mac | Os::IOS => Theme::macos_dark(),
            Os::Windows => Theme::windows_dark(),
            // Linux / Android / Unknown / Web → documented default.
            _ => Theme::windows_dark(),
        }
    }

    /// Every preset, light+dark where applicable, for a switcher/gallery.
    pub const PRESETS: &'static [fn() -> Theme] = &[
        Theme::windows_light,
        Theme::windows_dark,
        Theme::macos_light,
        Theme::macos_dark,
        Theme::device,
    ];

    pub fn preset_names() -> Vec<String> {
        Self::PRESETS.iter().map(|c| c().name).collect()
    }

    pub fn by_name(name: &str) -> Option<Theme> {
        let norm = |s: &str| s.to_ascii_lowercase().replace([' ', '_'], "-");
        let want = norm(name);
        Self::PRESETS.iter().map(|c| c()).find(|t| norm(&t.name) == want)
    }

    // ── builders (ARCH-5) ────────────────────────────────────────────────────

    pub fn with_effects(mut self, e: EffectsPolicy) -> Self {
        self.effects = e;
        self
    }
    pub fn with_keymap(mut self, k: KeyMap) -> Self {
        self.keymap = k;
        self
    }
    pub fn with_focus(mut self, f: FocusSpec) -> Self {
        self.focus = f;
        self
    }
    pub fn with_surface(mut self, s: SurfaceSpec) -> Self {
        self.surface = s;
        self
    }
    pub fn with_name(mut self, n: impl Into<String>) -> Self {
        self.name = n.into();
        self
    }

    /// Is this theme dark? (Resolves `FollowSystem` via the palette's own flag.)
    pub fn is_dark(&self) -> bool {
        match self.mode {
            ThemeMode::Dark => true,
            ThemeMode::Light => false,
            ThemeMode::FollowSystem => self.palette.dark,
        }
    }

    /// Build a complete `egui::Style` from this theme (visuals + spacing + scroll
    /// + text scale). Pure — `apply` installs it on a context.
    pub fn egui_style(&self) -> egui::Style {
        let mut style = egui::Style::default();

        // Visuals from the palette + radius from metrics.
        let mut visuals = self.palette.to_visuals(self.metrics.corner_radius);
        visuals.window_corner_radius = egui::CornerRadius::same(self.metrics.window_corner_radius);
        visuals.menu_corner_radius = egui::CornerRadius::same(self.metrics.menu_corner_radius);
        style.visuals = visuals;

        // Spacing from metrics.
        let sp = &mut style.spacing;
        sp.item_spacing = self.metrics.item_spacing_vec();
        sp.button_padding = self.metrics.button_padding_vec();
        sp.window_margin = self.metrics.window_margin_m();
        sp.menu_margin = self.metrics.menu_margin_m();
        sp.interact_size = self.metrics.interact_size_vec();
        sp.indent = self.metrics.indent;
        sp.slider_width = self.metrics.slider_width;
        sp.icon_width = self.metrics.icon_width;
        sp.scroll = self.scroll.to_scroll_style();

        // Text scale from typography.
        style.text_styles = self.typography.text_styles();

        style
    }

    /// **ARCH-2** — install the full style + publish the derived legacy palette in
    /// one call. Re-applying takes effect next frame, no restart. Also sets the OS
    /// (KEY-3) so `Modifiers::COMMAND` formats as ⌘/Ctrl correctly and built-in
    /// shortcuts match the preset.
    pub fn apply(&self, ctx: &Context) {
        // Tell egui which OS we're presenting as (drives ⌘ vs Ctrl labels +
        // built-in TextEdit shortcuts). macOS preset → Mac; else Windows.
        let os = if self.name.starts_with("macos") {
            egui::os::OperatingSystem::Mac
        } else {
            egui::os::OperatingSystem::Windows
        };
        ctx.set_os(os);

        ctx.set_global_style(self.egui_style());

        // Publish the derived legacy flat palette so every existing custom-painted
        // component (graph/depgraph/map/grid/…) follows with no change (COH-1).
        // Palette-only (not `set_theme`) so we don't re-impose the legacy
        // `override_text_color` over the Style we just installed (§27).
        crate::theme::publish_palette(ctx, self.to_legacy_palette());

        // Publish the keymap so every component resolves identical chords (COH-2).
        keymap::publish_keymap(ctx, self.keymap.clone());
    }

    /// Derive the legacy flat [`crate::Theme`] palette from the semantic roles, so
    /// existing components that read `crate::theme(ui)` follow this theme. This is
    /// the compatibility bridge: the rich [`Theme`] is the source of truth; the
    /// flat palette is *computed*, never authored.
    pub fn to_legacy_palette(&self) -> crate::Theme {
        let p = &self.palette;
        let name: &'static str = match self.name.as_str() {
            "windows-dark" => "windows-dark",
            "windows-light" => "windows-light",
            "macos-dark" => "macos-dark",
            "macos-light" => "macos-light",
            "device" => "device",
            _ => "look",
        };
        crate::Theme {
            name,
            bg: p.surface.to_color32(),
            node_fill: p.surface_container.to_color32(),
            node_stroke: p.outline.to_color32(),
            edge: p.outline.with_chroma_scale(0.6).to_color32(),
            text: p.on_surface.to_color32(),
            text_dim: p.on_surface_dim.to_color32(),
            accent: p.accent.to_color32(),
            point: p.primary.to_color32(),
            panel_bg: p.surface_container.to_color32(),
            panel_stroke: p.outline.to_color32(),
            glow: p.glow.to_color32(),
        }
    }
}

// ── serde for Palette / Oklch (kept here so the colour modules stay paint-only) ─

mod presets;

#[cfg(test)]
mod tests;