facett-core 0.1.10

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
//! **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 feel;
pub mod keymap;
pub mod metrics;
pub mod oklch;
pub mod palette;
pub mod platform;
pub mod policy;
pub mod scroll;
pub mod typography;

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 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(),
        }
    }

    /// 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,
    ];

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

        // 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);
    }

    /// 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",
            _ => "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;