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}