facett-core/src/look/policy.rs
//! **Policy sub-structs** — the smaller serialisable pieces of [`Theme`](super::Theme):
//! [`ThemeMode`], [`FocusSpec`] (§13), [`SurfaceSpec`] (§5/§20), [`Motion`],
//! [`EffectsPolicy`] (gates glass/blur/motion), and [`PerfConfig`] (§6).
use egui::Color32;
use serde::{Deserialize, Serialize};
/// Light / dark / follow the OS.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ThemeMode {
Light,
Dark,
FollowSystem,
}
/// How aggressively decorative effects (glass, blur, transparency, decorative
/// motion) are allowed. `None` is the Device preset (§23): GPU still allowed for
/// speed, just no eye-candy.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum EffectsPolicy {
/// Full glass/blur/transparency/decorative motion.
Full,
/// Transparency + light motion, no blur (accessibility "reduce motion").
Reduced,
/// Nothing decorative — opaque, crisp, deterministic (Device / §23).
None,
}
impl EffectsPolicy {
/// May translucent fills / opacity be used?
pub fn allows_transparency(self) -> bool {
matches!(self, EffectsPolicy::Full | EffectsPolicy::Reduced)
}
/// May real backdrop blur (frosted glass) be used?
pub fn allows_blur(self) -> bool {
matches!(self, EffectsPolicy::Full)
}
/// May decorative (non-functional) motion play?
pub fn allows_decorative_motion(self) -> bool {
matches!(self, EffectsPolicy::Full)
}
}
const EFFECTS_ID: &str = "facett_effects_policy";
/// Publish the active [`EffectsPolicy`] on the context so effect-gated components
/// (e.g. `facett-graph3d`'s neon decor) resolve the same policy without holding a
/// copy of the theme — the same pattern as [`publish_keymap`](super::publish_keymap).
/// Called by [`crate::look::Theme::apply`].
pub fn publish_effects(ctx: &egui::Context, effects: EffectsPolicy) {
ctx.data_mut(|d| d.insert_temp(egui::Id::new(EFFECTS_ID), effects));
}
/// The active [`EffectsPolicy`] on the ui's context, or [`EffectsPolicy::None`]
/// (effects-off, today's flat render) if no theme has published one. The single
/// resolution source for effect-gated components.
pub fn effects_policy(ui: &egui::Ui) -> EffectsPolicy {
ui.data(|d| d.get_temp::<EffectsPolicy>(egui::Id::new(EFFECTS_ID))).unwrap_or(EffectsPolicy::None)
}
/// Focus, pane-nav, hints, revolver and form policy (§13). Off-by-default for the
/// optional surfaces so existing behaviour is unchanged until a host opts in.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct FocusSpec {
/// Show the which-key / Vimium-style hint overlay when triggered (FOC-3).
pub hints_enabled: bool,
/// The single character labels cycle through for hint badges.
pub hint_alphabet: [char; 8],
/// Bring a focused pane to centre/foreground (revolver, FOC-4). Disabled when
/// `EffectsPolicy::None`.
pub revolver_enabled: bool,
/// Directional focus picks the nearest rect in that direction (FOC-2).
pub spatial_nav: bool,
}
impl Default for FocusSpec {
fn default() -> Self {
Self {
hints_enabled: false,
hint_alphabet: ['a', 's', 'd', 'f', 'j', 'k', 'l', ';'],
revolver_enabled: false,
spatial_nav: true,
}
}
}
/// Surface / overlay defaults (§5, §20). A guest layered over a host can be
/// opaque (default), tinted (all backends), or frosted (wgpu-only, degrades to
/// tint). Effects here are gated by [`EffectsPolicy`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub enum SurfaceSpec {
/// Fully opaque (the default; Device forces this).
#[default]
Opaque,
/// Per-subtree opacity multiplier in [0,1].
Opacity(f32),
/// Translucent fill of `(rgba)` over the host — works on all backends.
Tint([u8; 4]),
/// Real backdrop blur (wgpu-only); degrades to `Tint(tint)` off-wgpu.
Frosted { blur_radius: f32, tint: [u8; 4] },
}
impl SurfaceSpec {
/// Resolve this surface under an [`EffectsPolicy`]: when effects are off we
/// always render opaque; frosted degrades to its tint when blur isn't allowed.
pub fn resolve(self, policy: EffectsPolicy) -> SurfaceSpec {
match self {
SurfaceSpec::Opaque => SurfaceSpec::Opaque,
_ if !policy.allows_transparency() => SurfaceSpec::Opaque,
SurfaceSpec::Frosted { tint, .. } if !policy.allows_blur() => SurfaceSpec::Tint(tint),
other => other,
}
}
/// The tint colour to paint over the host, if this surface paints one.
pub fn tint_color(self) -> Option<Color32> {
match self {
SurfaceSpec::Tint(c) | SurfaceSpec::Frosted { tint: c, .. } => {
Some(Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]))
}
_ => None,
}
}
}
/// Animation timing (§3). `decorative` motion is gated by [`EffectsPolicy`];
/// functional motion (e.g. smooth scroll) reads these durations directly. The
/// [`curve`](Motion::curve) is the *easing personality* of the platform — Windows
/// Fluent's "connected" snap (`EaseInOutCubic`) vs macOS's gentle spring
/// overshoot (`EaseOutBack`) — so a [`Tween`](crate::effects::Tween) built
/// [`from_motion`](crate::effects::Tween::from_motion) inherits the right feel.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Motion {
/// Standard transition duration (seconds).
pub duration: f32,
/// A faster duration for small/cheap transitions.
pub fast: f32,
/// The platform's easing personality (used by `Tween::from_motion*`).
pub curve: crate::effects::Curve,
}
impl Default for Motion {
fn default() -> Self {
Self { duration: 0.18, fast: 0.10, curve: crate::effects::Curve::EaseInOutCubic }
}
}
impl Motion {
/// Windows / Fluent — snappy "connected" motion: short durations, a smooth
/// accelerate-then-decelerate curve (no overshoot).
pub fn windows() -> Self {
Self { duration: 0.15, fast: 0.09, curve: crate::effects::Curve::EaseInOutCubic }
}
/// macOS / HIG — gentler, slightly longer motion with a confident spring
/// **overshoot-and-settle** (`EaseOutBack`), the rubber-band personality.
pub fn macos() -> Self {
Self { duration: 0.30, fast: 0.18, curve: crate::effects::Curve::EaseOutBack }
}
/// A neutral, cross-platform default: moderate duration, plain cubic ease.
pub fn neutral() -> Self {
Self { duration: 0.20, fast: 0.12, curve: crate::effects::Curve::EaseInOutCubic }
}
/// A `Tween` from this motion's standard duration + its platform curve.
pub fn tween(&self, from: f32, to: f32) -> crate::effects::Tween {
crate::effects::Tween::new(from, to, self.duration, self.curve)
}
/// A `Tween` from this motion's *fast* duration + its platform curve.
pub fn tween_fast(&self, from: f32, to: f32) -> crate::effects::Tween {
crate::effects::Tween::new(from, to, self.fast, self.curve)
}
}
/// Performance budgets / toggles (§6). The grid + plots read these to bound work.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct PerfConfig {
/// Target frame budget in milliseconds (60 fps ≈ 16.7 ms).
pub frame_budget_ms: f32,
/// Extra rows rendered above/below the viewport to hide fast-scroll seams.
pub overscan_rows: usize,
/// Prefer the wgpu backend for custom GPU paths where available (§6/§23).
pub prefer_wgpu: bool,
}
impl Default for PerfConfig {
fn default() -> Self {
Self { frame_budget_ms: 16.7, overscan_rows: 2, prefer_wgpu: true }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn effects_none_disables_everything_decorative() {
let p = EffectsPolicy::None;
assert!(!p.allows_transparency());
assert!(!p.allows_blur());
assert!(!p.allows_decorative_motion());
}
#[test]
fn frosted_degrades_to_tint_without_blur() {
let frosted = SurfaceSpec::Frosted { blur_radius: 8.0, tint: [10, 12, 26, 180] };
// Reduced allows transparency but not blur → falls back to its tint.
assert_eq!(frosted.resolve(EffectsPolicy::Reduced), SurfaceSpec::Tint([10, 12, 26, 180]));
// None forces opaque.
assert_eq!(frosted.resolve(EffectsPolicy::None), SurfaceSpec::Opaque);
// Full keeps it frosted.
assert_eq!(frosted.resolve(EffectsPolicy::Full), frosted);
}
#[test]
fn tint_forced_opaque_under_none() {
let tint = SurfaceSpec::Tint([1, 2, 3, 200]);
assert_eq!(tint.resolve(EffectsPolicy::None), SurfaceSpec::Opaque);
assert_eq!(tint.resolve(EffectsPolicy::Full), tint);
}
}