Skip to main content

facett_core/look/
platform.rs

1//! **Platform-adaptive "native feel" layer** — make facett feel *at home* on the
2//! host OS. A [`Platform`] auto-detects the running OS (`cfg!(target_os)`, with an
3//! explicit override) and selects a preset; a [`NativeFeel`] spec captures the
4//! per-platform *cues* that map onto facett's **existing** primitives (it adds no
5//! new render engine — it parameterises the ones we already have).
6//!
7//! # The cue → primitive map (the adaptive layer)
8//!
9//! | cue                         | macOS                                  | Windows 11                                   | facett primitive |
10//! |-----------------------------|----------------------------------------|----------------------------------------------|------------------|
11//! | translucent material        | **vibrancy** — high blur, low tint α   | **Mica** — lower blur, higher tint α         | [`SurfaceSpec::Frosted`](super::SurfaceSpec) (dual-Kawase blur in `render/gpu/blur.rs`) |
12//! | corner radius               | soft (8 ctrl / 12 win)                 | moderate (6 ctrl / 8 win)                    | [`Metrics`](super::Metrics) |
13//! | density                     | generous (44×28, 12pt margin)          | denser (40×24, 8pt margin)                   | [`Metrics`](super::Metrics) |
14//! | scrollbars                  | floating, fade-when-idle               | solid, always-visible                        | [`ScrollSpec`](super::ScrollSpec) |
15//! | overscroll                  | **rubber-band** spring overshoot       | hard edge                                    | [`rubber_band`](NativeFeel::rubber_band) → `SmoothScroll` |
16//! | motion personality          | gentle spring **overshoot** (`EaseOutBack`), ~0.30s | snappy "connected" (`EaseInOutCubic`), ~0.15s | [`Motion`](super::Motion) curve + duration |
17//! | hover signature             | subtle highlight                       | **reveal highlight** (cursor-follow edge glow) | [`reveal_highlight`](NativeFeel::reveal_highlight) → [`effects::RevealHighlight`](crate::effects::RevealHighlight) |
18//! | focus                       | soft accent **ring** (~3px glow)       | crisp focus **rectangle**                    | [`FocusRing`] |
19//! | accent usage                | tints selection + focus ring           | tints reveal + focus rect                    | [`accent_tint`](NativeFeel::accent_tint) |
20//! | elevation                   | restrained shadow                      | pronounced drop-shadow elevation             | [`elevation`](NativeFeel::elevation) |
21//! | window controls             | **traffic lights** (top-left)          | **caption buttons** (top-right)              | [`WindowControls`] (host-drawn; data hint here) |
22//! | typography                  | SF Pro                                 | Segoe UI Variable                            | [`Typography`](super::Typography) `UiFont` |
23//!
24//! All of this is `serde` (a host can author/override it in TOML/JSON) and carries
25//! **no egui-memory state and no wall-clock** (FC-1/FC-7): the reveal glow + spring
26//! overshoot are driven by the caller's own clock through the existing
27//! [`effects`](crate::effects) / [`scroll_engine`](crate::scroll_engine) primitives.
28
29use serde::{Deserialize, Serialize};
30
31/// The desktop platform we present as. `Neutral` is the cross-platform / Linux
32/// default: premium but without OS-specific chrome cues (no traffic lights, no
33/// reveal highlight).
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
35pub enum Platform {
36    Mac,
37    Windows,
38    /// Linux / unknown / explicit "no OS-specific chrome".
39    Neutral,
40}
41
42impl Platform {
43    /// **Auto-detect** the host platform from `cfg!(target_os)` at compile time:
44    /// macOS → [`Mac`](Platform::Mac), Windows → [`Windows`](Platform::Windows),
45    /// everything else (Linux/BSD/unknown) → [`Neutral`](Platform::Neutral).
46    ///
47    /// This is the *default* — a host may override it explicitly (the demo's
48    /// runtime toggle does exactly that, so the look is demonstrable on any OS).
49    pub const fn detect() -> Platform {
50        if cfg!(target_os = "macos") {
51            Platform::Mac
52        } else if cfg!(target_os = "windows") {
53            Platform::Windows
54        } else {
55            Platform::Neutral
56        }
57    }
58
59    /// A short stable label (also the serde discriminant for the override setting).
60    pub fn label(self) -> &'static str {
61        match self {
62            Platform::Mac => "mac",
63            Platform::Windows => "windows",
64            Platform::Neutral => "neutral",
65        }
66    }
67
68    /// Parse a label back (fuzzy: case-insensitive, `macos`/`osx` ≡ mac,
69    /// `win` ≡ windows, anything else → `None`). The override-setting reader.
70    pub fn from_label(s: &str) -> Option<Platform> {
71        match s.trim().to_ascii_lowercase().as_str() {
72            "mac" | "macos" | "osx" | "darwin" | "apple" => Some(Platform::Mac),
73            "windows" | "win" | "win11" | "win32" => Some(Platform::Windows),
74            "neutral" | "linux" | "nix" | "default" | "other" => Some(Platform::Neutral),
75            _ => None,
76        }
77    }
78
79    /// Cycle Mac → Windows → Neutral → Mac (the runtime toggle order).
80    pub fn cycle(self) -> Platform {
81        match self {
82            Platform::Mac => Platform::Windows,
83            Platform::Windows => Platform::Neutral,
84            Platform::Neutral => Platform::Mac,
85        }
86    }
87
88    /// The default-mode [`NativeFeel`] spec for this platform.
89    pub fn native_feel(self) -> NativeFeel {
90        match self {
91            Platform::Mac => NativeFeel::macos(),
92            Platform::Windows => NativeFeel::windows(),
93            Platform::Neutral => NativeFeel::neutral(),
94        }
95    }
96}
97
98/// Where the window-management controls live + their shape. Real per-OS chrome is
99/// *host-drawn* (a follow-up); this is the **data hint** so a host (or the demo's
100/// faux title-bar) knows which side to render and the look the preset wants.
101#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
102pub enum WindowControls {
103    /// macOS: three coloured "traffic-light" circles, top-**left**.
104    TrafficLights,
105    /// Windows: minimise/maximise/close caption buttons, top-**right**.
106    Caption,
107    /// No app-drawn window controls (host/borderless).
108    None,
109}
110
111impl WindowControls {
112    /// Are the controls on the left edge (macOS) vs the right (Windows)?
113    pub fn on_left(self) -> bool {
114        matches!(self, WindowControls::TrafficLights)
115    }
116}
117
118/// The keyboard-focus indicator style — macOS draws a soft accent **ring** (a
119/// glow halo), Windows 11 a crisp focus **rectangle**. Both read the palette's
120/// `accent`; the difference is width/softness/expansion.
121#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
122pub struct FocusRing {
123    /// Stroke width of the ring/rect, px.
124    pub width: f32,
125    /// How far the ring sits *outside* the focused rect, px (macOS halo expands;
126    /// Windows hugs the control).
127    pub expansion: f32,
128    /// Soft glow halo (macOS) vs a single crisp stroke (Windows focus rectangle).
129    pub glow: bool,
130    /// Tint the ring with the palette `accent` (both platforms do; Device uses a
131    /// plain high-contrast outline instead).
132    pub accent_tinted: bool,
133}
134
135impl FocusRing {
136    /// macOS: a soft, accent-tinted ~3px ring that expands slightly (a halo).
137    pub fn macos() -> Self {
138        Self { width: 3.0, expansion: 2.5, glow: true, accent_tinted: true }
139    }
140
141    /// Windows 11: a crisp 2px accent focus rectangle hugging the control.
142    pub fn windows() -> Self {
143        Self { width: 2.0, expansion: 1.0, glow: false, accent_tinted: true }
144    }
145
146    /// Neutral: a modest 2px accent ring, no glow.
147    pub fn neutral() -> Self {
148        Self { width: 2.0, expansion: 1.5, glow: false, accent_tinted: true }
149    }
150}
151
152/// The **native-feel spec** — the per-platform cues that aren't already captured
153/// by [`Metrics`](super::Metrics)/[`ScrollSpec`](super::ScrollSpec)/
154/// [`Typography`](super::Typography)/[`SurfaceSpec`](super::SurfaceSpec). Carried
155/// on every [`Theme`](super::Theme) so a component (or the demo's chrome) can ask
156/// "what does this platform want here?" and drive the existing primitives.
157#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
158pub struct NativeFeel {
159    /// Which platform this feel models.
160    pub platform: Platform,
161    /// Windows **reveal highlight**: a cursor-follow edge glow on hover. Drives
162    /// [`effects::RevealHighlight`](crate::effects::RevealHighlight). Off on macOS
163    /// (it uses a quieter highlight) and Neutral/Device.
164    pub reveal_highlight: bool,
165    /// macOS **rubber-band** overscroll: the scroll spring is allowed to overshoot
166    /// past the edge and settle back. Read by the smooth-scroll glue.
167    pub rubber_band: bool,
168    /// Window-control chrome hint (host-drawn; see [`WindowControls`]).
169    pub window_controls: WindowControls,
170    /// Keyboard-focus indicator style (ring vs rectangle).
171    pub focus_ring: FocusRing,
172    /// How strongly the palette `accent` tints selection/material `∈[0,1]` —
173    /// macOS leans on accent more than the more neutral Fluent surfaces.
174    pub accent_tint: f32,
175    /// Drop-shadow elevation strength `∈[0,1]` — Windows 11 uses pronounced
176    /// elevation shadows; macOS keeps them restrained.
177    pub elevation: f32,
178}
179
180impl Default for NativeFeel {
181    fn default() -> Self {
182        Platform::detect().native_feel()
183    }
184}
185
186impl NativeFeel {
187    /// **macOS** — vibrancy-forward, accent-tinted soft focus ring, rubber-band
188    /// scroll, traffic-light controls, restrained shadow, no reveal highlight.
189    pub fn macos() -> Self {
190        Self {
191            platform: Platform::Mac,
192            reveal_highlight: false,
193            rubber_band: true,
194            window_controls: WindowControls::TrafficLights,
195            focus_ring: FocusRing::macos(),
196            accent_tint: 0.85,
197            elevation: 0.30,
198        }
199    }
200
201    /// **Windows 11** — Mica/Acrylic, reveal highlight on hover, crisp focus
202    /// rectangle, caption buttons, pronounced elevation, no rubber-band.
203    pub fn windows() -> Self {
204        Self {
205            platform: Platform::Windows,
206            reveal_highlight: true,
207            rubber_band: false,
208            window_controls: WindowControls::Caption,
209            focus_ring: FocusRing::windows(),
210            accent_tint: 0.65,
211            elevation: 0.70,
212        }
213    }
214
215    /// **Neutral** (Linux / cross-platform) — premium but OS-agnostic: no reveal,
216    /// no rubber-band, no app-drawn window controls, moderate shadow.
217    pub fn neutral() -> Self {
218        Self {
219            platform: Platform::Neutral,
220            reveal_highlight: false,
221            rubber_band: false,
222            window_controls: WindowControls::None,
223            focus_ring: FocusRing::neutral(),
224            accent_tint: 0.55,
225            elevation: 0.45,
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn detect_is_a_valid_platform() {
236        // Whatever we compiled on, detect() yields one of the three.
237        let p = Platform::detect();
238        assert!(matches!(p, Platform::Mac | Platform::Windows | Platform::Neutral));
239        // …and the cfg branch agrees with the target we built for.
240        #[cfg(target_os = "macos")]
241        assert_eq!(p, Platform::Mac);
242        #[cfg(target_os = "windows")]
243        assert_eq!(p, Platform::Windows);
244        #[cfg(not(any(target_os = "macos", target_os = "windows")))]
245        assert_eq!(p, Platform::Neutral);
246    }
247
248    #[test]
249    fn label_round_trips_and_is_fuzzy() {
250        for p in [Platform::Mac, Platform::Windows, Platform::Neutral] {
251            assert_eq!(Platform::from_label(p.label()), Some(p));
252        }
253        assert_eq!(Platform::from_label("macOS"), Some(Platform::Mac));
254        assert_eq!(Platform::from_label("WIN"), Some(Platform::Windows));
255        assert_eq!(Platform::from_label("linux"), Some(Platform::Neutral));
256        assert_eq!(Platform::from_label("plan9"), None);
257    }
258
259    #[test]
260    fn cycle_visits_all_three() {
261        let mut seen = std::collections::HashSet::new();
262        let mut p = Platform::Mac;
263        for _ in 0..3 {
264            seen.insert(p.label());
265            p = p.cycle();
266        }
267        assert_eq!(seen.len(), 3, "cycle must visit Mac, Windows, Neutral");
268        assert_eq!(p, Platform::Mac, "cycle returns to start after 3 steps");
269    }
270
271    #[test]
272    fn native_feel_cues_differ_per_platform() {
273        let m = NativeFeel::macos();
274        let w = NativeFeel::windows();
275        // The deltas that ARE the adaptive layer (asserted as data):
276        assert!(w.reveal_highlight && !m.reveal_highlight, "reveal is a Windows cue");
277        assert!(m.rubber_band && !w.rubber_band, "rubber-band is a macOS cue");
278        assert_ne!(m.window_controls, w.window_controls, "controls live on opposite sides");
279        assert!(m.window_controls.on_left() && !w.window_controls.on_left());
280        assert!(m.focus_ring.glow && !w.focus_ring.glow, "mac ring glows; win is a crisp rect");
281        assert!(w.elevation > m.elevation, "Windows elevation shadow is heavier");
282        assert!(m.accent_tint > w.accent_tint, "macOS leans on accent more");
283    }
284
285    #[test]
286    fn native_feel_serde_round_trips() {
287        let f = NativeFeel::macos();
288        let json = serde_json::to_string(&f).unwrap();
289        let back: NativeFeel = serde_json::from_str(&json).unwrap();
290        assert_eq!(f, back);
291    }
292}