facett-core/src/look/platform.rs
//! **Platform-adaptive "native feel" layer** — make facett feel *at home* on the
//! host OS. A [`Platform`] auto-detects the running OS (`cfg!(target_os)`, with an
//! explicit override) and selects a preset; a [`NativeFeel`] spec captures the
//! per-platform *cues* that map onto facett's **existing** primitives (it adds no
//! new render engine — it parameterises the ones we already have).
//!
//! # The cue → primitive map (the adaptive layer)
//!
//! | cue | macOS | Windows 11 | facett primitive |
//! |-----------------------------|----------------------------------------|----------------------------------------------|------------------|
//! | translucent material | **vibrancy** — high blur, low tint α | **Mica** — lower blur, higher tint α | [`SurfaceSpec::Frosted`](super::SurfaceSpec) (dual-Kawase blur in `render/gpu/blur.rs`) |
//! | corner radius | soft (8 ctrl / 12 win) | moderate (6 ctrl / 8 win) | [`Metrics`](super::Metrics) |
//! | density | generous (44×28, 12pt margin) | denser (40×24, 8pt margin) | [`Metrics`](super::Metrics) |
//! | scrollbars | floating, fade-when-idle | solid, always-visible | [`ScrollSpec`](super::ScrollSpec) |
//! | overscroll | **rubber-band** spring overshoot | hard edge | [`rubber_band`](NativeFeel::rubber_band) → `SmoothScroll` |
//! | motion personality | gentle spring **overshoot** (`EaseOutBack`), ~0.30s | snappy "connected" (`EaseInOutCubic`), ~0.15s | [`Motion`](super::Motion) curve + duration |
//! | hover signature | subtle highlight | **reveal highlight** (cursor-follow edge glow) | [`reveal_highlight`](NativeFeel::reveal_highlight) → [`effects::RevealHighlight`](crate::effects::RevealHighlight) |
//! | focus | soft accent **ring** (~3px glow) | crisp focus **rectangle** | [`FocusRing`] |
//! | accent usage | tints selection + focus ring | tints reveal + focus rect | [`accent_tint`](NativeFeel::accent_tint) |
//! | elevation | restrained shadow | pronounced drop-shadow elevation | [`elevation`](NativeFeel::elevation) |
//! | window controls | **traffic lights** (top-left) | **caption buttons** (top-right) | [`WindowControls`] (host-drawn; data hint here) |
//! | typography | SF Pro | Segoe UI Variable | [`Typography`](super::Typography) `UiFont` |
//!
//! All of this is `serde` (a host can author/override it in TOML/JSON) and carries
//! **no egui-memory state and no wall-clock** (FC-1/FC-7): the reveal glow + spring
//! overshoot are driven by the caller's own clock through the existing
//! [`effects`](crate::effects) / [`scroll_engine`](crate::scroll_engine) primitives.
use serde::{Deserialize, Serialize};
/// The desktop platform we present as. `Neutral` is the cross-platform / Linux
/// default: premium but without OS-specific chrome cues (no traffic lights, no
/// reveal highlight).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Platform {
Mac,
Windows,
/// Linux / unknown / explicit "no OS-specific chrome".
Neutral,
}
impl Platform {
/// **Auto-detect** the host platform from `cfg!(target_os)` at compile time:
/// macOS → [`Mac`](Platform::Mac), Windows → [`Windows`](Platform::Windows),
/// everything else (Linux/BSD/unknown) → [`Neutral`](Platform::Neutral).
///
/// This is the *default* — a host may override it explicitly (the demo's
/// runtime toggle does exactly that, so the look is demonstrable on any OS).
pub const fn detect() -> Platform {
if cfg!(target_os = "macos") {
Platform::Mac
} else if cfg!(target_os = "windows") {
Platform::Windows
} else {
Platform::Neutral
}
}
/// A short stable label (also the serde discriminant for the override setting).
pub fn label(self) -> &'static str {
match self {
Platform::Mac => "mac",
Platform::Windows => "windows",
Platform::Neutral => "neutral",
}
}
/// Parse a label back (fuzzy: case-insensitive, `macos`/`osx` ≡ mac,
/// `win` ≡ windows, anything else → `None`). The override-setting reader.
pub fn from_label(s: &str) -> Option<Platform> {
match s.trim().to_ascii_lowercase().as_str() {
"mac" | "macos" | "osx" | "darwin" | "apple" => Some(Platform::Mac),
"windows" | "win" | "win11" | "win32" => Some(Platform::Windows),
"neutral" | "linux" | "nix" | "default" | "other" => Some(Platform::Neutral),
_ => None,
}
}
/// Cycle Mac → Windows → Neutral → Mac (the runtime toggle order).
pub fn cycle(self) -> Platform {
match self {
Platform::Mac => Platform::Windows,
Platform::Windows => Platform::Neutral,
Platform::Neutral => Platform::Mac,
}
}
/// The default-mode [`NativeFeel`] spec for this platform.
pub fn native_feel(self) -> NativeFeel {
match self {
Platform::Mac => NativeFeel::macos(),
Platform::Windows => NativeFeel::windows(),
Platform::Neutral => NativeFeel::neutral(),
}
}
}
/// Where the window-management controls live + their shape. Real per-OS chrome is
/// *host-drawn* (a follow-up); this is the **data hint** so a host (or the demo's
/// faux title-bar) knows which side to render and the look the preset wants.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum WindowControls {
/// macOS: three coloured "traffic-light" circles, top-**left**.
TrafficLights,
/// Windows: minimise/maximise/close caption buttons, top-**right**.
Caption,
/// No app-drawn window controls (host/borderless).
None,
}
impl WindowControls {
/// Are the controls on the left edge (macOS) vs the right (Windows)?
pub fn on_left(self) -> bool {
matches!(self, WindowControls::TrafficLights)
}
}
/// The keyboard-focus indicator style — macOS draws a soft accent **ring** (a
/// glow halo), Windows 11 a crisp focus **rectangle**. Both read the palette's
/// `accent`; the difference is width/softness/expansion.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct FocusRing {
/// Stroke width of the ring/rect, px.
pub width: f32,
/// How far the ring sits *outside* the focused rect, px (macOS halo expands;
/// Windows hugs the control).
pub expansion: f32,
/// Soft glow halo (macOS) vs a single crisp stroke (Windows focus rectangle).
pub glow: bool,
/// Tint the ring with the palette `accent` (both platforms do; Device uses a
/// plain high-contrast outline instead).
pub accent_tinted: bool,
}
impl FocusRing {
/// macOS: a soft, accent-tinted ~3px ring that expands slightly (a halo).
pub fn macos() -> Self {
Self { width: 3.0, expansion: 2.5, glow: true, accent_tinted: true }
}
/// Windows 11: a crisp 2px accent focus rectangle hugging the control.
pub fn windows() -> Self {
Self { width: 2.0, expansion: 1.0, glow: false, accent_tinted: true }
}
/// Neutral: a modest 2px accent ring, no glow.
pub fn neutral() -> Self {
Self { width: 2.0, expansion: 1.5, glow: false, accent_tinted: true }
}
}
/// The **native-feel spec** — the per-platform cues that aren't already captured
/// by [`Metrics`](super::Metrics)/[`ScrollSpec`](super::ScrollSpec)/
/// [`Typography`](super::Typography)/[`SurfaceSpec`](super::SurfaceSpec). Carried
/// on every [`Theme`](super::Theme) so a component (or the demo's chrome) can ask
/// "what does this platform want here?" and drive the existing primitives.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct NativeFeel {
/// Which platform this feel models.
pub platform: Platform,
/// Windows **reveal highlight**: a cursor-follow edge glow on hover. Drives
/// [`effects::RevealHighlight`](crate::effects::RevealHighlight). Off on macOS
/// (it uses a quieter highlight) and Neutral/Device.
pub reveal_highlight: bool,
/// macOS **rubber-band** overscroll: the scroll spring is allowed to overshoot
/// past the edge and settle back. Read by the smooth-scroll glue.
pub rubber_band: bool,
/// Window-control chrome hint (host-drawn; see [`WindowControls`]).
pub window_controls: WindowControls,
/// Keyboard-focus indicator style (ring vs rectangle).
pub focus_ring: FocusRing,
/// How strongly the palette `accent` tints selection/material `∈[0,1]` —
/// macOS leans on accent more than the more neutral Fluent surfaces.
pub accent_tint: f32,
/// Drop-shadow elevation strength `∈[0,1]` — Windows 11 uses pronounced
/// elevation shadows; macOS keeps them restrained.
pub elevation: f32,
}
impl Default for NativeFeel {
fn default() -> Self {
Platform::detect().native_feel()
}
}
impl NativeFeel {
/// **macOS** — vibrancy-forward, accent-tinted soft focus ring, rubber-band
/// scroll, traffic-light controls, restrained shadow, no reveal highlight.
pub fn macos() -> Self {
Self {
platform: Platform::Mac,
reveal_highlight: false,
rubber_band: true,
window_controls: WindowControls::TrafficLights,
focus_ring: FocusRing::macos(),
accent_tint: 0.85,
elevation: 0.30,
}
}
/// **Windows 11** — Mica/Acrylic, reveal highlight on hover, crisp focus
/// rectangle, caption buttons, pronounced elevation, no rubber-band.
pub fn windows() -> Self {
Self {
platform: Platform::Windows,
reveal_highlight: true,
rubber_band: false,
window_controls: WindowControls::Caption,
focus_ring: FocusRing::windows(),
accent_tint: 0.65,
elevation: 0.70,
}
}
/// **Neutral** (Linux / cross-platform) — premium but OS-agnostic: no reveal,
/// no rubber-band, no app-drawn window controls, moderate shadow.
pub fn neutral() -> Self {
Self {
platform: Platform::Neutral,
reveal_highlight: false,
rubber_band: false,
window_controls: WindowControls::None,
focus_ring: FocusRing::neutral(),
accent_tint: 0.55,
elevation: 0.45,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_is_a_valid_platform() {
// Whatever we compiled on, detect() yields one of the three.
let p = Platform::detect();
assert!(matches!(p, Platform::Mac | Platform::Windows | Platform::Neutral));
// …and the cfg branch agrees with the target we built for.
#[cfg(target_os = "macos")]
assert_eq!(p, Platform::Mac);
#[cfg(target_os = "windows")]
assert_eq!(p, Platform::Windows);
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
assert_eq!(p, Platform::Neutral);
}
#[test]
fn label_round_trips_and_is_fuzzy() {
for p in [Platform::Mac, Platform::Windows, Platform::Neutral] {
assert_eq!(Platform::from_label(p.label()), Some(p));
}
assert_eq!(Platform::from_label("macOS"), Some(Platform::Mac));
assert_eq!(Platform::from_label("WIN"), Some(Platform::Windows));
assert_eq!(Platform::from_label("linux"), Some(Platform::Neutral));
assert_eq!(Platform::from_label("plan9"), None);
}
#[test]
fn cycle_visits_all_three() {
let mut seen = std::collections::HashSet::new();
let mut p = Platform::Mac;
for _ in 0..3 {
seen.insert(p.label());
p = p.cycle();
}
assert_eq!(seen.len(), 3, "cycle must visit Mac, Windows, Neutral");
assert_eq!(p, Platform::Mac, "cycle returns to start after 3 steps");
}
#[test]
fn native_feel_cues_differ_per_platform() {
let m = NativeFeel::macos();
let w = NativeFeel::windows();
// The deltas that ARE the adaptive layer (asserted as data):
assert!(w.reveal_highlight && !m.reveal_highlight, "reveal is a Windows cue");
assert!(m.rubber_band && !w.rubber_band, "rubber-band is a macOS cue");
assert_ne!(m.window_controls, w.window_controls, "controls live on opposite sides");
assert!(m.window_controls.on_left() && !w.window_controls.on_left());
assert!(m.focus_ring.glow && !w.focus_ring.glow, "mac ring glows; win is a crisp rect");
assert!(w.elevation > m.elevation, "Windows elevation shadow is heavier");
assert!(m.accent_tint > w.accent_tint, "macOS leans on accent more");
}
#[test]
fn native_feel_serde_round_trips() {
let f = NativeFeel::macos();
let json = serde_json::to_string(&f).unwrap();
let back: NativeFeel = serde_json::from_str(&json).unwrap();
assert_eq!(f, back);
}
}