Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-core/src/look/mod.rs

//! **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 color;
pub mod feel;
pub mod keymap;
pub mod metrics;
pub mod navtokens;
pub mod oklch;
pub mod palette;
pub mod platform;
pub mod policy;
pub mod scroll;
pub mod typography;

pub use color::{chip, heat_color, lerp_color, AMBER, PINK, RED};
pub use feel::{
    apply_focus_ring, elevation_shadow, elevation_shadow_params, focus_ring_visual, native_feel, publish_native,
    reveal_on_hover, ElevationShadow, FocusRingVisual,
};
pub use keymap::{Action, KeyMap, keymap, publish_keymap};
pub use metrics::Metrics;
pub use navtokens::{NavOverlayTokens, published_palette};
pub use oklch::{Oklch, contrast_ratio, relative_luminance};
pub use palette::Palette;
pub use platform::{FocusRing, NativeFeel, Platform, WindowControls};
pub use policy::{effects_policy, publish_effects, 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,
    /// The platform-adaptive **native-feel** cues (reveal highlight, rubber-band,
    /// focus-ring style, window-control side, accent-tint, elevation) layered over
    /// the primitives above. See [`platform`].
    pub native: NativeFeel,
}

// `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(),
            // Full-effects showcase: a frosted-glass surface (real backdrop blur on
            // wgpu, degrading to its tint off-wgpu / under Reduced, and forced opaque
            // under Device). This is the surface the `chrome` glass card + the deck
            // bloom read as "this preset wants glass", and the demo asserts as data.
            // Mica: a denser, more opaque material — lower blur, higher tint α.
            surface: SurfaceSpec::Frosted { blur_radius: 12.0, tint: [20, 22, 30, 160] },
            motion: Motion::windows(),
            effects: EffectsPolicy::Full,
            perf: PerfConfig::default(),
            native: NativeFeel::windows(),
        }
    }

    /// 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(),
            // Full-effects showcase: frosted glass (see `windows_dark`).
            // Vibrancy: a more translucent material — higher blur, lower tint α.
            surface: SurfaceSpec::Frosted { blur_radius: 16.0, tint: [22, 22, 26, 130] },
            motion: Motion::macos(),
            effects: EffectsPolicy::Full,
            perf: PerfConfig::default(),
            native: NativeFeel::macos(),
        }
    }

    /// 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,
            // no decorative motion (instant), but keep a valid curve token.
            motion: Motion { duration: 0.0, fast: 0.0, curve: crate::effects::Curve::Linear },
            effects: EffectsPolicy::None,
            perf: PerfConfig { prefer_wgpu: true, ..PerfConfig::default() },
            // Effects-off: no reveal/rubber-band/glow ring — a plain crisp outline.
            native: NativeFeel::neutral(),
        }
    }

    /// **Neutral** — the cross-platform / Linux default: premium and full-effects,
    /// but with **no OS-specific chrome** (no traffic lights, no reveal highlight,
    /// no rubber-band). Moderate radii, the System font, solid scrollbars, gentle
    /// cubic motion. This is what [`Platform::Neutral`] selects and the third stop
    /// of the demo's Mac ⇄ Windows ⇄ Neutral toggle.
    pub fn neutral() -> Self {
        Self {
            name: "neutral-dark".into(),
            mode: ThemeMode::Dark,
            palette: presets::windows_dark_palette(),
            typography: Typography::default().with_font(UiFont::System),
            metrics: Metrics::windows(),
            scroll: ScrollSpec::windows(),
            keymap: KeyMap::windows(),
            focus: FocusSpec::default(),
            // A balanced material between Mica and vibrancy.
            surface: SurfaceSpec::Frosted { blur_radius: 14.0, tint: [20, 22, 28, 150] },
            motion: Motion::neutral(),
            effects: EffectsPolicy::Full,
            perf: PerfConfig::default(),
            native: NativeFeel::neutral(),
        }
    }

    /// **Skaði "Skade Vinter"** — an icy winter showcase theme (glacier blues,
    /// frost/ice-white, pale cyan over deep-winter navy), full effects so the
    /// ice-drip decor + frost shimmer light up. The look the holger demo swaps to
    /// in its static "winter" mode via the COH-1 bridge (replaces the old red
    /// uboat/silent-running theme). Authored fresh — there is no SVT/skade UI to
    /// port. Serde name + `by_name`: `"skade_vinter"`.
    pub fn skade_vinter() -> Self {
        Self {
            name: "skade_vinter".into(),
            mode: ThemeMode::Dark,
            palette: presets::skade_vinter_palette(),
            typography: Typography::default().with_font(UiFont::System),
            metrics: Metrics::windows(),
            scroll: ScrollSpec::windows(),
            keymap: KeyMap::windows(),
            focus: FocusSpec::default(),
            // Icy frosted glass — the winter showcase wants its frost to read as glass.
            surface: SurfaceSpec::Frosted { blur_radius: 14.0, tint: [180, 210, 235, 90] },
            motion: Motion::default(),
            effects: EffectsPolicy::Full,
            perf: PerfConfig::default(),
            native: NativeFeel::neutral(),
        }
    }

    /// **Nordisk (dark)** — facett's own flagship identity, and the recommended
    /// default look: a calm, premium Nordic take on the Windows-11/macOS idiom
    /// with a signature aurora-cyan accent. OS-agnostic (no traffic lights, no
    /// reveal), full-effects, soft 8px radii, a cool frosted-glass surface. This
    /// is what the demo opens on and the "its own identity" stop of the toggle.
    pub fn nordisk_dark() -> Self {
        Self {
            name: "nordisk-dark".into(),
            mode: ThemeMode::Dark,
            palette: presets::nordisk_dark_palette(),
            typography: Typography::default().with_font(UiFont::System),
            metrics: Metrics::nordisk(),
            scroll: ScrollSpec::windows(),
            keymap: KeyMap::windows(),
            focus: FocusSpec::default(),
            // A cool blue-slate frosted glass — the Nordic key of the Mica/vibrancy
            // material, balanced between the two OS presets.
            surface: SurfaceSpec::Frosted { blur_radius: 14.0, tint: [16, 22, 32, 150] },
            motion: Motion::neutral(),
            effects: EffectsPolicy::Full,
            perf: PerfConfig::default(),
            native: NativeFeel::neutral(),
        }
    }

    /// **Nordisk (light)** — the airy daytime key of the flagship identity.
    pub fn nordisk_light() -> Self {
        Self {
            name: "nordisk-light".into(),
            mode: ThemeMode::Light,
            palette: presets::nordisk_light_palette(),
            // A pale frost tint for the light key.
            surface: SurfaceSpec::Frosted { blur_radius: 14.0, tint: [235, 240, 248, 120] },
            ..Theme::nordisk_dark()
        }
    }

    /// The default Nordisk theme (dark) — the flagship identity look.
    pub fn nordisk() -> Self {
        Theme::nordisk_dark()
    }

    /// Pick a preset from the running OS (ARCH-3). Falls back to Windows-dark on
    /// Linux/unknown (documented default). This is the **egui-OS** bridge (used by
    /// the host at startup); the cfg-detected [`Platform`] path is
    /// [`for_platform`](Theme::for_platform).
    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(),
        }
    }

    /// Pick the **platform-adaptive native-feel** preset for an explicit
    /// [`Platform`] (the cfg-detected or runtime-overridden one). `Mac` →
    /// macOS-dark, `Windows` → Windows-dark, `Neutral` → the neutral preset. The
    /// entry point the demo's Mac ⇄ Windows ⇄ Neutral toggle drives.
    pub fn for_platform(p: Platform) -> Self {
        match p {
            Platform::Mac => Theme::macos_dark(),
            Platform::Windows => Theme::windows_dark(),
            Platform::Neutral => Theme::neutral(),
        }
    }

    /// The preset selected by **auto-detecting** the host platform
    /// (`cfg!(target_os)`), with no override. Convenience for
    /// `Theme::for_platform(Platform::detect())`.
    pub fn for_detected_platform() -> Self {
        Theme::for_platform(Platform::detect())
    }

    /// 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,
        Theme::skade_vinter,
        Theme::neutral,
        Theme::nordisk_light,
        Theme::nordisk_dark,
    ];

    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());

        // Install the bundled UI font (Inter) as the default Proportional family.
        // Feature-gated + one-shot (see `install_ui_font`).
        self.install_ui_font(ctx);

        // 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 semantic palette itself too, so an overlay that derives its
        // colours from the v1 roles (the navgraph, `navtokens`) reads the active theme.
        navtokens::publish_palette(ctx, self.palette);

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

        // Publish the effects policy so effect-gated components (graph3d neon decor)
        // follow the theme — Full lights up, Reduced softens, None/device stays flat.
        policy::publish_effects(ctx, self.effects);

        // Publish the platform native-feel cues (reveal highlight, focus ring/rect,
        // elevation) so every component's paint follows the active platform preset
        // through the shared `look::feel` helpers (COH: mac/windows parity).
        feel::publish_native(ctx, self.native);
    }

    /// **Font install** — install **Inter** as the default `Proportional` family
    /// so text renders in the intended UI face instead of egui's built-in
    /// Ubuntu-Light (the "soft text" cause). `Inter` is already the named
    /// fallback in [`typography`] ([`UiFont::Inter`]).
    ///
    /// Behind the **`bundled-fonts`** feature (OFF by default) because it embeds
    /// a real font asset — `facett-core/assets/fonts/Inter-Regular.ttf` (Inter,
    /// SIL Open Font License; `assets/fonts/OFL.txt` ships beside it). With the
    /// feature OFF this is a no-op and eframe's `default_fonts` stay in place, so
    /// no consumer is re-fonted without asking for it.
    #[allow(unused_variables)]
    fn install_ui_font(&self, ctx: &Context) {
        #[cfg(feature = "bundled-fonts")]
        {
            // One-shot guard: `apply` runs every frame, but `set_fonts` rebuilds
            // the glyph atlas, so install exactly once per context.
            let flag = egui::Id::new("facett-look-fonts-installed");
            let done = ctx.data(|d| d.get_temp::<bool>(flag).unwrap_or(false));
            if done {
                return;
            }

            const INTER_REGULAR: &[u8] = include_bytes!("../../assets/fonts/Inter-Regular.ttf");
            // **A 131-byte "font" compiles.** MEASURED on oden 2026-08-27: this exact
            // asset was a Git-LFS pointer in `~/git/facett` — 131 B where
            // 876 576 belong — while `git status --porcelain` reported the tree CLEAN,
            // because the LFS clean filter passes a pointer through unchanged and there
            // is no diff to show. `include_bytes!` took it without a word, `FontData::
            // from_static` took it, and the only symptom would have been Inter quietly
            // not being the face on screen. The check is `const`, so it is the BUILD that
            // fails, on every host, before anything ships.
            const _: () = assert!(
                !crate::asset::is_a_git_lfs_pointer(INTER_REGULAR) && INTER_REGULAR.len() > 4096,
                "facett-core/assets/fonts/Inter-Regular.ttf is a Git-LFS pointer or a stub, \
                 not a font. Run `git lfs checkout` in this repo — `git status` will not \
                 tell you, it reads clean either way."
            );

            let mut fonts = egui::FontDefinitions::default();
            fonts.font_data.insert(
                "Inter".to_owned(),
                std::sync::Arc::new(egui::FontData::from_static(INTER_REGULAR)),
            );
            // Make Inter the FIRST (default) Proportional face; keep egui's
            // built-ins after it as glyph fallbacks (emoji, symbols, CJK).
            fonts
                .families
                .entry(egui::FontFamily::Proportional)
                .or_default()
                .insert(0, "Inter".to_owned());
            ctx.set_fonts(fonts);
            ctx.data_mut(|d| d.insert_temp(flag, true));
        }
    }

    /// 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",
            "skade_vinter" => "skade_vinter",
            "neutral-dark" => "neutral-dark",
            "nordisk-dark" => "nordisk-dark",
            "nordisk-light" => "nordisk-light",
            _ => "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;