facett-core/src/look/scroll.rs
//! **Scrollbars** (§10) — a serialisable [`ScrollSpec`] mapping onto egui's
//! `ScrollStyle` (all 14 fields incl. the six opacity fields) plus a per-preset
//! visibility default. Windows: solid, ~14–16 px, visible. macOS: floating,
//! ~8–10 px, dormant ≈ 0, fade on hover. Device: solid, high-contrast, always
//! visible.
use egui::style::ScrollStyle;
use serde::{Deserialize, Serialize};
/// When the scrollbar shows — maps to egui `ScrollBarVisibility`, applied
/// per-`ScrollArea` (it is not a `ScrollStyle` field in egui 0.34).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScrollVisibility {
AlwaysVisible,
VisibleWhenNeeded,
AlwaysHidden,
}
impl ScrollVisibility {
pub fn to_egui(self) -> egui::scroll_area::ScrollBarVisibility {
use egui::scroll_area::ScrollBarVisibility as V;
match self {
ScrollVisibility::AlwaysVisible => V::AlwaysVisible,
ScrollVisibility::VisibleWhenNeeded => V::VisibleWhenNeeded,
ScrollVisibility::AlwaysHidden => V::AlwaysHidden,
}
}
}
/// All 14 `ScrollStyle` knobs + the visibility default, serialisable. The six
/// opacity fields make a macOS bar dormant-invisible until hover.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct ScrollSpec {
pub floating: bool,
pub bar_width: f32,
pub handle_min_length: f32,
pub bar_inner_margin: f32,
pub bar_outer_margin: f32,
pub floating_width: f32,
pub floating_allocated_width: f32,
pub foreground_color: bool,
pub dormant_background_opacity: f32,
pub active_background_opacity: f32,
pub interact_background_opacity: f32,
pub dormant_handle_opacity: f32,
pub active_handle_opacity: f32,
pub interact_handle_opacity: f32,
pub visibility: ScrollVisibility,
}
impl Default for ScrollSpec {
fn default() -> Self {
Self::windows()
}
}
impl ScrollSpec {
/// Windows: solid, always-present, chunky bar.
pub fn windows() -> Self {
Self {
floating: false,
bar_width: 14.0,
handle_min_length: 16.0,
bar_inner_margin: 4.0,
bar_outer_margin: 0.0,
floating_width: 2.0,
floating_allocated_width: 0.0,
foreground_color: false,
dormant_background_opacity: 0.6,
active_background_opacity: 0.7,
interact_background_opacity: 0.9,
dormant_handle_opacity: 0.7,
active_handle_opacity: 0.9,
interact_handle_opacity: 1.0,
visibility: ScrollVisibility::AlwaysVisible,
}
}
/// macOS: floating, slim, dormant-invisible, fades in on hover/scroll.
pub fn macos() -> Self {
Self {
floating: true,
bar_width: 10.0,
handle_min_length: 16.0,
bar_inner_margin: 2.0,
bar_outer_margin: 0.0,
floating_width: 8.0,
floating_allocated_width: 0.0,
foreground_color: true,
dormant_background_opacity: 0.0,
active_background_opacity: 0.0,
interact_background_opacity: 0.4,
dormant_handle_opacity: 0.0,
active_handle_opacity: 0.6,
interact_handle_opacity: 1.0,
visibility: ScrollVisibility::VisibleWhenNeeded,
}
}
/// Device: solid, high-contrast, always visible (no fade to find in sun).
pub fn device() -> Self {
Self {
floating: false,
bar_width: 16.0,
handle_min_length: 24.0,
bar_inner_margin: 4.0,
bar_outer_margin: 0.0,
floating_width: 2.0,
floating_allocated_width: 0.0,
foreground_color: false,
dormant_background_opacity: 1.0,
active_background_opacity: 1.0,
interact_background_opacity: 1.0,
dormant_handle_opacity: 1.0,
active_handle_opacity: 1.0,
interact_handle_opacity: 1.0,
visibility: ScrollVisibility::AlwaysVisible,
}
}
/// Build an egui [`ScrollStyle`], modelling the 14 work-order fields and
/// inheriting egui's defaults for the rest (`content_margin`, `fade`, which
/// 0.34 added beyond the spec). Starting from `solid()`/`floating()` keeps us
/// forward-compatible with future egui fields.
pub fn to_scroll_style(&self) -> ScrollStyle {
let mut s = if self.floating { ScrollStyle::floating() } else { ScrollStyle::solid() };
s.floating = self.floating;
s.bar_width = self.bar_width;
s.handle_min_length = self.handle_min_length;
s.bar_inner_margin = self.bar_inner_margin;
s.bar_outer_margin = self.bar_outer_margin;
s.floating_width = self.floating_width;
s.floating_allocated_width = self.floating_allocated_width;
s.foreground_color = self.foreground_color;
s.dormant_background_opacity = self.dormant_background_opacity;
s.active_background_opacity = self.active_background_opacity;
s.interact_background_opacity = self.interact_background_opacity;
s.dormant_handle_opacity = self.dormant_handle_opacity;
s.active_handle_opacity = self.active_handle_opacity;
s.interact_handle_opacity = self.interact_handle_opacity;
s
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn windows_is_solid_and_always_visible() {
let s = ScrollSpec::windows();
assert!(!s.floating);
assert_eq!(s.visibility, ScrollVisibility::AlwaysVisible);
assert!(s.bar_width >= 14.0);
}
#[test]
fn macos_is_floating_and_dormant_invisible() {
let s = ScrollSpec::macos();
assert!(s.floating);
assert_eq!(s.dormant_handle_opacity, 0.0, "macOS bar fades away when idle");
}
#[test]
fn device_is_always_opaque() {
let s = ScrollSpec::device();
assert_eq!(s.dormant_handle_opacity, 1.0);
assert_eq!(s.visibility, ScrollVisibility::AlwaysVisible);
}
#[test]
fn to_scroll_style_copies_all_fields() {
let s = ScrollSpec::macos();
let e = s.to_scroll_style();
assert_eq!(e.bar_width, s.bar_width);
assert_eq!(e.floating, s.floating);
assert_eq!(e.interact_handle_opacity, s.interact_handle_opacity);
}
}