Skip to main content

facett_core/look/
mod.rs

1//! **facett look & feel** (the work-order architecture, §3) — one [`Theme`] struct
2//! that fully describes a coherent, fast, fully-themeable look across every
3//! facett component, shipped as three presets: **Windows**, **macOS**, **Device**
4//! (effects-off, rugged/military). The [`Theme`] is the *single source of truth*:
5//! [`Theme::apply`] installs a complete `egui::Style` (visuals + spacing + scroll)
6//! plus the text scale in one call, and also publishes the derived legacy
7//! [`crate::Theme`] palette so the existing custom-painted components follow with
8//! no per-component wiring (COH-1).
9//!
10//! Everything is `serde` (ARCH-4): a host can author themes + remap keys in
11//! TOML/JSON without recompiling.
12
13use egui::Context;
14use serde::{Deserialize, Serialize};
15
16pub mod feel;
17pub mod keymap;
18pub mod metrics;
19pub mod oklch;
20pub mod palette;
21pub mod platform;
22pub mod policy;
23pub mod scroll;
24pub mod typography;
25
26pub use feel::{
27    apply_focus_ring, elevation_shadow, elevation_shadow_params, focus_ring_visual, native_feel, publish_native,
28    reveal_on_hover, ElevationShadow, FocusRingVisual,
29};
30pub use keymap::{Action, KeyMap, keymap, publish_keymap};
31pub use metrics::Metrics;
32pub use oklch::{Oklch, contrast_ratio, relative_luminance};
33pub use palette::Palette;
34pub use platform::{FocusRing, NativeFeel, Platform, WindowControls};
35pub use policy::{effects_policy, publish_effects, EffectsPolicy, FocusSpec, Motion, PerfConfig, SurfaceSpec, ThemeMode};
36pub use scroll::{ScrollSpec, ScrollVisibility};
37pub use typography::{Typography, UiFont};
38
39/// The one theme (ARCH-1). Every sub-struct is fully populated by every preset
40/// (no leaked defaults, ARCH-3); fields are public with `with_*` builders
41/// (ARCH-5); the whole thing is `serde` (ARCH-4).
42#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
43pub struct Theme {
44    pub name: String,
45    pub mode: ThemeMode,
46    pub palette: Palette,
47    pub typography: Typography,
48    pub metrics: Metrics,
49    pub scroll: ScrollSpec,
50    pub keymap: KeyMap,
51    pub focus: FocusSpec,
52    pub surface: SurfaceSpec,
53    pub motion: Motion,
54    pub effects: EffectsPolicy,
55    pub perf: PerfConfig,
56    /// The platform-adaptive **native-feel** cues (reveal highlight, rubber-band,
57    /// focus-ring style, window-control side, accent-tint, elevation) layered over
58    /// the primitives above. See [`platform`].
59    pub native: NativeFeel,
60}
61
62// `Palette` is `Copy` but not `serde` via derive on `Oklch`; we derive serde on
63// it explicitly below so the whole `Theme` is serialisable (ARCH-4).
64impl Default for Theme {
65    fn default() -> Self {
66        Theme::windows_dark()
67    }
68}
69
70impl Theme {
71    // ── presets (ARCH-3): each fully populates every sub-struct ──────────────
72
73    /// Windows, dark.
74    pub fn windows_dark() -> Self {
75        Self {
76            name: "windows-dark".into(),
77            mode: ThemeMode::Dark,
78            palette: presets::windows_dark_palette(),
79            typography: Typography::default().with_font(UiFont::SegoeUi),
80            metrics: Metrics::windows(),
81            scroll: ScrollSpec::windows(),
82            keymap: KeyMap::windows(),
83            focus: FocusSpec::default(),
84            // Full-effects showcase: a frosted-glass surface (real backdrop blur on
85            // wgpu, degrading to its tint off-wgpu / under Reduced, and forced opaque
86            // under Device). This is the surface the `chrome` glass card + the deck
87            // bloom read as "this preset wants glass", and the demo asserts as data.
88            // Mica: a denser, more opaque material — lower blur, higher tint α.
89            surface: SurfaceSpec::Frosted { blur_radius: 12.0, tint: [20, 22, 30, 160] },
90            motion: Motion::windows(),
91            effects: EffectsPolicy::Full,
92            perf: PerfConfig::default(),
93            native: NativeFeel::windows(),
94        }
95    }
96
97    /// Windows, light.
98    pub fn windows_light() -> Self {
99        Self {
100            name: "windows-light".into(),
101            mode: ThemeMode::Light,
102            palette: presets::windows_light_palette(),
103            ..Theme::windows_dark()
104        }
105    }
106
107    /// The default Windows theme (dark) — `windows()` for the work-order name.
108    pub fn windows() -> Self {
109        Theme::windows_dark()
110    }
111
112    /// macOS, dark.
113    pub fn macos_dark() -> Self {
114        Self {
115            name: "macos-dark".into(),
116            mode: ThemeMode::Dark,
117            palette: presets::macos_dark_palette(),
118            typography: Typography::default().with_font(UiFont::SanFrancisco),
119            metrics: Metrics::macos(),
120            scroll: ScrollSpec::macos(),
121            keymap: KeyMap::macos(),
122            focus: FocusSpec::default(),
123            // Full-effects showcase: frosted glass (see `windows_dark`).
124            // Vibrancy: a more translucent material — higher blur, lower tint α.
125            surface: SurfaceSpec::Frosted { blur_radius: 16.0, tint: [22, 22, 26, 130] },
126            motion: Motion::macos(),
127            effects: EffectsPolicy::Full,
128            perf: PerfConfig::default(),
129            native: NativeFeel::macos(),
130        }
131    }
132
133    /// macOS, light.
134    pub fn macos_light() -> Self {
135        Self {
136            name: "macos-light".into(),
137            mode: ThemeMode::Light,
138            palette: presets::macos_light_palette(),
139            ..Theme::macos_dark()
140        }
141    }
142
143    /// The default macOS theme (dark).
144    pub fn macos() -> Self {
145        Theme::macos_dark()
146    }
147
148    /// Device — barren, effects-off, sunlight-legible, crisp (§23). GPU still
149    /// allowed (PerfConfig.prefer_wgpu) — just no eye-candy.
150    pub fn device() -> Self {
151        Self {
152            name: "device".into(),
153            mode: ThemeMode::Dark,
154            palette: presets::device_palette(),
155            typography: Typography::default().with_font(UiFont::System),
156            metrics: Metrics::device(),
157            scroll: ScrollSpec::device(),
158            keymap: KeyMap::device(),
159            focus: FocusSpec { hints_enabled: false, revolver_enabled: false, ..FocusSpec::default() },
160            surface: SurfaceSpec::Opaque,
161            // no decorative motion (instant), but keep a valid curve token.
162            motion: Motion { duration: 0.0, fast: 0.0, curve: crate::effects::Curve::Linear },
163            effects: EffectsPolicy::None,
164            perf: PerfConfig { prefer_wgpu: true, ..PerfConfig::default() },
165            // Effects-off: no reveal/rubber-band/glow ring — a plain crisp outline.
166            native: NativeFeel::neutral(),
167        }
168    }
169
170    /// **Neutral** — the cross-platform / Linux default: premium and full-effects,
171    /// but with **no OS-specific chrome** (no traffic lights, no reveal highlight,
172    /// no rubber-band). Moderate radii, the System font, solid scrollbars, gentle
173    /// cubic motion. This is what [`Platform::Neutral`] selects and the third stop
174    /// of the demo's Mac ⇄ Windows ⇄ Neutral toggle.
175    pub fn neutral() -> Self {
176        Self {
177            name: "neutral-dark".into(),
178            mode: ThemeMode::Dark,
179            palette: presets::windows_dark_palette(),
180            typography: Typography::default().with_font(UiFont::System),
181            metrics: Metrics::windows(),
182            scroll: ScrollSpec::windows(),
183            keymap: KeyMap::windows(),
184            focus: FocusSpec::default(),
185            // A balanced material between Mica and vibrancy.
186            surface: SurfaceSpec::Frosted { blur_radius: 14.0, tint: [20, 22, 28, 150] },
187            motion: Motion::neutral(),
188            effects: EffectsPolicy::Full,
189            perf: PerfConfig::default(),
190            native: NativeFeel::neutral(),
191        }
192    }
193
194    /// **Skaði "Skade Vinter"** — an icy winter showcase theme (glacier blues,
195    /// frost/ice-white, pale cyan over deep-winter navy), full effects so the
196    /// ice-drip decor + frost shimmer light up. The look the holger demo swaps to
197    /// in its static "winter" mode via the COH-1 bridge (replaces the old red
198    /// uboat/silent-running theme). Authored fresh — there is no SVT/skade UI to
199    /// port. Serde name + `by_name`: `"skade_vinter"`.
200    pub fn skade_vinter() -> Self {
201        Self {
202            name: "skade_vinter".into(),
203            mode: ThemeMode::Dark,
204            palette: presets::skade_vinter_palette(),
205            typography: Typography::default().with_font(UiFont::System),
206            metrics: Metrics::windows(),
207            scroll: ScrollSpec::windows(),
208            keymap: KeyMap::windows(),
209            focus: FocusSpec::default(),
210            // Icy frosted glass — the winter showcase wants its frost to read as glass.
211            surface: SurfaceSpec::Frosted { blur_radius: 14.0, tint: [180, 210, 235, 90] },
212            motion: Motion::default(),
213            effects: EffectsPolicy::Full,
214            perf: PerfConfig::default(),
215            native: NativeFeel::neutral(),
216        }
217    }
218
219    /// **Nordisk (dark)** — facett's own flagship identity, and the recommended
220    /// default look: a calm, premium Nordic take on the Windows-11/macOS idiom
221    /// with a signature aurora-cyan accent. OS-agnostic (no traffic lights, no
222    /// reveal), full-effects, soft 8px radii, a cool frosted-glass surface. This
223    /// is what the demo opens on and the "its own identity" stop of the toggle.
224    pub fn nordisk_dark() -> Self {
225        Self {
226            name: "nordisk-dark".into(),
227            mode: ThemeMode::Dark,
228            palette: presets::nordisk_dark_palette(),
229            typography: Typography::default().with_font(UiFont::System),
230            metrics: Metrics::nordisk(),
231            scroll: ScrollSpec::windows(),
232            keymap: KeyMap::windows(),
233            focus: FocusSpec::default(),
234            // A cool blue-slate frosted glass — the Nordic key of the Mica/vibrancy
235            // material, balanced between the two OS presets.
236            surface: SurfaceSpec::Frosted { blur_radius: 14.0, tint: [16, 22, 32, 150] },
237            motion: Motion::neutral(),
238            effects: EffectsPolicy::Full,
239            perf: PerfConfig::default(),
240            native: NativeFeel::neutral(),
241        }
242    }
243
244    /// **Nordisk (light)** — the airy daytime key of the flagship identity.
245    pub fn nordisk_light() -> Self {
246        Self {
247            name: "nordisk-light".into(),
248            mode: ThemeMode::Light,
249            palette: presets::nordisk_light_palette(),
250            // A pale frost tint for the light key.
251            surface: SurfaceSpec::Frosted { blur_radius: 14.0, tint: [235, 240, 248, 120] },
252            ..Theme::nordisk_dark()
253        }
254    }
255
256    /// The default Nordisk theme (dark) — the flagship identity look.
257    pub fn nordisk() -> Self {
258        Theme::nordisk_dark()
259    }
260
261    /// Pick a preset from the running OS (ARCH-3). Falls back to Windows-dark on
262    /// Linux/unknown (documented default). This is the **egui-OS** bridge (used by
263    /// the host at startup); the cfg-detected [`Platform`] path is
264    /// [`for_platform`](Theme::for_platform).
265    pub fn from_os(os: egui::os::OperatingSystem) -> Self {
266        use egui::os::OperatingSystem as Os;
267        match os {
268            Os::Mac | Os::IOS => Theme::macos_dark(),
269            Os::Windows => Theme::windows_dark(),
270            // Linux / Android / Unknown / Web → documented default.
271            _ => Theme::windows_dark(),
272        }
273    }
274
275    /// Pick the **platform-adaptive native-feel** preset for an explicit
276    /// [`Platform`] (the cfg-detected or runtime-overridden one). `Mac` →
277    /// macOS-dark, `Windows` → Windows-dark, `Neutral` → the neutral preset. The
278    /// entry point the demo's Mac ⇄ Windows ⇄ Neutral toggle drives.
279    pub fn for_platform(p: Platform) -> Self {
280        match p {
281            Platform::Mac => Theme::macos_dark(),
282            Platform::Windows => Theme::windows_dark(),
283            Platform::Neutral => Theme::neutral(),
284        }
285    }
286
287    /// The preset selected by **auto-detecting** the host platform
288    /// (`cfg!(target_os)`), with no override. Convenience for
289    /// `Theme::for_platform(Platform::detect())`.
290    pub fn for_detected_platform() -> Self {
291        Theme::for_platform(Platform::detect())
292    }
293
294    /// Every preset, light+dark where applicable, for a switcher/gallery.
295    pub const PRESETS: &'static [fn() -> Theme] = &[
296        Theme::windows_light,
297        Theme::windows_dark,
298        Theme::macos_light,
299        Theme::macos_dark,
300        Theme::device,
301        Theme::skade_vinter,
302        Theme::neutral,
303        Theme::nordisk_light,
304        Theme::nordisk_dark,
305    ];
306
307    pub fn preset_names() -> Vec<String> {
308        Self::PRESETS.iter().map(|c| c().name).collect()
309    }
310
311    pub fn by_name(name: &str) -> Option<Theme> {
312        let norm = |s: &str| s.to_ascii_lowercase().replace([' ', '_'], "-");
313        let want = norm(name);
314        Self::PRESETS.iter().map(|c| c()).find(|t| norm(&t.name) == want)
315    }
316
317    // ── builders (ARCH-5) ────────────────────────────────────────────────────
318
319    pub fn with_effects(mut self, e: EffectsPolicy) -> Self {
320        self.effects = e;
321        self
322    }
323    pub fn with_keymap(mut self, k: KeyMap) -> Self {
324        self.keymap = k;
325        self
326    }
327    pub fn with_focus(mut self, f: FocusSpec) -> Self {
328        self.focus = f;
329        self
330    }
331    pub fn with_surface(mut self, s: SurfaceSpec) -> Self {
332        self.surface = s;
333        self
334    }
335    pub fn with_name(mut self, n: impl Into<String>) -> Self {
336        self.name = n.into();
337        self
338    }
339
340    /// Is this theme dark? (Resolves `FollowSystem` via the palette's own flag.)
341    pub fn is_dark(&self) -> bool {
342        match self.mode {
343            ThemeMode::Dark => true,
344            ThemeMode::Light => false,
345            ThemeMode::FollowSystem => self.palette.dark,
346        }
347    }
348
349    /// Build a complete `egui::Style` from this theme (visuals + spacing + scroll
350    /// + text scale). Pure — `apply` installs it on a context.
351    pub fn egui_style(&self) -> egui::Style {
352        let mut style = egui::Style::default();
353
354        // Visuals from the palette + radius from metrics.
355        let mut visuals = self.palette.to_visuals(self.metrics.corner_radius);
356        visuals.window_corner_radius = egui::CornerRadius::same(self.metrics.window_corner_radius);
357        visuals.menu_corner_radius = egui::CornerRadius::same(self.metrics.menu_corner_radius);
358        style.visuals = visuals;
359
360        // Spacing from metrics.
361        let sp = &mut style.spacing;
362        sp.item_spacing = self.metrics.item_spacing_vec();
363        sp.button_padding = self.metrics.button_padding_vec();
364        sp.window_margin = self.metrics.window_margin_m();
365        sp.menu_margin = self.metrics.menu_margin_m();
366        sp.interact_size = self.metrics.interact_size_vec();
367        sp.indent = self.metrics.indent;
368        sp.slider_width = self.metrics.slider_width;
369        sp.icon_width = self.metrics.icon_width;
370        sp.scroll = self.scroll.to_scroll_style();
371
372        // Text scale from typography.
373        style.text_styles = self.typography.text_styles();
374
375        style
376    }
377
378    /// **ARCH-2** — install the full style + publish the derived legacy palette in
379    /// one call. Re-applying takes effect next frame, no restart. Also sets the OS
380    /// (KEY-3) so `Modifiers::COMMAND` formats as ⌘/Ctrl correctly and built-in
381    /// shortcuts match the preset.
382    pub fn apply(&self, ctx: &Context) {
383        // Tell egui which OS we're presenting as (drives ⌘ vs Ctrl labels +
384        // built-in TextEdit shortcuts). macOS preset → Mac; else Windows.
385        let os = if self.name.starts_with("macos") {
386            egui::os::OperatingSystem::Mac
387        } else {
388            egui::os::OperatingSystem::Windows
389        };
390        ctx.set_os(os);
391
392        ctx.set_global_style(self.egui_style());
393
394        // Publish the derived legacy flat palette so every existing custom-painted
395        // component (graph/depgraph/map/grid/…) follows with no change (COH-1).
396        // Palette-only (not `set_theme`) so we don't re-impose the legacy
397        // `override_text_color` over the Style we just installed (§27).
398        crate::theme::publish_palette(ctx, self.to_legacy_palette());
399
400        // Publish the keymap so every component resolves identical chords (COH-2).
401        keymap::publish_keymap(ctx, self.keymap.clone());
402
403        // Publish the effects policy so effect-gated components (graph3d neon decor)
404        // follow the theme — Full lights up, Reduced softens, None/device stays flat.
405        policy::publish_effects(ctx, self.effects);
406
407        // Publish the platform native-feel cues (reveal highlight, focus ring/rect,
408        // elevation) so every component's paint follows the active platform preset
409        // through the shared `look::feel` helpers (COH: mac/windows parity).
410        feel::publish_native(ctx, self.native);
411    }
412
413    /// Derive the legacy flat [`crate::Theme`] palette from the semantic roles, so
414    /// existing components that read `crate::theme(ui)` follow this theme. This is
415    /// the compatibility bridge: the rich [`Theme`] is the source of truth; the
416    /// flat palette is *computed*, never authored.
417    pub fn to_legacy_palette(&self) -> crate::Theme {
418        let p = &self.palette;
419        let name: &'static str = match self.name.as_str() {
420            "windows-dark" => "windows-dark",
421            "windows-light" => "windows-light",
422            "macos-dark" => "macos-dark",
423            "macos-light" => "macos-light",
424            "device" => "device",
425            "skade_vinter" => "skade_vinter",
426            "neutral-dark" => "neutral-dark",
427            "nordisk-dark" => "nordisk-dark",
428            "nordisk-light" => "nordisk-light",
429            _ => "look",
430        };
431        crate::Theme {
432            name,
433            bg: p.surface.to_color32(),
434            node_fill: p.surface_container.to_color32(),
435            node_stroke: p.outline.to_color32(),
436            edge: p.outline.with_chroma_scale(0.6).to_color32(),
437            text: p.on_surface.to_color32(),
438            text_dim: p.on_surface_dim.to_color32(),
439            accent: p.accent.to_color32(),
440            point: p.primary.to_color32(),
441            panel_bg: p.surface_container.to_color32(),
442            panel_stroke: p.outline.to_color32(),
443            glow: p.glow.to_color32(),
444        }
445    }
446}
447
448// ── serde for Palette / Oklch (kept here so the colour modules stay paint-only) ─
449
450mod presets;
451
452#[cfg(test)]
453mod tests;