Add panel-wide styles and the Morph hub (#26)

Panel appearance now applies to the whole panel, with seven styles: Floating, Capsules, Notch, Edge to edge, Fused islands, Adaptive and Morph.

- Share composition, geometry, and motion through the renderer-independent `ferese-panel` crate, including native materials, input regions, and Settings previews.
- Add the Morph hub with media, calendar, notifications, controls, workspace previews, hover feedback, and attached mode. Keep recording and low-battery status visible and respect window clearance.
- Cache workspace preview geometry, draw thumbnails directly, centralize Morph transitions, and keep rendering settings local to each shell instance.
- Put Record Screen in the Morph header and show the display control only when an external monitor is connected. Update panel documentation, the desktop screenshot, and native check style names.

Local validation included 660 compositor tests, 191 shell tests, isolated preview-cache checks, GPU Morph captures at 1×/1.25×/1.5×, formatting, and Clippy with warnings denied. The final header change passed all 23 Morph tests. Release workflow jobs are skipped for this PR.
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-12 01:31:34 +01:00
1 parent 42c3b290dd
commit e22f0ebef9
131 files changed
+12562 -1634

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
use std::time::Duration;
use crate::PanelStyle;
pub const HYSTERESIS: Duration = Duration::from_millis(150);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct WindowState {
pub maximized: bool,
pub fullscreen: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Presentation {
Floating,
Flat,
Hidden,
}
impl Presentation {
pub fn style(self) -> Option<PanelStyle> {
match self {
Self::Floating => Some(PanelStyle::Floating),
Self::Flat => Some(PanelStyle::Flat),
Self::Hidden => None,
}
}
}
impl From<WindowState> for Presentation {
fn from(state: WindowState) -> Self {
if state.fullscreen {
Self::Hidden
} else if state.maximized {
Self::Flat
} else {
Self::Floating
}
}
}
/// One instance belongs to one output. A deadline exists only while a new
/// ordinary window state remains stable; fullscreen takes effect immediately.
#[derive(Clone, Debug)]
pub struct Adaptive {
current: Presentation,
pending: Option<(Presentation, Duration)>,
}
impl Adaptive {
pub fn new(state: WindowState) -> Self {
Self {
current: state.into(),
pending: None,
}
}
pub fn current(&self) -> Presentation {
self.current
}
pub fn observe(&mut self, state: WindowState, now: Duration) -> bool {
let desired = Presentation::from(state);
if desired == self.current {
self.pending = None;
return false;
}
if desired == Presentation::Hidden || self.current == Presentation::Hidden {
self.current = desired;
self.pending = None;
return true;
}
match self.pending {
Some((target, since)) if target == desired && now >= since => {
if now - since >= HYSTERESIS {
self.current = target;
self.pending = None;
true
} else {
false
}
}
_ => {
self.pending = Some((desired, now));
false
}
}
}
pub fn deadline(&self) -> Option<Duration> {
self.pending.map(|(_, since)| since.saturating_add(HYSTERESIS))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(ms: u64) -> Duration {
Duration::from_millis(ms)
}
fn maximized() -> WindowState {
WindowState {
maximized: true,
fullscreen: false,
}
}
#[test]
fn boundary_flapping_cancels_the_pending_style() {
let mut state = Adaptive::new(WindowState::default());
assert!(!state.observe(maximized(), at(0)));
assert!(!state.observe(WindowState::default(), at(149)));
assert_eq!(state.deadline(), None);
assert!(!state.observe(maximized(), at(150)));
assert!(!state.observe(maximized(), at(299)));
assert!(state.observe(maximized(), at(300)));
assert_eq!(state.current(), Presentation::Flat);
}
#[test]
fn fullscreen_cancels_pending_work_and_restores_without_delay() {
let mut state = Adaptive::new(WindowState::default());
state.observe(maximized(), at(0));
assert!(state.observe(
WindowState {
fullscreen: true,
..maximized()
},
at(1)
));
assert_eq!(state.current().style(), None);
assert_eq!(state.deadline(), None);
assert!(state.observe(maximized(), at(2)));
assert_eq!(state.current().style(), Some(PanelStyle::Flat));
}
#[test]
fn outputs_change_independently() {
let mut left = Adaptive::new(WindowState::default());
let right = Adaptive::new(WindowState::default());
left.observe(maximized(), at(0));
left.observe(maximized(), at(150));
assert_eq!(left.current(), Presentation::Flat);
assert_eq!(right.current(), Presentation::Floating);
}
}
+14
View File
@@ -0,0 +1,14 @@
pub mod adaptive;
pub mod morph;
pub mod motion;
pub mod panel;
pub use panel::*;
pub mod outline;
pub mod scene;
pub mod shape;
pub use shape::Style as PanelStyle;
pub const fn default_island_padding() -> f32 {
4.0
}
+492
View File
@@ -0,0 +1,492 @@
use std::time::Duration;
use ferese_layout::Rect;
use serde::{Deserialize, Serialize};
use crate::motion::{Geometry, Motion};
const FADE: Duration = Duration::from_millis(120);
const CONTENT_DELAY: Duration = Duration::from_millis(130);
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Profile {
pub peek_on_hover: bool,
pub attached: bool,
pub idle_width: f64,
pub peek_width: f64,
pub peek_height: f64,
pub toast_width: f64,
pub toast_height: f64,
pub expanded_width: f64,
pub workspaces_height: f64,
pub media_height: f64,
pub calendar_height: f64,
pub notifications_height: f64,
pub controls_height: f64,
pub expanded_radius: f64,
}
impl Default for Profile {
fn default() -> Self {
Self {
peek_on_hover: true,
attached: false,
idle_width: 150.,
peek_width: 260.,
peek_height: 44.,
toast_width: 320.,
toast_height: 48.,
expanded_width: 340.,
workspaces_height: 176.,
media_height: 170.,
calendar_height: 230.,
notifications_height: 176.,
controls_height: 248.,
expanded_radius: 26.,
}
}
}
impl Profile {
pub fn validate(self) -> Result<(), &'static str> {
let sizes = [
self.idle_width,
self.peek_width,
self.peek_height,
self.toast_width,
self.toast_height,
self.expanded_width,
self.workspaces_height,
self.media_height,
self.calendar_height,
self.notifications_height,
self.controls_height,
];
if sizes
.iter()
.any(|value| !value.is_finite() || !(24. ..=1024.).contains(value))
|| !self.expanded_radius.is_finite()
|| !(0. ..=128.).contains(&self.expanded_radius)
{
Err("hub dimensions must be 24..1024 and its expanded radius 0..128")
} else {
Ok(())
}
}
pub fn size(self, mode: Mode, collapsed_height: f64) -> [f64; 2] {
match mode {
Mode::Idle => [self.idle_width, collapsed_height],
Mode::Peek => [self.peek_width, self.peek_height],
Mode::Toast => [self.toast_width, self.toast_height],
Mode::Expanded(content) => [
self.expanded_width,
match content {
Content::Workspaces => self.workspaces_height,
Content::Media => self.media_height,
Content::Calendar => self.calendar_height,
Content::Notifications => self.notifications_height,
Content::Controls => self.controls_height,
},
],
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Content {
Workspaces,
Media,
Calendar,
Notifications,
Controls,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Mode {
#[default]
Idle,
Peek,
Toast,
Expanded(Content),
}
impl Mode {
pub fn expanded(self) -> bool {
matches!(self, Self::Expanded(_))
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Sample {
pub geometry: Geometry,
pub previous: Mode,
pub previous_opacity: f32,
pub current: Mode,
pub opacity: f32,
}
/// The exclusive zone is deliberately absent. Expansion changes the painted
/// island and its hit target; the caller retains the collapsed reservation.
#[derive(Clone, Debug)]
pub struct Hub {
profile: Profile,
pins: Option<[f64; 4]>,
height: f64,
motion: Motion,
mode: Mode,
previous: Mode,
previous_opacity: f32,
initial_opacity: f32,
elapsed: Duration,
available: [f64; 2],
}
impl Hub {
pub fn new(profile: Profile, collapsed_height: f64) -> Result<Self, &'static str> {
profile.validate()?;
if !collapsed_height.is_finite() || !(24. ..=128.).contains(&collapsed_height) {
return Err("hub height must be 24..128");
}
let size = profile.size(Mode::Idle, collapsed_height);
Ok(Self {
profile,
pins: None,
height: collapsed_height,
motion: Motion::with_spring(
Geometry::rounded(Rect::new(0., 0., size[0], size[1]), size[1] / 2.),
ferese_animation::SpringConfig {
stiffness: 300.,
damping: 2. * 0.7 * 300_f64.sqrt(),
position_tolerance: 0.05,
velocity_tolerance: 0.05,
crossing: ferese_animation::CrossingPolicy::AllowOvershoot,
..Default::default()
},
)?,
mode: Mode::Idle,
previous: Mode::Idle,
previous_opacity: 0.,
initial_opacity: 1.,
elapsed: FADE,
available: [f64::MAX; 2],
})
}
pub fn pin_radii(&mut self, pins: Option<[f64; 4]>) -> Result<(), &'static str> {
if pins.is_some_and(|pins| {
pins.iter()
.any(|value| !value.is_finite() || !(0. ..=128.).contains(value))
}) {
return Err("hub radius pins must be finite values between 0 and 128");
}
self.pins = pins;
let mut target = self.motion.target();
target.radii = pins.unwrap_or(
[if self.mode.expanded() {
self.profile.expanded_radius
} else {
target.bounds.height / 2.
}; 4],
);
self.motion.retarget(target)
}
pub fn mode(&self) -> Mode {
self.mode
}
pub fn retarget_peek(&mut self, width: f64, available: [f64; 2]) -> Result<(), &'static str> {
if !width.is_finite() || !(24. ..=1024.).contains(&width) {
return Err("peek width must be 24..1024");
}
let configured = self.profile.peek_width;
self.profile.peek_width = width;
let result = self.retarget(Mode::Peek, available);
self.profile.peek_width = configured;
result
}
pub fn retarget_idle(&mut self, width: f64, available: [f64; 2]) -> Result<(), &'static str> {
if !width.is_finite() || !(24. ..=1024.).contains(&width) {
return Err("idle width must be 24..1024");
}
let configured = self.profile.idle_width;
self.profile.idle_width = width;
let result = self.retarget(Mode::Idle, available);
self.profile.idle_width = configured;
result
}
pub fn retarget(&mut self, mode: Mode, available: [f64; 2]) -> Result<(), &'static str> {
if available.iter().any(|value| !value.is_finite() || *value <= 0.) {
return Err("hub requires a finite positive available area");
}
let [width, height] = self.profile.size(mode, self.height);
let width = width.min(available[0]);
let height = height.min(available[1]);
let radius = if mode.expanded() {
self.profile.expanded_radius
} else {
height / 2.
};
let mut geometry = Geometry::rounded(Rect::new(0., 0., width, height), radius);
geometry.radii = self.pins.unwrap_or(geometry.radii);
if mode == self.mode {
self.available = available;
return self.motion.retarget(geometry);
}
let sample = self.sample();
self.available = available;
if sample.previous_opacity > sample.opacity {
self.previous = sample.previous;
self.previous_opacity = sample.previous_opacity;
} else {
self.previous = sample.current;
self.previous_opacity = sample.opacity;
}
self.initial_opacity = if mode == sample.current {
sample.opacity
} else if mode == sample.previous {
sample.previous_opacity
} else {
0.
};
if self.previous == mode {
self.previous_opacity = 0.;
}
self.mode = mode;
self.elapsed = Duration::ZERO;
self.motion.retarget(geometry)
}
/// Fit the expanded shell to measured content without restarting its content fade.
pub fn fit_content_height(&mut self, height: f64) -> Result<(), &'static str> {
if !height.is_finite() || height <= 0. {
return Err("content height must be finite and positive");
}
if self.mode.expanded() {
let mut target = self.motion.target();
target.bounds.height = height
.max(24.)
.min(self.profile.size(self.mode, self.height)[1])
.min(self.available[1]);
self.motion.retarget(target)?;
}
Ok(())
}
/// Replace content inside an already settled shape without restarting its springs.
pub fn replace_content(&mut self) {
self.previous = self.mode;
self.previous_opacity = self.sample().opacity;
self.initial_opacity = 0.;
self.elapsed = Duration::ZERO;
}
pub fn advance(&mut self, elapsed: Duration, reduced_motion: bool) -> bool {
self.elapsed = self.elapsed.saturating_add(elapsed);
self.motion.advance(elapsed, reduced_motion);
if reduced_motion {
self.previous_opacity = 0.;
self.initial_opacity = 1.;
}
self.active()
}
pub fn active(&self) -> bool {
self.motion.active() || self.sample().previous_opacity > 0. || self.sample().opacity < 1.
}
pub fn sample(&self) -> Sample {
let fade = |elapsed: Duration| (elapsed.as_secs_f32() / FADE.as_secs_f32()).clamp(0., 1.);
let opacity =
self.initial_opacity + (1. - self.initial_opacity) * fade(self.elapsed.saturating_sub(CONTENT_DELAY));
let mut geometry = self.motion.current();
geometry.bounds.width = geometry.bounds.width.clamp(0., self.available[0]);
geometry.bounds.height = geometry.bounds.height.clamp(0., self.available[1]);
geometry.radii = geometry.radii.map(|radius| radius.max(0.));
Sample {
geometry,
previous: self.previous,
previous_opacity: self.previous_opacity * (1. - fade(self.elapsed)),
current: self.mode,
opacity,
}
}
pub fn allocation_size(&self) -> [f64; 2] {
let maximum = self.motion.maximum_size();
std::array::from_fn(|i| maximum[i].min(self.available[i]))
}
pub fn target_size(&self) -> [f64; 2] {
let target = self.motion.target().bounds;
[target.width, target.height]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workspace_width_changes_preserve_content_and_motion() {
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
hub.retarget_idle(270., [800., 600.]).unwrap();
hub.advance(Duration::from_millis(60), false);
let before = hub.sample();
assert_eq!(before.opacity, 1.);
hub.retarget_idle(300., [800., 600.]).unwrap();
assert_eq!(hub.sample(), before);
assert_eq!(hub.target_size(), [300., 34.]);
hub.advance(Duration::from_secs(2), false);
assert_eq!(hub.sample().geometry.bounds.width, 300.);
assert_eq!(hub.sample().opacity, 1.);
hub.retarget_peek(360., [320., 600.]).unwrap();
assert_eq!(hub.target_size(), [320., 44.]);
}
#[test]
fn measured_height_preserves_fade_and_motion_and_caps_long_content() {
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
hub.retarget(Mode::Expanded(Content::Controls), [800., 600.]).unwrap();
hub.advance(Duration::from_millis(180), false);
let before = hub.sample();
hub.fit_content_height(148.).unwrap();
assert_eq!(hub.target_size(), [340., 148.]);
assert_eq!(hub.sample().geometry, before.geometry);
assert_eq!(hub.sample().opacity, before.opacity);
hub.advance(Duration::from_secs(2), false);
assert_eq!(hub.sample().geometry.bounds.height, 148.);
assert_eq!(hub.sample().opacity, 1.);
hub.fit_content_height(900.).unwrap();
assert_eq!(hub.target_size(), [340., Profile::default().controls_height]);
hub.retarget(Mode::Idle, [800., 600.]).unwrap();
hub.fit_content_height(100.).unwrap();
assert_eq!(hub.target_size(), [150., 34.]);
}
#[test]
fn a_content_swap_fades_without_restarting_the_geometry() {
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
hub.retarget(Mode::Expanded(Content::Controls), [800., 600.]).unwrap();
hub.advance(Duration::from_secs(2), false);
hub.advance(Duration::from_secs(1), false);
let geometry = hub.sample().geometry;
hub.replace_content();
assert_eq!(hub.sample().geometry, geometry);
assert_eq!(hub.sample().opacity, 0.);
assert_eq!(hub.sample().previous_opacity, 1.);
hub.advance(Duration::from_millis(60), false);
assert_eq!(hub.sample().opacity, 0.);
assert!((hub.sample().previous_opacity - 0.5).abs() < 0.001);
hub.advance(Duration::from_millis(130), false);
assert!((hub.sample().opacity - 0.5).abs() < 0.001);
assert_eq!(hub.sample().previous_opacity, 0.);
hub.advance(Duration::from_millis(60), false);
assert!(!hub.active());
}
#[test]
fn content_swap_uses_a_fixed_delay_instead_of_waiting_for_spring_settlement() {
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
assert!(!hub.active());
hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.]).unwrap();
assert_eq!(hub.target_size(), [340., 230.]);
hub.advance(Duration::from_millis(120), false);
assert_eq!(hub.sample().opacity, 0.);
assert_eq!(hub.sample().previous_opacity, 0.);
hub.advance(Duration::from_millis(70), false);
assert!((hub.sample().opacity - 0.5).abs() < 0.001);
hub.advance(Duration::from_millis(60), false);
assert_eq!(hub.sample().opacity, 1.);
assert!(hub.active(), "content finishes while the shape is still settling");
for _ in 0..50 {
hub.advance(Duration::from_millis(16), false);
}
assert_eq!(hub.sample().opacity, 1.);
assert_eq!(hub.sample().previous_opacity, 0.);
assert!(!hub.active());
}
#[test]
fn an_interrupted_content_delay_never_reveals_the_cancelled_panel() {
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
hub.retarget(Mode::Expanded(Content::Media), [800., 600.]).unwrap();
hub.advance(Duration::from_millis(80), false);
hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.]).unwrap();
for _ in 0..130 {
hub.advance(Duration::from_millis(8), false);
let sample = hub.sample();
assert_eq!(sample.current, Mode::Expanded(Content::Calendar));
assert_eq!(sample.previous, Mode::Idle);
}
assert!(!hub.active());
assert_eq!(hub.sample().opacity, 1.);
}
#[test]
fn spring_overshoots_then_settles_within_its_reserved_surface() {
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.]).unwrap();
let allocation = hub.allocation_size();
let mut peak = 340_f64;
for _ in 0..200 {
hub.advance(Duration::from_millis(8), false);
let sample = hub.sample();
peak = peak.max(sample.geometry.bounds.width);
assert!(sample.geometry.bounds.width <= allocation[0] + 1e-9);
assert!(sample.geometry.bounds.height <= allocation[1] + 1e-9);
}
let overshoot = (peak - 340.) / (340. - 150.);
assert!((0.04..0.05).contains(&overshoot));
assert_eq!(hub.sample().geometry.bounds, Rect::new(0., 0., 340., 230.));
assert!(!hub.active());
}
#[test]
fn overshoot_is_clipped_to_small_outputs_and_reduced_motion_removes_all_fades() {
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
hub.retarget(Mode::Expanded(Content::Calendar), [240., 180.]).unwrap();
for _ in 0..60 {
hub.advance(Duration::from_millis(8), false);
let bounds = hub.sample().geometry.bounds;
assert!(bounds.width <= 240. && bounds.height <= 180.);
}
hub.retarget(Mode::Idle, [240., 180.]).unwrap();
hub.advance(Duration::ZERO, true);
assert_eq!(hub.sample().geometry.bounds, Rect::new(0., 0., 150., 34.));
assert_eq!(hub.sample().previous_opacity, 0.);
assert_eq!(hub.sample().opacity, 1.);
assert!(!hub.active());
}
#[test]
fn interrupted_expansion_keeps_the_presented_shape() {
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
hub.retarget(Mode::Expanded(Content::Media), [800., 600.]).unwrap();
hub.advance(Duration::from_millis(40), false);
let before = hub.sample().geometry;
hub.retarget(Mode::Idle, [800., 600.]).unwrap();
assert_eq!(hub.sample().geometry, before);
for _ in 0..50 {
hub.advance(Duration::from_millis(16), false);
}
assert_eq!(hub.target_size(), [150., 34.]);
assert!(!hub.active());
}
#[test]
fn narrow_outputs_and_reduced_motion_keep_the_hub_bounded() {
let mut hub = Hub::new(Profile::default(), 30.).unwrap();
hub.retarget(Mode::Expanded(Content::Calendar), [240., 180.]).unwrap();
hub.advance(Duration::ZERO, true);
assert_eq!(hub.sample().geometry.bounds, Rect::new(0., 0., 240., 180.));
assert_eq!(hub.sample().opacity, 1.);
assert!(!hub.active());
assert!(hub.retarget(Mode::Idle, [f64::NAN, 180.]).is_err());
assert_eq!(hub.mode(), Mode::Expanded(Content::Calendar));
}
}
+257
View File
@@ -0,0 +1,257 @@
use std::time::Duration;
use ferese_animation::{AnimatedValue, CrossingPolicy, SpringConfig};
use ferese_layout::Rect;
/// Unsnapped logical geometry. Pixel snapping and radius normalization belong
/// to scene derivation, so neither changes the spring's trajectory.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Geometry {
pub bounds: Rect,
pub radii: [f64; 4],
pub margin: [f64; 4],
pub thickness: [f64; 4],
pub gap: f64,
pub fusion: f64,
}
const COUNT: usize = 18;
impl Geometry {
pub fn rounded(bounds: Rect, radius: f64) -> Self {
Self {
bounds,
radii: [radius; 4],
margin: [0.; 4],
thickness: [0.; 4],
gap: 0.,
fusion: 0.,
}
}
fn values(self) -> [f64; COUNT] {
let mut values = [0.; COUNT];
values[..4].copy_from_slice(&[self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height]);
values[4..8].copy_from_slice(&self.radii);
values[8..12].copy_from_slice(&self.margin);
values[12..16].copy_from_slice(&self.thickness);
values[16] = self.gap;
values[17] = self.fusion;
values
}
fn from_values(values: [f64; COUNT]) -> Self {
Self {
bounds: Rect::new(values[0], values[1], values[2], values[3]),
radii: values[4..8].try_into().unwrap(),
margin: values[8..12].try_into().unwrap(),
thickness: values[12..16].try_into().unwrap(),
gap: values[16],
fusion: values[17],
}
}
fn validate(self) -> Result<Self, &'static str> {
let values = self.values();
if values.iter().any(|value| !value.is_finite()) || values[2..].iter().any(|value| *value < 0.) {
Err("panel animation geometry must be finite with nonnegative extents")
} else {
Ok(self)
}
}
}
#[derive(Clone, Debug)]
pub struct Motion {
values: [AnimatedValue; COUNT],
spring: SpringConfig,
}
impl Motion {
pub fn new(geometry: Geometry) -> Result<Self, &'static str> {
Self::with_spring(
geometry,
SpringConfig {
stiffness: 6400.,
damping: 160.,
position_tolerance: 0.05,
velocity_tolerance: 0.1,
crossing: CrossingPolicy::NoCrossing,
..Default::default()
},
)
}
pub(crate) fn with_spring(geometry: Geometry, spring: SpringConfig) -> Result<Self, &'static str> {
Ok(Self {
values: geometry.validate()?.values().map(AnimatedValue::new),
spring,
})
}
/// Largest future dimensions, including the momentum carried through a retarget.
pub(crate) fn maximum_size(&self) -> [f64; 2] {
let omega2 = self.spring.stiffness / self.spring.mass;
let decay = self.spring.damping / (2. * self.spring.mass);
[self.values[2], self.values[3]].map(|value| {
let displacement = value.current - value.target;
let mut maximum = value.current.max(value.target);
if omega2 > decay * decay {
let frequency = (omega2 - decay * decay).sqrt();
let sine = (value.velocity + decay * displacement) / frequency;
let derivative_sine = -(decay * value.velocity + omega2 * displacement) / frequency;
let first = (-value.velocity)
.atan2(derivative_sine)
.rem_euclid(std::f64::consts::PI);
// Extrema alternate; later peaks decay, so the first two suffice.
for phase in [first, first + std::f64::consts::PI] {
let t = phase / frequency;
let position =
value.target + (-decay * t).exp() * (displacement * phase.cos() + sine * phase.sin());
maximum = maximum.max(position);
}
} else {
// Total spring energy bounds displacement for any nonnegative damping.
maximum = maximum.max(
value.target + (displacement * displacement + value.velocity * value.velocity / omega2).sqrt(),
);
}
maximum
})
}
pub fn current(&self) -> Geometry {
Geometry::from_values(self.values.map(|value| value.current))
}
pub fn target(&self) -> Geometry {
Geometry::from_values(self.values.map(|value| value.target))
}
pub fn retarget(&mut self, geometry: Geometry) -> Result<(), &'static str> {
let target = geometry.validate()?.values();
for (value, target) in self.values.iter_mut().zip(target) {
value.retarget_preserving_motion(target);
}
Ok(())
}
pub fn active(&self) -> bool {
self.values.iter().any(AnimatedValue::is_animating)
}
/// Returns whether another frame is needed. Reduced motion also clears
/// residual velocity, including when enabled halfway through an animation.
pub fn advance(&mut self, elapsed: Duration, reduced_motion: bool) -> bool {
let mut active = false;
for value in &mut self.values {
if reduced_motion {
value.snap();
} else {
active |= value.advance(elapsed, self.spring);
}
}
active
}
}
#[cfg(test)]
mod tests {
use super::*;
fn collapsed() -> Geometry {
Geometry::rounded(Rect::new(0., 0., 150., 34.), 17.)
}
fn expanded() -> Geometry {
Geometry::rounded(Rect::new(-95., 0., 340., 230.), 26.)
}
#[test]
fn interruption_keeps_the_current_geometry_and_velocity() {
let mut motion = Motion::new(collapsed()).unwrap();
motion.retarget(expanded()).unwrap();
motion.advance(Duration::from_millis(16), false);
let before = motion.current();
let velocity = motion.values.map(|value| value.velocity);
motion.retarget(collapsed()).unwrap();
assert_eq!(motion.current(), before);
assert_eq!(motion.values.map(|value| value.velocity), velocity);
for _ in 0..30 {
motion.advance(Duration::from_millis(16), false);
}
assert_eq!(motion.current(), collapsed());
assert!(!motion.active());
}
#[test]
fn dimensions_share_one_trajectory_and_stop_requesting_frames() {
let mut motion = Motion::new(collapsed()).unwrap();
motion.retarget(expanded()).unwrap();
for _ in 0..12 {
motion.advance(Duration::from_millis(16), false);
let geometry = motion.current();
let width_progress = (geometry.bounds.width - 150.) / 190.;
let height_progress = (geometry.bounds.height - 34.) / 196.;
let radius_progress = (geometry.radii[0] - 17.) / 9.;
// A dimension may snap at its final subpixel tolerance first.
assert!((width_progress - height_progress).abs() < 0.0005);
assert!((width_progress - radius_progress).abs() < 0.0005);
}
for _ in 0..12 {
motion.advance(Duration::from_millis(16), false);
}
assert_eq!(motion.current(), expanded());
assert!(!motion.advance(Duration::from_secs(1), false));
}
#[test]
fn reduced_motion_snaps_an_inflight_animation() {
let mut motion = Motion::new(collapsed()).unwrap();
motion.retarget(expanded()).unwrap();
motion.advance(Duration::from_millis(16), false);
assert!(!motion.advance(Duration::ZERO, true));
assert_eq!(motion.current(), expanded());
}
#[test]
fn underdamped_reversals_preserve_velocity_and_fit_the_predicted_extrema() {
let mut motion = Motion::with_spring(
collapsed(),
SpringConfig {
stiffness: 300.,
damping: 2. * 0.7 * 300_f64.sqrt(),
crossing: CrossingPolicy::AllowOvershoot,
..Default::default()
},
)
.unwrap();
for turn in 0..60 {
let before = motion.current();
let velocity = motion.values.map(|value| value.velocity);
motion
.retarget(if turn % 2 == 0 { expanded() } else { collapsed() })
.unwrap();
assert_eq!(motion.current(), before);
assert_eq!(motion.values.map(|value| value.velocity), velocity);
let bound = motion.maximum_size();
let mut prediction = motion.clone();
for _ in 0..200 {
prediction.advance(Duration::from_millis(8), false);
let geometry = prediction.current();
assert!(geometry.bounds.width <= bound[0] + 1e-9);
assert!(geometry.bounds.height <= bound[1] + 1e-9);
}
motion.advance(Duration::from_millis(16 + turn * 7), false);
}
}
#[test]
fn invalid_retarget_leaves_motion_untouched() {
let mut motion = Motion::new(collapsed()).unwrap();
let mut invalid = expanded();
invalid.bounds.height = f64::NAN;
assert!(motion.retarget(invalid).is_err());
assert_eq!(motion.target(), collapsed());
assert!(!motion.active());
}
}
+388
View File
@@ -0,0 +1,388 @@
use crate::scene::{Primitive, Scene, smooth_min};
use std::f64::consts::{FRAC_PI_2, PI};
pub type Contour = Vec<[f64; 2]>;
fn arc(points: &mut Contour, center: [f64; 2], radius: f64, start: f64, tolerance: f64) {
let segments = ((FRAC_PI_2 / (8. * tolerance / radius.max(tolerance)).sqrt()).ceil() as usize).clamp(1, 128);
for i in 0..=segments {
let angle = start + FRAC_PI_2 * i as f64 / segments as f64;
points.push([center[0] + radius * angle.cos(), center[1] + radius * angle.sin()]);
}
}
fn clockwise(points: &mut Contour, cutout: bool) {
let area = points
.iter()
.zip(points.iter().cycle().skip(1))
.take(points.len())
.map(|(a, b)| a[0] * b[1] - b[0] * a[1])
.sum::<f64>();
if (area < 0.) != cutout {
points.reverse();
}
}
fn primitive_outline(primitive: Primitive, tolerance: f64) -> Contour {
let b = primitive.bounds;
let (l, t, r, d) = (b.x, b.y, b.x + b.width, b.y + b.height);
let mut points = Vec::new();
if primitive.operation >= 2 {
let radius = b.width.min(b.height);
let (center, start, remaining) = match primitive.operation {
2 => ([l, d], -FRAC_PI_2, [[r, d], [r, t], [l, t]]),
3 => ([r, d], PI, [[r, t], [l, t], [l, d]]),
4 => ([r, t], FRAC_PI_2, [[l, t], [l, d], [r, d]]),
_ => ([l, t], 0., [[l, d], [r, d], [r, t]]),
};
arc(&mut points, center, radius, start, tolerance);
points.extend(remaining);
} else {
let [tl, tr, br, bl] = primitive.radii.0.map(f64::from);
for (center, radius, start) in [
([l + tl, t + tl], tl, PI),
([r - tr, t + tr], tr, PI + FRAC_PI_2),
([r - br, d - br], br, 0.),
([l + bl, d - bl], bl, FRAC_PI_2),
] {
arc(&mut points, center, radius, start, tolerance);
}
}
clockwise(&mut points, primitive.operation == 1);
points
}
/// Paths for vector backends. Additive contours share one nonzero fill, so
/// touching groups and fillets have no separately anti-aliased seam.
pub fn contours(scene: &Scene, tolerance: f64) -> Result<Vec<Contour>, &'static str> {
if !tolerance.is_finite() || !(0.01..=1.).contains(&tolerance) {
return Err("outline tolerance must be between 0.01 and 1 logical pixels");
}
let mut result: Vec<_> = scene
.paint
.iter()
.filter(|p| scene.fusion == 0. || p.blend_zone == 0)
.map(|p| primitive_outline(*p, tolerance))
.collect();
if scene.fusion > 0. {
for zone in 1..=3 {
let shapes: Vec<_> = scene.paint.iter().filter(|p| p.blend_zone == zone).copied().collect();
if shapes.is_empty() {
continue;
}
// Fused rows share their vertical baseline. Each component is
// vertically convex; solve its upper and lower boundary, rather
// than sampling every pixel of a full-output bitmap.
let top = shapes.iter().map(|p| p.bounds.y).fold(f64::INFINITY, f64::min);
let bottom = shapes
.iter()
.map(|p| p.bounds.y + p.bounds.height)
.fold(f64::NEG_INFINITY, f64::max);
let mid = (top + bottom) * 0.5;
let left = shapes.iter().map(|p| p.bounds.x).fold(f64::INFINITY, f64::min) - scene.fusion;
let right = shapes
.iter()
.map(|p| p.bounds.x + p.bounds.width)
.fold(f64::NEG_INFINITY, f64::max)
+ scene.fusion;
let distance = |x, y| {
shapes
.iter()
.map(|p| p.distance([x, y]))
.fold(f64::INFINITY, |a, b| smooth_min(a, b, scene.fusion))
};
let step = tolerance * 2.;
let count = ((right - left) / step).ceil() as usize;
if count > 1_000_000 {
return Err("fused outline exceeds sampling capacity");
}
let mut upper = Vec::new();
let mut lower = Vec::new();
let finish = |upper: &mut Contour, lower: &mut Contour, result: &mut Vec<Contour>| {
if !upper.is_empty() {
upper.extend(lower.drain(..).rev());
clockwise(upper, false);
result.push(std::mem::take(upper));
}
};
for i in 0..=count {
let x = left + (right - left) * i as f64 / count.max(1) as f64;
if distance(x, mid) >= 0. {
finish(&mut upper, &mut lower, &mut result);
continue;
}
let root = |outside: f64| {
let mut a = outside;
let mut b = mid;
for _ in 0..16 {
let y = (a + b) * 0.5;
if distance(x, y) < 0. {
b = y;
} else {
a = y;
}
}
(a + b) * 0.5
};
upper.push([x, root(top - scene.fusion)]);
lower.push([x, root(bottom + scene.fusion)]);
}
finish(&mut upper, &mut lower, &mut result);
}
}
Ok(result)
}
/// Open border paths for screen-attached notches. Split shared straight edges
/// before cancelling them, so the body and its fillets have one continuous rim.
pub fn notch_border(scene: &Scene, tolerance: f64, edge: crate::Edge) -> Result<Vec<Contour>, &'static str> {
let contours = contours(scene, tolerance)?;
let vertices: Vec<_> = contours.iter().flatten().copied().collect();
let screen_edge = vertices.iter().map(|p| p[1]).fold(
if edge == crate::Edge::Top {
f64::INFINITY
} else {
f64::NEG_INFINITY
},
if edge == crate::Edge::Top { f64::min } else { f64::max },
);
let close = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
let mut segments: Vec<([f64; 2], [f64; 2])> = Vec::new();
for contour in &contours {
for (&a, &b) in contour.iter().zip(contour.iter().cycle().skip(1)).take(contour.len()) {
if close(a, b) || ((a[1] - screen_edge).abs() < 1e-6 && (b[1] - screen_edge).abs() < 1e-6) {
continue;
}
let delta = [b[0] - a[0], b[1] - a[1]];
let length_squared = delta[0] * delta[0] + delta[1] * delta[1];
let mut cuts = vec![(0., a), (1., b)];
for &point in &vertices {
let offset = [point[0] - a[0], point[1] - a[1]];
let t = (offset[0] * delta[0] + offset[1] * delta[1]) / length_squared;
if t > 0. && t < 1. && (offset[0] * delta[1] - offset[1] * delta[0]).abs() < 1e-6 {
cuts.push((t, point));
}
}
cuts.sort_by(|a, b| a.0.total_cmp(&b.0));
cuts.dedup_by(|a, b| close(a.1, b.1));
for pair in cuts.windows(2) {
let segment = (pair[0].1, pair[1].1);
if let Some(index) = segments
.iter()
.position(|&(a, b)| close(a, segment.1) && close(b, segment.0))
{
segments.swap_remove(index);
} else {
segments.push(segment);
}
}
}
}
let mut paths = Vec::new();
while let Some((a, b)) = segments.pop() {
let mut path = vec![a, b];
while let Some(index) = segments.iter().position(|&(a, _)| close(a, *path.last().unwrap())) {
path.push(segments.swap_remove(index).1);
}
while let Some(index) = segments.iter().position(|&(_, b)| close(b, path[0])) {
path.insert(0, segments.swap_remove(index).0);
}
paths.push(path);
}
Ok(paths)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scene::{GroupBounds, Parameters, derive};
use crate::shape::{PixelGrid, Style, ZonePosition};
use ferese_layout::Rect;
fn winding(contours: &[Contour], point: [f64; 2]) -> i32 {
let mut winding = 0;
for points in contours {
for (a, b) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()) {
let cross = (b[0] - a[0]) * (point[1] - a[1]) - (point[0] - a[0]) * (b[1] - a[1]);
if a[1] <= point[1] && b[1] > point[1] && cross > 0. {
winding += 1;
}
if a[1] > point[1] && b[1] <= point[1] && cross < 0. {
winding -= 1;
}
}
}
winding
}
#[test]
fn notch_rim_connects_both_fillets_without_shared_or_screen_edges() {
for scale in [1., 1.25, 1.5, 2.] {
for edge in [crate::Edge::Top, crate::Edge::Bottom] {
let scene = derive(
Parameters {
style: Style::Notch,
edge,
height: 44.,
fillet: 12.,
..Default::default()
},
Rect::new(0., 0., 340., 44.),
&[GroupBounds {
zone: ZonePosition::Center,
bounds: Rect::new(70.3, 0., 160.4, 44.),
}],
PixelGrid::new(scale).unwrap(),
)
.unwrap();
let paths = notch_border(&scene, 0.1 / scale, edge).unwrap();
assert_eq!(paths.len(), 1, "{edge:?} at {scale}");
let path = &paths[0];
let screen_y = if edge == crate::Edge::Top { 0. } else { 44. };
assert!((path.first().unwrap()[1] - screen_y).abs() < 1e-6);
assert!((path.last().unwrap()[1] - screen_y).abs() < 1e-6);
let body = scene.paint.iter().find(|p| p.operation == 0).unwrap().bounds;
assert!(path.iter().any(|p| p[0] < body.x));
assert!(path.iter().any(|p| p[0] > body.x + body.width));
for segment in path.windows(2) {
let [a, b] = [segment[0], segment[1]];
assert!((a[1] - screen_y).abs() > 1e-6 || (b[1] - screen_y).abs() > 1e-6);
let mid = [(a[0] + b[0]) / 2., (a[1] + b[1]) / 2.];
assert!(scene.distance(mid).abs() < 0.12 / scale, "off outline: {mid:?}");
// A shared body/fillet seam has fill on both sides, unlike a visible rim.
let delta = [b[0] - a[0], b[1] - a[1]];
let length = delta[0].hypot(delta[1]);
let normal = [-delta[1] / length * 0.25, delta[0] / length * 0.25];
assert!(
scene.distance([mid[0] + normal[0], mid[1] + normal[1]]) > 0.
|| scene.distance([mid[0] - normal[0], mid[1] - normal[1]]) > 0.
);
}
}
}
}
#[test]
fn attached_morph_rim_follows_animated_body_at_fractional_scales() {
for scale in [1., 1.25, 1.5, 2.] {
for edge in [crate::Edge::Top, crate::Edge::Bottom] {
for (width, height, radius) in [(150., 34., 17.), (218.3, 41.7, 20.85), (340., 230., 26.)] {
let scene = derive(
Parameters {
style: Style::Morph,
attached: true,
edge,
fillet: 12.,
radius: Some(crate::shape::AnchoredRadii([radius; 4])),
..Default::default()
},
Rect::new(30.3, 0., width, height),
&[],
PixelGrid::new(scale).unwrap(),
)
.unwrap();
let body = scene.paint[0];
assert_eq!(scene.paint.len(), 3);
assert_eq!(scene.input.len(), 1, "decorative shoulders must not take pointer input");
let screen_y = if edge == crate::Edge::Top {
body.bounds.y
} else {
body.bounds.y + body.bounds.height
};
let paths = notch_border(&scene, 0.1 / scale, edge).unwrap();
assert_eq!(
paths.len(),
1,
"one continuous rim: {scale}, {edge:?}, {width}, {height}"
);
let path = &paths[0];
assert!((path.first().unwrap()[1] - screen_y).abs() < 1e-6);
assert!((path.last().unwrap()[1] - screen_y).abs() < 1e-6);
assert!(path.iter().any(|p| p[0] < body.bounds.x));
assert!(path.iter().any(|p| p[0] > body.bounds.x + body.bounds.width));
for segment in path.windows(2) {
let [a, b] = [segment[0], segment[1]];
assert!(
(a[1] - screen_y).abs() > 1e-6 || (b[1] - screen_y).abs() > 1e-6,
"no stroke on the screen edge"
);
let mid = [(a[0] + b[0]) / 2., (a[1] + b[1]) / 2.];
assert!(scene.distance(mid).abs() < 0.12 / scale);
}
let contours = contours(&scene, 0.1 / scale).unwrap();
for x in 0..((width + 60.) as usize) {
for y in 0..(height as usize) {
let p = [x as f64 + 0.37, y as f64 + 0.37];
if scene.distance(p).abs() > 0.15 / scale {
assert_eq!(
winding(&contours, p) != 0,
scene.distance(p) < 0.,
"native and fallback must agree"
);
}
}
}
}
}
}
}
#[test]
fn vector_union_and_cutout_match_implicit_shapes() {
let groups = [
GroupBounds {
zone: ZonePosition::Start,
bounds: Rect::new(4., 0., 20., 16.),
},
GroupBounds {
zone: ZonePosition::Start,
bounds: Rect::new(28., 0., 20., 16.),
},
GroupBounds {
zone: ZonePosition::Center,
bounds: Rect::new(52., 0., 20., 16.),
},
];
for style in [
Style::Floating,
Style::Capsules,
Style::Notch,
Style::Flat,
Style::Frame,
Style::CornerWrap,
Style::Fused,
] {
let scene = derive(
Parameters {
style,
height: 16.,
gap: 4.,
frame_insets: [16., 4., 4., 4.],
frame_radius: 8.,
wrap_size: [40., 42.],
wrap_width: 8.,
fillet: 6.,
..Default::default()
},
Rect::new(0., 0., 96., 64.),
&groups,
PixelGrid::new(1.25).unwrap(),
)
.unwrap();
let contours = contours(&scene, 0.1).unwrap();
for y in 0..64 {
for x in 0..96 {
let point = [x as f64 + 0.5, y as f64 + 0.5];
let distance = scene.distance(point);
if distance.abs() > 0.3 {
assert_eq!(
winding(&contours, point) != 0,
distance < 0.,
"{style:?} at {point:?}, {distance}"
);
}
}
}
}
}
}
+868
View File
@@ -0,0 +1,868 @@
pub(crate) mod geometry;
pub mod layout;
pub use geometry::{GeometryIntent, PanelGeometry};
mod radius;
use crate::PanelStyle;
pub use radius::CornerRadii;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
/// The effects protocol accepts 32 regions; reserve one for the overflow group.
pub const MAX_GROUPS: usize = 31;
fn yes() -> bool {
true
}
fn priority() -> u8 {
50
}
fn spacing() -> f32 {
1.0
}
fn padding() -> [u16; 2] {
[2, 3]
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(transparent)]
pub struct PanelId(pub String);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Edge {
#[default]
Top,
Bottom,
}
impl Edge {
pub fn key(self) -> &'static str {
match self {
Self::Top => "top",
Self::Bottom => "bottom",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(transparent)]
pub struct GroupId(pub String);
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
#[serde(transparent)]
pub struct ItemId(pub String);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Representation {
Wide,
Compact,
Icon,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum OverflowPolicy {
#[default]
Auto,
Never,
Always,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum WorkspaceStyle {
Numbers,
#[default]
Dots,
Tabs,
WindowStacks,
AppIcons,
}
impl WorkspaceStyle {
pub const ALL: [Self; 5] = [
Self::Numbers,
Self::Dots,
Self::Tabs,
Self::WindowStacks,
Self::AppIcons,
];
pub fn key(self) -> &'static str {
match self {
Self::Numbers => "numbers",
Self::Dots => "dots",
Self::Tabs => "tabs",
Self::WindowStacks => "window-stacks",
Self::AppIcons => "app-icons",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Numbers => "Numbers",
Self::Dots => "Dots",
Self::Tabs => "Tabs",
Self::WindowStacks => "Window stacks",
Self::AppIcons => "App icons",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum ItemKind {
Overview,
Workspaces {
#[serde(default)]
style: WorkspaceStyle,
},
FocusedWindow,
Media,
QuickSettings,
Network,
Audio,
Recording,
Notifications,
Battery {
#[serde(default = "yes")]
percentage: bool,
},
Clock,
DisplayMode,
Overflow,
}
impl ItemKind {
pub fn label(self) -> &'static str {
match self {
Self::Overview => "Overview",
Self::Workspaces { .. } => "Workspaces",
Self::FocusedWindow => "Focused window",
Self::Media => "Media",
Self::QuickSettings => "Quick Settings",
Self::Network => "Network",
Self::Audio => "Audio",
Self::Recording => "Recording",
Self::Notifications => "Notifications",
Self::Battery { .. } => "Battery",
Self::Clock => "Clock",
Self::DisplayMode => "Display mode",
Self::Overflow => "Overflow",
}
}
pub fn representations(self) -> &'static [Representation] {
use Representation::*;
match self {
Self::Media | Self::Clock => &[Wide, Compact, Icon],
Self::Battery { percentage: true } => &[Wide, Icon],
Self::Workspaces { .. } | Self::FocusedWindow => &[Wide],
_ => &[Icon],
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Item {
pub id: ItemId,
#[serde(flatten)]
pub kind: ItemKind,
// Preserve the existing gap between status controls and the clock/display.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gap_before: Option<f32>,
#[serde(default)]
pub overflow: OverflowPolicy,
#[serde(default = "priority")]
pub priority: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub representation: Option<Representation>,
#[serde(default = "yes")]
pub visible: bool,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, serde_json::Value>,
}
impl<'de> Deserialize<'de> for Item {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = serde_json::Value::deserialize(deserializer)?;
let fields = value
.as_object()
.ok_or_else(|| serde::de::Error::custom("expected a panel item"))?;
const OPTIONS: &[&str] = &[
"id",
"kind",
"gap_before",
"overflow",
"priority",
"representation",
"visible",
"metadata",
"style",
"percentage",
];
for key in fields.keys() {
if !OPTIONS.contains(&key.as_str()) {
return Err(serde::de::Error::unknown_field(key, OPTIONS));
}
}
if fields.contains_key("style") && fields.get("kind").and_then(serde_json::Value::as_str) != Some("workspaces")
{
return Err(serde::de::Error::custom("style is only supported by Workspaces items"));
}
if fields.contains_key("percentage")
&& fields.get("kind").and_then(serde_json::Value::as_str) != Some("battery")
{
return Err(serde::de::Error::custom(
"percentage is only supported by Battery items",
));
}
#[derive(Deserialize)]
struct Fields {
id: ItemId,
#[serde(flatten)]
kind: ItemKind,
#[serde(default)]
gap_before: Option<f32>,
#[serde(default)]
overflow: OverflowPolicy,
#[serde(default = "priority")]
priority: u8,
#[serde(default)]
representation: Option<Representation>,
#[serde(default = "yes")]
visible: bool,
#[serde(default)]
metadata: BTreeMap<String, serde_json::Value>,
}
let item: Fields = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
Ok(Self {
id: item.id,
kind: item.kind,
gap_before: item.gap_before,
overflow: item.overflow,
priority: item.priority,
representation: item.representation,
visible: item.visible,
metadata: item.metadata,
})
}
}
impl Item {
/// Start at the configured preference and retain supported smaller forms.
pub fn representations(&self) -> Vec<Representation> {
let supported = self.kind.representations();
let preferred = self.representation.unwrap_or(supported[0]);
let alternatives: Vec<_> = supported
.iter()
.copied()
.filter(|representation| *representation as u8 >= preferred as u8)
.collect();
if alternatives.is_empty() {
vec![supported[0]]
} else {
alternatives
}
}
pub fn new(id: &str, kind: ItemKind) -> Self {
Self {
id: ItemId(id.into()),
kind,
gap_before: None,
representation: None,
visible: true,
metadata: BTreeMap::new(),
overflow: if matches!(
kind,
ItemKind::Overview | ItemKind::Workspaces { .. } | ItemKind::FocusedWindow
) {
OverflowPolicy::Never
} else {
OverflowPolicy::Auto
},
priority: if matches!(kind, ItemKind::Overview | ItemKind::Workspaces { .. }) {
100
} else {
50
},
}
}
pub fn available(&self, availability: Availability) -> bool {
if !self.visible {
return false;
}
match self.kind {
ItemKind::Media => availability.media,
ItemKind::Network => availability.network,
ItemKind::Audio => availability.audio,
ItemKind::Notifications => availability.notifications,
ItemKind::Battery { .. } => availability.battery,
ItemKind::DisplayMode => availability.external_display,
_ => true,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Availability {
pub media: bool,
pub network: bool,
pub audio: bool,
pub notifications: bool,
pub battery: bool,
pub external_display: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum PanelSurface {
None,
#[default]
Solid,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GroupSurface {
#[default]
None,
Inset,
Island,
}
impl GroupSurface {
pub fn key(self) -> &'static str {
match self {
Self::None => "none",
Self::Inset => "inset",
Self::Island => "island",
}
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Group {
pub id: GroupId,
#[serde(default)]
pub items: Vec<Item>,
#[serde(default = "spacing")]
pub spacing: f32,
#[serde(default = "padding")]
pub padding: [u16; 2],
#[serde(default = "crate::default_island_padding")]
pub island_padding: f32,
}
impl Group {
fn new(id: &str, items: Vec<Item>, padding: [u16; 2], island_padding: f32) -> Self {
Self {
id: GroupId(id.into()),
items,
spacing: 1.0,
padding,
island_padding,
}
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Zone {
pub groups: Vec<Group>,
pub spacing: f32,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Panel {
pub id: PanelId,
#[serde(default)]
pub edge: Edge,
#[serde(default)]
pub geometry: GeometryIntent,
#[serde(default)]
pub style: PanelStyle,
#[serde(default, skip_serializing_if = "default_morph")]
pub morph: crate::morph::Profile,
#[serde(default)]
pub border: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_opacity: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub corner_radius: Option<CornerRadii>,
#[serde(default)]
pub start: Zone,
#[serde(default)]
pub center: Zone,
#[serde(default)]
pub end: Zone,
}
impl Default for Zone {
fn default() -> Self {
Self {
groups: Vec::new(),
spacing: 8.0,
}
}
}
fn default_morph(profile: &crate::morph::Profile) -> bool {
*profile == crate::morph::Profile::default()
}
pub fn validate(panels: &[Panel]) -> Result<(), String> {
if panels.len() != 1 {
return Err("configure exactly one panel; multiple panels are not supported yet".into());
}
let valid_id = |value: &str| {
!value.is_empty()
&& value.len() <= 128
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"-_.:@".contains(&byte))
&& !value.starts_with('_')
};
let mut panel_ids = HashSet::new();
for panel in panels {
if !PanelStyle::AVAILABLE.contains(&panel.style) {
return Err(format!("panel style {} is not available yet", panel.style.key()));
}
panel.geometry.validate()?;
panel.morph.validate()?;
if panel.group_count() > MAX_GROUPS {
return Err(format!(
"a panel supports at most {MAX_GROUPS} groups, reserving one region for overflow"
));
}
if let Some(opacity) = panel.background_opacity
&& (!opacity.is_finite() || !(0.0..=1.0).contains(&opacity))
{
return Err("panel background opacity must be between 0 and 1".into());
}
if let Some(radius) = panel.corner_radius {
radius.validate()?;
}
if !valid_id(&panel.id.0) || !panel_ids.insert(&panel.id.0) {
return Err(format!("invalid or duplicate panel ID {:?}", panel.id.0));
}
let mut group_ids = HashSet::new();
let mut item_ids = HashSet::new();
for zone in [&panel.start, &panel.center, &panel.end] {
if !zone.spacing.is_finite() || !(0.0..=64.0).contains(&zone.spacing) {
return Err("panel zone spacing must be between 0 and 64".into());
}
for group in &zone.groups {
if !valid_id(&group.id.0) || !group_ids.insert(&group.id.0) {
return Err(format!("invalid or duplicate group ID {:?}", group.id.0));
}
if !group.spacing.is_finite()
|| !(0.0..=64.0).contains(&group.spacing)
|| !group.island_padding.is_finite()
|| !(0.0..=32.0).contains(&group.island_padding)
|| group.padding.iter().any(|value| *value > 32)
{
return Err("invalid panel group spacing or padding".into());
}
for item in &group.items {
if !valid_id(&item.id.0) || !item_ids.insert(&item.id.0) {
return Err(format!("invalid or duplicate item ID {:?}", item.id.0));
}
if item.priority > 100
|| item
.gap_before
.is_some_and(|gap| !gap.is_finite() || !(0.0..=64.0).contains(&gap))
{
return Err("panel item priority must be 0..100 and gaps 0..64".into());
}
if item.kind == ItemKind::Overflow {
return Err("overflow is supplied by the panel; it cannot be configured as an item".into());
}
}
}
}
}
Ok(())
}
impl Default for Panel {
fn default() -> Self {
Self::from_preset(PanelStyle::Flat)
}
}
impl Panel {
pub fn preset(&self) -> Option<PanelStyle> {
Some(self.style)
}
pub fn apply_preset(&mut self, style: PanelStyle) {
self.style = style;
}
pub fn surface(&self) -> PanelSurface {
if matches!(self.style, PanelStyle::Flat | PanelStyle::Frame) {
PanelSurface::Solid
} else {
PanelSurface::None
}
}
pub fn group_surface(&self) -> GroupSurface {
if self.surface() == PanelSurface::Solid {
GroupSurface::None
} else {
GroupSurface::Island
}
}
pub fn zone_spacing(&self, zone: &Zone) -> f32 {
if self.style.connected() { 0. } else { zone.spacing }
}
pub fn shape_parameters(&self) -> crate::scene::Parameters {
crate::scene::Parameters {
style: self.style,
attached: self.style == PanelStyle::Morph && self.morph.attached,
fillet: if self.style == PanelStyle::Morph {
12.
} else {
crate::scene::Parameters::default().fillet
},
edge: self.edge,
height: self.resolved_geometry().height.into(),
radius: self.corner_radius.map(|r| crate::shape::AnchoredRadii(r.0)),
..Default::default()
}
}
pub fn resolved_geometry(&self) -> PanelGeometry {
let mut geometry = self.geometry.resolve(self.style);
if self.style == PanelStyle::Morph && self.morph.attached {
geometry.edge_margin = 0;
}
geometry
}
pub fn exclusive_zone(&self, hidden: bool) -> i32 {
if hidden {
0
} else {
let geometry = self.resolved_geometry();
(geometry.height.round() as i32).saturating_add(geometry.window_clearance)
}
}
pub fn group_count(&self) -> usize {
[&self.start, &self.center, &self.end]
.into_iter()
.map(|zone| zone.groups.len())
.sum()
}
/// Appearance changes do not replace item ownership or measured allocation.
pub fn same_composition(&self, other: &Self) -> bool {
let same_zone = |left: &Zone, right: &Zone| {
left.spacing == right.spacing
&& left.groups.len() == right.groups.len()
&& left.groups.iter().zip(&right.groups).all(|(left, right)| {
left.id == right.id
&& left.items == right.items
&& left.spacing == right.spacing
&& left.padding == right.padding
&& left.island_padding == right.island_padding
&& (self.group_surface() == GroupSurface::Island)
== (other.group_surface() == GroupSurface::Island)
})
};
self.id == other.id
&& self.edge == other.edge
&& self.style == other.style
&& self.morph == other.morph
&& same_zone(&self.start, &other.start)
&& same_zone(&self.center, &other.center)
&& same_zone(&self.end, &other.end)
}
pub fn resolved_radius(&self, shell_radius: f32) -> CornerRadii {
self.corner_radius.unwrap_or_else(|| CornerRadii::uniform(shell_radius))
}
/// The overflow trigger is generated, never persisted as a configured item.
pub fn overflow_group(&self) -> Group {
Group {
id: GroupId("_overflow".into()),
items: vec![Item {
id: ItemId("_overflow".into()),
kind: ItemKind::Overflow,
gap_before: None,
representation: None,
visible: true,
metadata: BTreeMap::new(),
overflow: OverflowPolicy::Never,
priority: 100,
}],
spacing: 0.0,
padding: [2, 3],
island_padding: self
.end
.groups
.first()
.map_or(crate::default_island_padding(), |group| group.island_padding),
}
}
pub fn item(&self, id: &ItemId) -> Option<&Item> {
[&self.start, &self.center, &self.end]
.into_iter()
.flat_map(|zone| &zone.groups)
.flat_map(|group| &group.items)
.find(|item| &item.id == id)
}
/// Build a stable initial composition; presets are an editor convenience.
pub fn from_preset(preset: PanelStyle) -> Self {
use ItemKind::*;
let item = Item::new;
let padding = crate::default_island_padding();
let overview = item("overview", Overview);
let workspaces = item(
"workspaces",
Workspaces {
style: WorkspaceStyle::default(),
},
);
let title = item("focused-window", FocusedWindow);
let controls = vec![
item("media", Media),
item("quick-settings", QuickSettings),
item("network", Network),
item("audio", Audio),
item("recording", Recording),
item("notifications", Notifications),
item("battery", Battery { percentage: true }),
];
let clock = item("clock", Clock);
let display = item("display-mode", DisplayMode);
let (start, center, end) = match preset {
PanelStyle::Flat => (
vec![
Group::new("overview", vec![overview], [0, 0], padding),
Group::new("workspaces", vec![workspaces], [2, 3], padding),
],
Group::new("title", vec![title], [0, 0], padding),
vec![
Group::new("status", controls, [2, 3], padding),
Group::new("time", vec![clock], [2, 3], padding),
Group::new("display", vec![display], [0, 0], padding),
],
),
_ => {
let mut controls = controls;
controls.extend([clock, display]);
(
vec![Group::new("navigation", vec![overview, workspaces], [2, 3], padding)],
Group::new("title", vec![title], [0, 0], padding),
vec![Group::new("status", controls, [2, 3], padding)],
)
}
};
Self {
id: PanelId("main".into()),
edge: Edge::Top,
geometry: GeometryIntent::default(),
style: preset,
morph: Default::default(),
border: false,
background_opacity: Option::None,
corner_radius: Option::None,
start: Zone {
groups: start,
spacing: if preset == PanelStyle::Floating { 8.0 } else { 3.0 },
},
center: Zone {
groups: vec![center],
spacing: 8.0,
},
end: Zone {
groups: end,
spacing: 8.0,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fullscreen_releases_the_entire_reservation_without_discarding_pins() {
let mut panel = Panel::from_preset(PanelStyle::Floating);
panel.geometry.height = Some(36.);
panel.geometry.window_clearance = Some(4);
assert_eq!(panel.exclusive_zone(false), 40);
assert_eq!(panel.exclusive_zone(true), 0);
assert_eq!(panel.geometry.window_clearance, Some(4));
}
fn items(panel: &Panel) -> Vec<&Item> {
[&panel.start, &panel.center, &panel.end]
.into_iter()
.flat_map(|zone| &zone.groups)
.flat_map(|group| &group.items)
.collect()
}
#[test]
fn group_capacity_reserves_overflow_even_for_empty_groups() {
for background in [PanelStyle::Flat, PanelStyle::Floating] {
let mut panel = Panel::from_preset(background);
panel.start.groups.clear();
panel.center.groups.clear();
panel.end.groups = (0..MAX_GROUPS)
.map(|i| Group::new(&format!("group-{i}"), Vec::new(), [2, 3], 4.))
.collect();
assert_eq!(panel.group_count(), MAX_GROUPS);
validate(std::slice::from_ref(&panel)).unwrap();
panel
.start
.groups
.push(Group::new("one-too-many", Vec::new(), [2, 3], 4.));
assert!(validate(&[panel]).unwrap_err().contains("31 groups"));
}
}
#[test]
fn defaults_preserve_order_and_ids_across_background_changes() {
let continuous = Panel::default();
let islands = Panel::from_preset(PanelStyle::Floating);
let expected = [
"overview",
"workspaces",
"focused-window",
"media",
"quick-settings",
"network",
"audio",
"recording",
"notifications",
"battery",
"clock",
"display-mode",
];
for panel in [&continuous, &islands] {
let ids: Vec<_> = items(panel).iter().map(|item| item.id.0.as_str()).collect();
assert_eq!(ids, expected);
assert_eq!(Panel::from_preset(panel.preset().unwrap()), *panel);
}
assert_eq!(continuous.start.groups.len(), 2);
assert_eq!(continuous.end.groups.len(), 3);
assert_eq!(islands.start.groups.len(), 1);
assert_eq!(islands.end.groups.len(), 1);
assert!(!islands.border && !continuous.border);
}
#[test]
fn availability_filters_without_removing_or_reidentifying_instances() {
let panel = Panel::default();
let all = items(&panel);
let visible = |availability| {
all.iter()
.filter(|item| item.available(availability))
.map(|item| item.id.0.as_str())
.collect::<Vec<_>>()
};
assert_eq!(
visible(Availability::default()),
[
"overview",
"workspaces",
"focused-window",
"quick-settings",
"recording",
"clock"
]
);
assert_eq!(
visible(Availability {
media: true,
network: true,
audio: true,
notifications: true,
battery: true,
external_display: true
}),
all.iter().map(|item| item.id.0.as_str()).collect::<Vec<_>>()
);
assert_eq!(items(&panel).len(), 12);
}
#[test]
fn settings_map_to_instances_and_group_presentation() {
let mut panel = Panel::from_preset(PanelStyle::Floating);
panel.center.groups[0].items[0].visible = false;
let battery = panel.end.groups[0]
.items
.iter_mut()
.find(|item| item.id.0 == "battery")
.unwrap();
battery.kind = ItemKind::Battery { percentage: false };
for group in [&mut panel.start, &mut panel.center, &mut panel.end]
.into_iter()
.flat_map(|zone| &mut zone.groups)
{
group.island_padding = 13.;
}
assert_eq!(panel.center.groups[0].items[0].kind, ItemKind::FocusedWindow);
assert!(!panel.center.groups[0].items[0].visible);
let all = items(&panel);
assert_eq!(
all.iter().find(|item| item.id.0 == "battery").unwrap().kind,
ItemKind::Battery { percentage: false }
);
for zone in [&panel.start, &panel.center, &panel.end] {
for group in &zone.groups {
assert_eq!(group.island_padding, 13.0);
}
}
assert_eq!(all.iter().find(|item| item.id.0 == "clock").unwrap().gap_before, None);
}
#[test]
fn two_clock_instances_have_distinct_identity() {
let first = Item::new("main-clock", ItemKind::Clock);
let second = Item::new("other-clock", ItemKind::Clock);
assert_eq!(first.kind, second.kind);
assert_ne!(first.id, second.id);
}
#[test]
fn removed_item_fields_are_rejected() {
for value in [
serde_json::json!({"id":"title", "kind":"focused-window", "enabled":false}),
serde_json::json!({"id":"spaces", "kind":"workspaces", "workspace_style":"numbers"}),
] {
assert!(serde_json::from_value::<Item>(value).is_err());
}
}
#[test]
fn surface_changes_only_replace_composition_when_item_geometry_changes() {
let panel = Panel::default();
let mut changed = panel.clone();
changed.style = PanelStyle::Flat;
assert!(panel.same_composition(&changed));
changed.style = PanelStyle::Floating;
assert!(!panel.same_composition(&changed));
changed.style = PanelStyle::Flat;
assert!(panel.same_composition(&changed));
changed.end.groups[0].padding[1] += 1;
assert!(!panel.same_composition(&changed));
}
}
+138
View File
@@ -0,0 +1,138 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct GeometryIntent {
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub edge_margin: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub side_margins: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub window_clearance: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inner_padding: Option<f32>,
}
impl GeometryIntent {
pub fn resolve(self, style: crate::PanelStyle) -> PanelGeometry {
use crate::PanelStyle::*;
let flush = matches!(style, Notch | Flat | Frame | CornerWrap);
PanelGeometry {
height: self.height.unwrap_or(match style {
Frame => 40.,
Morph => 34.,
_ => 36.,
}),
edge_margin: if flush { 0 } else { self.edge_margin.unwrap_or(8) },
side_margins: if flush { 0 } else { self.side_margins.unwrap_or(8) },
window_clearance: self.window_clearance.unwrap_or(0),
inner_padding: self.inner_padding.unwrap_or(12.),
}
}
pub fn validate(self) -> Result<(), String> {
// Validate pins even when a flush style temporarily overrides margins.
let mut geometry = self.resolve(crate::PanelStyle::Floating);
geometry.edge_margin = self.edge_margin.unwrap_or(0);
geometry.side_margins = self.side_margins.unwrap_or(0);
geometry.validate()
}
}
/// Logical dimensions owned by a panel, independent of its color theme or edge.
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct PanelGeometry {
pub height: f32,
pub edge_margin: i32,
pub side_margins: i32,
pub window_clearance: i32,
pub inner_padding: f32,
}
impl Default for PanelGeometry {
fn default() -> Self {
Self {
height: 36.,
edge_margin: 0,
side_margins: 0,
window_clearance: 0,
inner_padding: 12.,
}
}
}
impl PanelGeometry {
pub fn validate(self) -> Result<(), String> {
if !self.height.is_finite()
|| !(24. ..=128.).contains(&self.height)
|| !self.inner_padding.is_finite()
|| !(0. ..=64.).contains(&self.inner_padding)
|| [self.edge_margin, self.side_margins, self.window_clearance]
.into_iter()
.any(|value| !(0..=4096).contains(&value))
{
return Err("panel height must be 24..128, inner padding 0..64, and margins/clearance 0..4096".into());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn style_defaults_and_user_pins_have_separate_ownership() {
use crate::PanelStyle::*;
let intent = GeometryIntent::default();
assert_eq!(intent.resolve(Floating).edge_margin, 8);
assert_eq!(intent.resolve(Flat).edge_margin, 0);
assert_eq!(intent.resolve(Morph).height, 34.);
let pins = GeometryIntent {
height: Some(32.),
edge_margin: Some(6),
side_margins: Some(18),
..intent
};
assert_eq!(pins.resolve(Flat).height, 32.);
assert_eq!(pins.resolve(Flat).side_margins, 0);
assert_eq!(pins.resolve(Floating).side_margins, 18);
assert_eq!(pins.resolve(Notch).edge_margin, 0);
assert!(serde_json::to_value(intent).unwrap().as_object().unwrap().is_empty());
assert!(
GeometryIntent {
edge_margin: Some(-1),
..intent
}
.validate()
.is_err()
);
}
#[test]
fn invalid_geometry_rejects_nonfinite_dimensions_and_negative_clearances() {
for geometry in [
PanelGeometry {
height: f32::NAN,
..Default::default()
},
PanelGeometry {
edge_margin: -1,
..Default::default()
},
PanelGeometry {
height: 12.,
..Default::default()
},
PanelGeometry {
inner_padding: f32::INFINITY,
..Default::default()
},
] {
assert!(geometry.validate().is_err());
}
}
}
+734
View File
@@ -0,0 +1,734 @@
use super::{ItemId, OverflowPolicy, Panel, Representation, Zone};
use std::collections::BTreeMap;
#[derive(Clone, Debug)]
pub struct Measurement {
pub id: ItemId,
pub alternatives: Vec<(Representation, f32)>,
pub minimum: Option<f32>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Placement {
Visible { representation: Representation, width: f32 },
Overflow,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Resolution {
pub items: BTreeMap<ItemId, Placement>,
pub overflow: Vec<ItemId>,
pub zone_widths: [f32; 3],
/// Separation used when allocating these zones.
pub zone_gap: f32,
pub forced_overflow: bool,
pub overflow_trigger_width: f32,
/// Width needed by the smallest visible representations before automatic overflow.
pub minimum_width: f32,
}
fn zone_width(panel: &Panel, zone: &Zone, items: &BTreeMap<ItemId, Placement>) -> f32 {
let mut width = 0.0;
let mut group_count: usize = 0;
for group in &zone.groups {
let mut content_width = 0.0;
let mut occupied = false;
for item in &group.items {
if let Some(Placement::Visible { width, .. }) = items.get(&item.id)
&& *width > 0.0
{
if occupied {
content_width += item.gap_before.unwrap_or(group.spacing);
}
content_width += *width;
occupied = true;
}
}
if occupied {
let padding = 2.0 * f32::from(group.padding[1]);
let island = if panel.group_surface() == super::GroupSurface::Island {
2.0 * group.island_padding
} else {
0.0
};
width += content_width + padding + island;
group_count += 1;
}
}
width + panel.zone_spacing(zone) * group_count.saturating_sub(1) as f32
}
impl Resolution {
fn update_widths(&mut self, panel: &Panel, overflow_width: f32) {
self.zone_widths = [
zone_width(panel, &panel.start, &self.items),
zone_width(panel, &panel.center, &self.items),
zone_width(panel, &panel.end, &self.items),
];
if !self.overflow.is_empty() {
self.zone_widths[2] += overflow_width
+ if self.zone_widths[2] > 0.0 {
panel.zone_spacing(&panel.end)
} else {
0.0
};
}
}
fn required_width(&self) -> f32 {
self.required_width_with_gap(self.zone_gap)
}
fn required_width_with_gap(&self, gap: f32) -> f32 {
let [start, center, end] = self.zone_widths;
if center > 0.0 {
let side = start.max(end);
center + if side > 0.0 { 2.0 * (side + gap) } else { 0.0 }
} else {
start + end + if start > 0.0 && end > 0.0 { gap } else { 0.0 }
}
}
pub fn fits(&self, available: f32) -> bool {
self.required_width() <= available
}
/// Clamp a requested inset independently for each output. Content may still
/// overflow on an output too small to fit it even without an inset.
pub fn side_margin(&self, requested: i32, output_width: i32, padding: f32) -> i32 {
let maximum = ((output_width as f32 - 2. * padding - self.minimum_width) / 2.)
.floor()
.max(0.);
requested.max(0).min(maximum as i32)
}
}
pub fn resolve(panel: &Panel, measurements: &[Measurement], available: f32, overflow_width: f32) -> Resolution {
resolve_with_gap(panel, measurements, available, overflow_width, 8.)
}
/// Reserve separation between zones while keeping the center on the panel midpoint.
pub fn resolve_with_gap(
panel: &Panel,
measurements: &[Measurement],
available: f32,
overflow_width: f32,
zone_gap: f32,
) -> Resolution {
let available = available.max(0.0);
let zone_gap = zone_gap.max(0.);
let fits = |resolution: &Resolution| resolution.fits(available);
let mut result = Resolution {
zone_gap,
..Resolution::default()
};
let ordered: Vec<_> = [&panel.start, &panel.center, &panel.end]
.into_iter()
.flat_map(|zone| &zone.groups)
.flat_map(|group| &group.items)
.collect();
let measurements: BTreeMap<_, _> = measurements.iter().map(|measured| (&measured.id, measured)).collect();
// Allocation policy follows composition, never the renderer's sample order.
// Lower priorities yield first; definition order breaks equal-priority ties.
let mut candidates: Vec<_> = ordered.iter().enumerate().collect();
candidates.sort_by_key(|(index, item)| (item.priority, *index));
for item in &ordered {
let Some(measured) = measurements.get(&item.id) else {
continue;
};
let Some(&(representation, width)) = measured.alternatives.first() else {
continue;
};
if item.overflow == OverflowPolicy::Always {
result.items.insert(item.id.clone(), Placement::Overflow);
result.overflow.push(item.id.clone());
} else {
result
.items
.insert(item.id.clone(), Placement::Visible { representation, width });
}
}
result.update_widths(panel, overflow_width);
let mut minimum = result.clone();
for measured in measurements.values() {
if let Some(Placement::Visible { width, .. }) = minimum.items.get_mut(&measured.id) {
for (_, alternative) in &measured.alternatives {
*width = width.min(*alternative);
}
if let Some(flexible) = measured.minimum {
*width = width.min(flexible);
}
}
}
minimum.update_widths(panel, overflow_width);
result.minimum_width = minimum.required_width();
// Flexible content yields space without changing the stored item preference.
for (_, item) in &candidates {
let Some(measured) = measurements.get(&item.id).filter(|measured| measured.minimum.is_some()) else {
continue;
};
if fits(&result) {
break;
}
let Some(Placement::Visible { representation, width }) = result.items.get(&measured.id).cloned() else {
continue;
};
let minimum = measured.minimum.unwrap().min(width);
result.items.insert(
measured.id.clone(),
Placement::Visible {
representation,
width: minimum,
},
);
result.update_widths(panel, overflow_width);
}
for (_, item) in &candidates {
if fits(&result) {
break;
}
let Some(measured) = measurements.get(&item.id) else {
continue;
};
for &(representation, width) in measured.alternatives.iter().skip(1) {
if let Some(Placement::Visible { width: previous, .. }) = result.items.get(&item.id)
&& width < *previous
{
result
.items
.insert(item.id.clone(), Placement::Visible { representation, width });
result.update_widths(panel, overflow_width);
if fits(&result) {
break;
}
}
}
}
// Never is honored whenever the mandatory minimums fit. On physically smaller
// surfaces, retain access through overflow instead of silently clipping actions.
for force in [false, true] {
for (_, item) in &candidates {
if fits(&result) {
break;
}
if item.overflow == OverflowPolicy::Never && !force {
continue;
}
if matches!(result.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.0) {
result.items.insert(item.id.clone(), Placement::Overflow);
result.overflow.push(item.id.clone());
result.forced_overflow |= force && item.overflow == OverflowPolicy::Never;
result.update_widths(panel, overflow_width);
}
}
}
// A reduction on the shorter side can leave unused room after the other
// side shrinks. Restore preferred representations before expanding titles.
for (_, item) in candidates.iter().rev() {
let Some(measured) = measurements.get(&item.id) else {
continue;
};
let Some(previous @ Placement::Visible { representation, .. }) = result.items.get(&item.id).cloned() else {
continue;
};
for &(candidate, width) in measured
.alternatives
.iter()
.take_while(|(candidate, _)| *candidate != representation)
{
result.items.insert(
item.id.clone(),
Placement::Visible {
representation: candidate,
width: measured.minimum.map_or(width, |minimum| minimum.min(width)),
},
);
result.update_widths(panel, overflow_width);
if fits(&result) {
break;
}
result.items.insert(item.id.clone(), previous.clone());
}
}
result.update_widths(panel, overflow_width);
// Restore the most protected flexible items first, reversing the yield order.
for (_, item) in candidates.iter().rev() {
let Some(measured) = measurements.get(&item.id).filter(|measured| measured.minimum.is_some()) else {
continue;
};
let Some(Placement::Visible {
representation,
width: minimum,
}) = result.items.get(&measured.id).cloned()
else {
continue;
};
let Some(maximum) = measured
.alternatives
.iter()
.find_map(|(candidate, width)| (*candidate == representation).then_some(*width))
else {
continue;
};
let mut low = minimum;
let mut high = maximum;
for _ in 0..20 {
let width = (low + high) * 0.5;
*result.items.get_mut(&measured.id).unwrap() = Placement::Visible { representation, width };
result.update_widths(panel, overflow_width);
if fits(&result) { low = width } else { high = width }
}
*result.items.get_mut(&measured.id).unwrap() = Placement::Visible {
representation,
width: low,
};
}
result
.overflow
.sort_by_key(|id| ordered.iter().position(|item| &item.id == id).unwrap());
result.update_widths(panel, overflow_width);
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zone_width_ignores_empty_groups_and_gaps_before_hidden_items() {
use super::super::{Group, Item, ItemKind};
let mut panel = Panel::default();
let mut first = Item::new("first", ItemKind::Overview);
first.gap_before = Some(99.);
let hidden = Item::new("hidden", ItemKind::Network);
let mut last = Item::new("last", ItemKind::Clock);
last.gap_before = Some(7.);
let mut group = Group::new("content", vec![first, hidden, last], [0, 3], 5.);
panel.style = crate::PanelStyle::Floating;
group.island_padding = 4.;
let empty = Group::new("empty", vec![Item::new("missing", ItemKind::Media)], [0, 99], 99.);
let trailing = Group::new(
"trailing",
vec![Item::new("trailing", ItemKind::Battery { percentage: false })],
[0, 2],
5.,
);
panel.start.groups = vec![empty.clone(), group, empty, trailing];
panel.start.spacing = 11.;
let items = [
(
ItemId("first".into()),
Placement::Visible {
representation: Representation::Icon,
width: 20.,
},
),
(ItemId("hidden".into()), Placement::Overflow),
(
ItemId("last".into()),
Placement::Visible {
representation: Representation::Wide,
width: 30.,
},
),
(
ItemId("trailing".into()),
Placement::Visible {
representation: Representation::Icon,
width: 10.,
},
),
]
.into();
assert_eq!(zone_width(&panel, &panel.start, &items), 106.);
}
#[test]
fn a_center_only_item_keeps_its_preferred_size_without_side_gaps() {
use super::super::{Group, Item, ItemKind};
let mut panel = Panel::default();
panel.start.groups.clear();
panel.end.groups.clear();
panel.center.groups = vec![Group::new(
"center",
vec![Item::new("media", ItemKind::Media)],
[0, 0],
0.,
)];
let measurements = [Measurement {
id: ItemId("media".into()),
alternatives: vec![
(Representation::Wide, 40.),
(Representation::Compact, 24.),
(Representation::Icon, 12.),
],
minimum: None,
}];
for gap in [0., 8., 32.] {
for available in [40., 48.] {
let resolution = resolve_with_gap(&panel, &measurements, available, 24., gap);
assert_eq!(
resolution.items[&ItemId("media".into())],
Placement::Visible {
representation: Representation::Wide,
width: 40.
}
);
assert!(resolution.overflow.is_empty());
assert!(resolution.fits(available));
}
}
}
#[test]
fn center_collision_spacing_applies_only_to_occupied_sides() {
for (widths, required) in [
([0., 40., 0.], 40.),
([20., 40., 0.], 96.),
([0., 40., 20.], 96.),
([20., 40., 30.], 116.),
([20., 0., 30.], 58.),
([0., 0., 30.], 30.),
] {
let resolution = Resolution {
zone_widths: widths,
zone_gap: 8.,
..Default::default()
};
assert!(resolution.fits(required));
assert!(!resolution.fits(required - 1.));
}
}
#[test]
fn a_crowded_opposite_zone_does_not_leave_media_compacted_in_unused_space() {
use super::super::{Group, Item, ItemKind};
let mut panel = Panel::default();
panel.start.groups = vec![Group::new(
"navigation",
vec![
Item::new("overview", ItemKind::Overview),
Item::new("media", ItemKind::Media),
],
[0, 0],
0.,
)];
panel.center.groups = vec![Group::new(
"title",
vec![Item::new("title", ItemKind::FocusedWindow)],
[0, 0],
0.,
)];
panel.end.groups = vec![Group::new(
"status",
vec![Item::new("clock", ItemKind::Clock)],
[0, 0],
0.,
)];
let measured = vec![
Measurement {
id: ItemId("overview".into()),
alternatives: vec![(Representation::Icon, 28.)],
minimum: None,
},
Measurement {
id: ItemId("media".into()),
alternatives: vec![
(Representation::Wide, 180.),
(Representation::Compact, 100.),
(Representation::Icon, 28.),
],
minimum: None,
},
Measurement {
id: ItemId("title".into()),
alternatives: vec![(Representation::Wide, 48.)],
minimum: Some(48.),
},
Measurement {
id: ItemId("clock".into()),
alternatives: vec![
(Representation::Wide, 340.),
(Representation::Compact, 240.),
(Representation::Icon, 140.),
],
minimum: None,
},
];
for mirrored in [false, true] {
if mirrored {
std::mem::swap(&mut panel.start, &mut panel.end);
}
let resolution = resolve(&panel, &measured, 550., 32.);
assert!(resolution.fits(550.));
assert_eq!(
resolution.items[&ItemId("media".into())],
Placement::Visible {
representation: Representation::Wide,
width: 180.
}
);
assert_eq!(
resolution.items[&ItemId("clock".into())],
Placement::Visible {
representation: Representation::Compact,
width: 240.
}
);
}
let mut measured = measured;
measured[1].alternatives[0].1 = 400.;
measured[1].minimum = Some(112.);
for mirrored in [false, true] {
if mirrored {
std::mem::swap(&mut panel.start, &mut panel.end);
}
let resolution = resolve(&panel, &measured, 550., 32.);
assert!(resolution.fits(550.));
assert!(
matches!(resolution.items[&ItemId("media".into())], Placement::Visible { representation: Representation::Wide, width } if width > 200. && width < 400.),
"flexible media must expand into the unused room beside the center"
);
}
}
#[test]
fn fits_uses_the_gap_that_was_resolved() {
let panel = Panel::default();
let measured = measurements(&panel);
for gap in [0., 2., 16., 48.] {
let result = resolve_with_gap(&panel, &measured, 2400., 32., gap);
assert_eq!(result.zone_gap, gap);
let required = result.required_width_with_gap(gap);
assert!(result.fits(required));
assert!(!result.fits(required - 1.));
}
}
#[test]
fn flexible_restoration_stays_within_the_selected_representation() {
let mut panel = Panel::default();
panel.start.groups.clear();
panel.center.groups.clear();
panel.end.groups.retain(|group| group.id.0 == "time");
let mut clock = super::super::Item::new("clock", super::super::ItemKind::Clock);
clock.overflow = OverflowPolicy::Never;
clock.priority = 0;
let audio = super::super::Item::new("audio", super::super::ItemKind::Audio);
panel.end.groups[0].items = vec![clock, audio];
let result = resolve(
&panel,
&[
Measurement {
id: ItemId("clock".into()),
alternatives: vec![(Representation::Wide, 80.), (Representation::Compact, 40.)],
minimum: Some(60.),
},
Measurement {
id: ItemId("audio".into()),
alternatives: vec![(Representation::Icon, 100.)],
minimum: None,
},
],
96.,
32.,
);
assert_eq!(
result.items[&ItemId("clock".into())],
Placement::Visible {
representation: Representation::Compact,
width: 40.
}
);
assert_eq!(result.overflow, [ItemId("audio".into())]);
assert!(result.fits(96.));
}
#[test]
fn allocation_is_independent_of_measurement_order_even_for_flexible_items() {
let panel = Panel::default();
let mut measured = measurements(&panel);
for measurement in &mut measured {
measurement.minimum = Some(24.);
}
for width in [2400., 1400., 1000., 800., 400., 80., 32.] {
let expected = resolve_with_gap(&panel, &measured, width, 32., 16.);
for _ in 0..measured.len() {
measured.rotate_left(1);
assert_eq!(resolve_with_gap(&panel, &measured, width, 32., 16.), expected);
measured.reverse();
assert_eq!(resolve_with_gap(&panel, &measured, width, 32., 16.), expected);
measured.reverse();
}
}
}
fn measurements(panel: &Panel) -> Vec<Measurement> {
[&panel.start, &panel.center, &panel.end]
.into_iter()
.flat_map(|zone| &zone.groups)
.flat_map(|group| &group.items)
.map(|item| Measurement {
id: item.id.clone(),
alternatives: vec![
(Representation::Wide, 80.0),
(Representation::Compact, 40.0),
(Representation::Icon, 24.0),
],
minimum: matches!(item.kind, crate::panel::ItemKind::FocusedWindow).then_some(48.0),
})
.collect()
}
#[test]
fn narrow_panels_adapt_deterministically_and_keep_every_item_reachable() {
for background in [crate::PanelStyle::Flat, crate::PanelStyle::Floating] {
let panel = Panel::from_preset(background);
let measured = measurements(&panel);
for width in [2400., 1200., 800., 400., 160., 80., 32.] {
let resolution = resolve(&panel, &measured, width, 32.);
assert!(resolution.fits(width));
assert_eq!(resolution, resolve(&panel, &measured, width, 32.));
assert_eq!(resolution.items.len(), measured.len());
for id in &resolution.overflow {
assert_eq!(resolution.items.get(id), Some(&Placement::Overflow));
}
if width >= 400. {
assert!(!resolution.forced_overflow);
}
}
}
}
#[test]
fn decoration_and_borders_do_not_change_background_arrangement_or_allocation() {
for background in [crate::PanelStyle::Flat, crate::PanelStyle::Floating] {
let mut panel = Panel::from_preset(background);
let measurements = measurements(&panel);
let plain = resolve(&panel, &measurements, 1000., 32.);
assert!(!panel.border);
panel.border = true;
panel.background_opacity = Some(0.4);
assert_eq!(resolve(&panel, &measurements, 1000., 32.), plain);
assert_eq!(panel.preset(), Some(background));
}
}
#[test]
fn side_margins_stop_before_overflow_and_use_each_outputs_logical_width() {
let panel = Panel::default();
let measurements = measurements(&panel);
let full = resolve(&panel, &measurements, 4000., 32.);
assert!(full.minimum_width < full.required_width());
for output_width in [1600, 2400, 3840] {
for requested in [0, 32, 400, 4096] {
let margin = full.side_margin(requested, output_width, 12.);
let available = output_width as f32 - 2. * (margin as f32 + 12.);
let result = resolve(&panel, &measurements, available, 32.);
assert!(result.overflow.is_empty(), "output {output_width}, margin {margin}");
assert!(result.fits(available));
assert!(margin <= requested);
}
}
assert!(full.side_margin(4096, 3840, 12.) > full.side_margin(4096, 1600, 12.));
assert_eq!(full.side_margin(4096, 100, 12.), 0);
assert!(
!resolve(&panel, &measurements, 76., 32.).overflow.is_empty(),
"physical output shortage still uses existing overflow"
);
}
#[test]
fn margin_limit_includes_group_padding_and_authored_overflow_without_dependence_on_current_surface_width() {
let mut panel = Panel::default();
panel.end.groups[0].items[0].overflow = OverflowPolicy::Always;
let measurements = measurements(&panel);
let full = resolve(&panel, &measurements, 4000., 32.);
let narrow = resolve(&panel, &measurements, 100., 32.);
assert_eq!(full.minimum_width, narrow.minimum_width);
let margin = full.side_margin(4096, 2400, 12.);
let result = resolve(&panel, &measurements, 2400. - 2. * (margin as f32 + 12.), 32.);
assert_eq!(result.overflow, full.overflow);
let original = full.minimum_width;
panel.end.groups[0].padding[1] += 10;
let padded = resolve(&panel, &measurements, 4000., 32.);
assert!(padded.minimum_width > original);
assert!(padded.side_margin(4096, 2400, 12.) < margin);
}
#[test]
fn asymmetric_sides_reserve_a_real_screen_center() {
let panel = Panel::default();
let resolution = resolve(&panel, &measurements(&panel), 1500., 32.);
let [start, center, end] = resolution.zone_widths;
assert!(end > start);
assert!(start + 8. <= (1500. - center) * 0.5);
assert!((1500. + center) * 0.5 + 8. <= 1500. - end);
}
#[test]
fn always_overflow_is_reserved_and_unavailable_items_are_not_allocated() {
let mut panel = Panel::default();
panel.end.groups[0].items[2].overflow = OverflowPolicy::Always;
let mut measured = measurements(&panel);
measured.remove(3);
let resolution = resolve(&panel, &measured, 2000., 32.);
assert!(!resolution.items.contains_key(&ItemId("media".into())));
assert_eq!(resolution.overflow, [ItemId("network".into())]);
}
#[test]
fn priority_controls_displacement_and_expansion_restores_requested_sizes() {
let mut panel = Panel::default();
panel.start.groups.clear();
panel.center.groups.clear();
panel.end.groups.retain(|group| group.id.0 == "time");
let mut first = panel.end.groups[0].items[0].clone();
first.id = ItemId("low".into());
first.priority = 0;
let mut second = first.clone();
second.id = ItemId("high".into());
second.priority = 100;
let mut third = first.clone();
third.id = ItemId("middle".into());
third.priority = 50;
panel.end.groups[0].items = vec![first, second, third];
let measured: Vec<_> = panel.end.groups[0]
.items
.iter()
.map(|item| Measurement {
id: item.id.clone(),
alternatives: vec![(Representation::Wide, 50.)],
minimum: None,
})
.collect();
let initial = panel.clone();
let small = resolve(&panel, &measured, 150., 32.);
assert_eq!(small.overflow, [ItemId("low".into())]);
assert!(small.fits(150.));
for width in [200., 150., 40., 200.] {
let result = resolve(&panel, &measured, width, 32.);
assert!(result.fits(width));
if width == 200. {
assert!(result.overflow.is_empty());
assert!(result.items.values().all(|placement| matches!(placement, Placement::Visible { representation: Representation::Wide, width } if *width == 50.)));
}
assert_eq!(panel, initial, "resolution must not rewrite configuration intent");
}
}
#[test]
fn panel_surface_policy_controls_padding_for_every_group() {
let mut panel = Panel::from_preset(crate::PanelStyle::Floating);
let measured = measurements(&panel);
let initial = resolve(&panel, &measured, 2400., 32.);
panel.style = crate::PanelStyle::Flat;
let plain = resolve(&panel, &measured, 2400., 32.);
for (i, zone) in [&panel.start, &panel.center, &panel.end].into_iter().enumerate() {
let padding: f32 = zone.groups.iter().map(|group| 2. * group.island_padding).sum();
assert_eq!(plain.zone_widths[i], initial.zone_widths[i] - padding);
}
panel.style = crate::PanelStyle::Flat;
assert_eq!(resolve(&panel, &measured, 2400., 32.), plain);
panel.style = crate::PanelStyle::Flat;
assert_eq!(resolve(&panel, &measured, 2400., 32.), plain);
}
}
+176
View File
@@ -0,0 +1,176 @@
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Corner radii, clockwise from top-left, in logical pixels.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CornerRadii(pub [f32; 4]);
impl CornerRadii {
pub fn uniform(radius: f32) -> Self {
Self([radius; 4])
}
pub fn validate(self) -> Result<(), String> {
if self.0.iter().all(|value| value.is_finite() && *value >= 0.) {
Ok(())
} else {
Err("corner radii must be finite, nonnegative pixel values".into())
}
}
/// A panel touching the top edge must cover its top corners.
pub fn at_top_edge(mut self, touching: bool) -> Self {
if touching {
self.0[0] = 0.;
self.0[1] = 0.;
}
self
}
pub fn at_edge(mut self, edge: super::Edge, touching: bool) -> Self {
if touching {
match edge {
super::Edge::Top => self = self.at_top_edge(true),
super::Edge::Bottom => {
self.0[2] = 0.;
self.0[3] = 0.;
}
}
}
self
}
pub fn max(self) -> f32 {
self.0.into_iter().fold(0., f32::max)
}
/// Keep every corner within half the shorter surface dimension.
pub fn clamped(self, width: f32, height: f32) -> Self {
let limit = width.min(height).max(0.) / 2.;
Self(self.0.map(|value| value.min(limit)))
}
/// Scale all corners together when their sums exceed an adjacent edge.
/// Unlike the symmetric round-box clamp, this permits a large free-side
/// corner when the corner on the anchored side is square.
pub fn normalized(self, width: f32, height: f32) -> Self {
let [tl, tr, br, bl] = self.0;
let mut factor = 1.0_f64;
for (length, sum) in [
(width, f64::from(tl) + f64::from(tr)),
(width, f64::from(bl) + f64::from(br)),
(height, f64::from(tl) + f64::from(bl)),
(height, f64::from(tr) + f64::from(br)),
] {
if sum > 0. {
factor = factor.min(f64::from(length.max(0.)) / sum);
}
}
Self(self.0.map(|radius| (f64::from(radius) * factor) as f32))
}
}
impl std::str::FromStr for CornerRadii {
type Err = String;
fn from_str(source: &str) -> Result<Self, Self::Err> {
let values: Vec<f32> = source
.split_whitespace()
.map(|part| {
let number = if part == "0" {
part
} else {
part.strip_suffix("px").ok_or("use pixel values such as 4px")?
};
number.parse::<f32>().map_err(|_| "invalid corner radius")
})
.collect::<Result<_, _>>()
.map_err(str::to_owned)?;
let radii = match values.as_slice() {
[a] => [*a; 4],
[a, b] => [*a, *b, *a, *b],
[a, b, c] => [*a, *b, *c, *b],
[a, b, c, d] => [*a, *b, *c, *d],
_ => return Err("enter one to four pixel values, clockwise from top-left".into()),
};
let result = Self(radii);
result.validate()?;
Ok(result)
}
}
impl std::fmt::Display for CornerRadii {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let [a, b, c, d] = self.0;
if a == b && b == c && c == d {
write!(f, "{a}px")
} else if a == c && b == d {
write!(f, "{a}px {b}px")
} else if b == d {
write!(f, "{a}px {b}px {c}px")
} else {
write!(f, "{a}px {b}px {c}px {d}px")
}
}
}
impl Serialize for CornerRadii {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for CornerRadii {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer)?
.parse()
.map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn css_shorthand_expands_clockwise_and_round_trips() {
for (source, expected) in [
("4px", [4.; 4]),
("4px 8px", [4., 8., 4., 8.]),
("4px 8px 12px", [4., 8., 12., 8.]),
("4px 8px 12px 16px", [4., 8., 12., 16.]),
("0 2.5px", [0., 2.5, 0., 2.5]),
] {
let radii: CornerRadii = source.parse().unwrap();
assert_eq!(radii.0, expected);
let value = serde_json::to_value(radii).unwrap();
assert_eq!(serde_json::from_value::<CornerRadii>(value).unwrap(), radii);
}
}
#[test]
fn docking_squares_only_the_top_corners_without_changing_the_override() {
let radii = CornerRadii([4., 8., 12., 16.]);
assert_eq!(radii.at_top_edge(true).0, [0., 0., 12., 16.]);
assert_eq!(radii.at_top_edge(false), radii);
assert_eq!(radii.at_edge(super::super::Edge::Bottom, true).0, [4., 8., 0., 0.]);
assert_eq!(radii.at_edge(super::super::Edge::Bottom, false), radii);
assert_eq!(radii.0, [4., 8., 12., 16.]);
}
#[test]
fn invalid_values_are_rejected_and_large_radii_clamp_to_surface_size() {
for source in [
"",
"4",
"4%",
"-4px",
"NaNpx",
"infpx",
"4px / 8px",
"1px 2px 3px 4px 5px",
] {
assert!(source.parse::<CornerRadii>().is_err(), "{source}");
}
assert_eq!(CornerRadii([4., 40., 0., 18.]).clamped(100., 28.).0, [4., 14., 0., 14.]);
}
}
+716
View File
@@ -0,0 +1,716 @@
use crate::shape::{AnchoredRadii, PixelGrid, RoundedSurface, Style, ZonePosition};
use crate::{CornerRadii, Edge};
use ferese_layout::Rect;
pub const MAX_PRIMITIVES: usize = 32;
/// Additive rounded rectangles, one optional cutout, and concave corner patches.
/// Patch codes name the circle's center: bottom-left, bottom-right, top-right,
/// top-left. A patch is its rectangle minus that circle, rather than a hot area.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Primitive {
pub bounds: Rect,
pub radii: CornerRadii,
pub operation: u8,
pub blend_zone: u8,
}
impl Primitive {
pub fn packet(self) -> [f32; 10] {
let Rect { x, y, width, height } = self.bounds;
let [tl, tr, br, bl] = self.radii.0;
[
x as f32,
y as f32,
width as f32,
height as f32,
tl,
tr,
br,
bl,
self.operation as f32,
self.blend_zone as f32,
]
}
pub fn from_packet(values: [f32; 10]) -> Result<Self, &'static str> {
if !values.into_iter().all(|v| v.is_finite() && v.abs() <= 32768.)
|| values[2] <= 0.
|| values[3] <= 0.
|| values[4..8].iter().any(|v| *v < 0.)
|| values[8].fract() != 0.
|| !(0. ..=5.).contains(&values[8])
|| values[9].fract() != 0.
|| !(0. ..=3.).contains(&values[9])
{
return Err("invalid panel shape primitive");
}
Ok(Self {
bounds: Rect::new(values[0].into(), values[1].into(), values[2].into(), values[3].into()),
radii: CornerRadii([values[4], values[5], values[6], values[7]]).normalized(values[2], values[3]),
operation: values[8] as u8,
blend_zone: values[9] as u8,
})
}
pub fn distance(self, point: [f64; 2]) -> f64 {
let rect = rectangle_distance(point, self.bounds);
if self.operation >= 2 {
let b = self.bounds;
let center = match self.operation {
2 => [b.x, b.y + b.height],
3 => [b.x + b.width, b.y + b.height],
4 => [b.x + b.width, b.y],
_ => [b.x, b.y],
};
return rect.max(b.width.min(b.height) - (point[0] - center[0]).hypot(point[1] - center[1]));
}
let b = self.bounds;
let (x, y) = (point[0] - b.x, point[1] - b.y);
let mut distance = rect;
for (r, dx, dy) in [
(self.radii.0[0], x, y),
(self.radii.0[1], b.width - x, y),
(self.radii.0[2], b.width - x, b.height - y),
(self.radii.0[3], x, b.height - y),
] {
let r = f64::from(r);
if r > 0. && dx < r && dy < r {
distance = distance.max((dx - r).hypot(dy - r) - r);
}
}
distance
}
}
fn rectangle_distance(point: [f64; 2], b: Rect) -> f64 {
let x = (point[0] - b.x - b.width / 2.).abs() - b.width / 2.;
let y = (point[1] - b.y - b.height / 2.).abs() - b.height / 2.;
x.max(0.).hypot(y.max(0.)) + x.max(y).min(0.)
}
pub fn smooth_min(a: f64, b: f64, strength: f64) -> f64 {
if strength <= 0. {
return a.min(b);
}
let h = ((strength - (a - b).abs()) / strength).max(0.);
a.min(b) - h * h * strength / 4.
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Scene {
pub paint: Vec<Primitive>,
pub input: Vec<RoundedSurface>,
/// Bounded drawing/capture regions. Frame uses strips, not the output area.
pub damage: Vec<Rect>,
pub dividers: Vec<[f64; 4]>,
pub fusion: f64,
}
impl Scene {
pub fn distance(&self, point: [f64; 2]) -> f64 {
let mut zones = [f64::INFINITY; 4];
let mut cutout = f64::INFINITY;
for primitive in &self.paint {
let distance = primitive.distance(point);
if primitive.operation == 1 {
cutout = cutout.min(distance);
} else {
let zone = usize::from(primitive.blend_zone);
zones[zone] = if zone > 0 {
smooth_min(zones[zone], distance, self.fusion)
} else {
zones[zone].min(distance)
};
}
}
zones.into_iter().fold(f64::INFINITY, f64::min).max(-cutout)
}
pub fn contains(&self, point: [f64; 2]) -> bool {
self.input.iter().any(|shape| shape.contains(point))
}
pub fn packets(&self) -> Vec<[f32; 10]> {
self.paint.iter().map(|p| p.packet()).collect()
}
/// Horizontal runs at physical-pixel centers. Corners and animated gaps
/// stay click-through; adjacent equal runs are coalesced into one rectangle.
pub fn input_rectangles(&self, scale: f64) -> Vec<[i32; 4]> {
if !scale.is_finite() || scale <= 0. {
return Vec::new();
}
let mut result: Vec<[i32; 4]> = Vec::new();
for shape in &self.input {
let b = shape.bounds();
let left = (b.x * scale).floor() as i32;
let right = ((b.x + b.width) * scale).ceil() as i32;
let top = (b.y * scale).floor() as i32;
let bottom = ((b.y + b.height) * scale).ceil() as i32;
for y in top..bottom {
let mut first = left;
while first < right && !shape.contains([(f64::from(first) + 0.5) / scale, (f64::from(y) + 0.5) / scale])
{
first += 1;
}
let mut last = right;
while last > first && !shape.contains([(f64::from(last) - 0.5) / scale, (f64::from(y) + 0.5) / scale]) {
last -= 1;
}
if first == last {
continue;
}
if let Some(previous) = result.last_mut()
&& previous[0] == first
&& previous[2] == last - first
&& previous[1] + previous[3] == y
{
previous[3] += 1;
} else {
result.push([first, y, last - first, 1]);
}
}
}
result
}
}
#[derive(Clone, Copy, Debug)]
pub struct GroupBounds {
pub zone: ZonePosition,
pub bounds: Rect,
}
#[derive(Clone, Copy, Debug)]
pub struct Parameters {
pub style: Style,
pub attached: bool,
pub edge: Edge,
pub height: f64,
pub gap: f64,
pub radius: Option<AnchoredRadii>,
pub frame_insets: [f64; 4],
pub frame_radius: f64,
pub wrap_size: [f64; 2],
pub wrap_width: f64,
pub fillet: f64,
}
impl Default for Parameters {
fn default() -> Self {
Self {
style: Style::Flat,
attached: false,
edge: Edge::Top,
height: 36.,
gap: 8.,
radius: None,
frame_insets: [40., 8., 8., 8.],
frame_radius: 14.,
wrap_size: [560., 380.],
wrap_width: 36.,
fillet: 10.,
}
}
}
fn add(
scene: &mut Scene,
grid: PixelGrid,
rect: Rect,
radii: CornerRadii,
input: bool,
zone: u8,
) -> Result<(), &'static str> {
let rounded = RoundedSurface::new(rect, radii, grid)?;
if rounded.bounds().width == 0. || rounded.bounds().height == 0. {
return Ok(());
}
scene.paint.push(Primitive {
bounds: rounded.bounds(),
radii: rounded.radii(),
operation: 0,
blend_zone: zone,
});
if input {
scene.input.push(rounded);
}
scene.damage.push(rounded.bounds());
Ok(())
}
pub fn derive(
parameters: Parameters,
bounds: Rect,
groups: &[GroupBounds],
grid: PixelGrid,
) -> Result<Scene, &'static str> {
let bounds = grid.rect(bounds)?;
if bounds.width == 0. || bounds.height == 0. {
return Ok(Scene::default());
}
if ![
parameters.height,
parameters.gap,
parameters.frame_radius,
parameters.wrap_width,
parameters.fillet,
]
.into_iter()
.chain(parameters.frame_insets)
.chain(parameters.wrap_size)
.all(|v| v.is_finite() && (0. ..=32768.).contains(&v))
|| parameters
.radius
.is_some_and(|r| r.screen_space(parameters.edge).validate().is_err())
{
return Err("invalid panel shape parameters");
}
let mut scene = Scene::default();
match parameters.style {
Style::Flat => add(&mut scene, grid, bounds, CornerRadii::uniform(0.), true, 0)?,
Style::Frame => {
add(&mut scene, grid, bounds, CornerRadii::uniform(0.), false, 0)?;
let [top, right, bottom, left] = parameters.frame_insets;
let left = left.min(bounds.width / 3.);
let right = right.min(bounds.width / 3.);
let top = top.min(bounds.height / 3.);
let bottom = bottom.min(bounds.height / 3.);
let inner = grid.rect(Rect::new(
bounds.x + left,
bounds.y + top,
bounds.width - left - right,
bounds.height - top - bottom,
))?;
let r = parameters.frame_radius as f32;
let radii = CornerRadii([
if left > 0.5 && top > 0.5 { r } else { 0. },
if right > 0.5 && top > 0.5 { r } else { 0. },
if right > 0.5 && bottom > 0.5 { r } else { 0. },
if left > 0.5 && bottom > 0.5 { r } else { 0. },
])
.normalized(inner.width as f32, inner.height as f32);
scene.paint.push(Primitive {
bounds: inner,
radii,
operation: 1,
blend_zone: 0,
});
scene.input.push(RoundedSurface::new(
Rect::new(bounds.x, bounds.y, bounds.width, top),
CornerRadii::uniform(0.),
grid,
)?);
let radius = f64::from(radii.max());
scene.damage = vec![
Rect::new(bounds.x, bounds.y, bounds.width, top + radius),
Rect::new(
bounds.x,
bounds.y + bounds.height - bottom - radius,
bounds.width,
bottom + radius,
),
Rect::new(
bounds.x,
bounds.y + top + radius,
left + radius,
(bounds.height - top - bottom - 2. * radius).max(0.),
),
Rect::new(
bounds.x + bounds.width - right - radius,
bounds.y + top + radius,
right + radius,
(bounds.height - top - bottom - 2. * radius).max(0.),
),
];
}
Style::CornerWrap => {
let h = parameters.height.min(bounds.height);
let w = parameters.wrap_width.min(bounds.width);
let horizontal = Rect::new(bounds.x, bounds.y, parameters.wrap_size[0].min(bounds.width), h);
let vertical = Rect::new(bounds.x, bounds.y, w, parameters.wrap_size[1].min(bounds.height));
add(
&mut scene,
grid,
horizontal,
AnchoredRadii([0., 0., 14., 0.]).screen_space(Edge::Top),
true,
0,
)?;
add(&mut scene, grid, vertical, CornerRadii([0., 0., 14., 0.]), true, 0)?;
// Capture the union once: overlapping translucent captures darken the elbow.
scene.damage = vec![
horizontal,
Rect::new(vertical.x, vertical.y + h, w, (vertical.height - h).max(0.)),
];
let r = parameters
.fillet
.min((horizontal.width - w).max(0.))
.min((vertical.height - h).max(0.));
if r > 0. {
let patch = grid.rect(Rect::new(bounds.x + w, bounds.y + h, r, r))?;
scene.damage.push(patch);
scene.paint.push(Primitive {
bounds: patch,
radii: CornerRadii::uniform(0.),
operation: 4,
blend_zone: 0,
});
}
}
Style::Morph | Style::WindowHug => {
let attached = parameters.style == Style::Morph && parameters.attached;
let radii = parameters
.radius
.map_or(CornerRadii::uniform((bounds.height / 2.) as f32), |r| {
r.screen_space(parameters.edge)
})
.at_edge(parameters.edge, attached);
add(&mut scene, grid, bounds, radii, true, 0)?;
if attached {
let r = grid.snap(parameters.fillet.min(bounds.height));
if r > 0. {
let (y, left, right) = if parameters.edge == Edge::Top {
(bounds.y, 2, 3)
} else {
(bounds.y + bounds.height - r, 5, 4)
};
for (x, operation) in [(bounds.x - r, left), (bounds.x + bounds.width, right)] {
let patch = grid.rect(Rect::new(x, y, r, r))?;
scene.paint.push(Primitive {
bounds: patch,
radii: CornerRadii::uniform(0.),
operation,
blend_zone: 0,
});
scene.damage.push(patch);
}
}
}
}
style => {
for (zone_index, zone) in [ZonePosition::Start, ZonePosition::Center, ZonePosition::End]
.into_iter()
.enumerate()
{
let occupied: Vec<_> = groups
.iter()
.filter(|g| g.zone == zone && g.bounds.width > 0. && g.bounds.height > 0.)
.collect();
if occupied.is_empty() {
continue;
}
if style.connected() {
let x = occupied.iter().map(|g| g.bounds.x).fold(f64::INFINITY, f64::min);
let end = occupied
.iter()
.map(|g| g.bounds.x + g.bounds.width)
.fold(f64::NEG_INFINITY, f64::max);
let (x, end) = if style == Style::Notch {
match zone {
ZonePosition::Start => (bounds.x, end),
ZonePosition::End => (x, bounds.x + bounds.width),
ZonePosition::Center => (x, end),
}
} else {
(x, end)
};
let body = Rect::new(x, bounds.y, end - x, bounds.height);
let radii = parameters.radius.map_or_else(
|| {
crate::shape::effective_radii(
style,
zone,
parameters.edge,
[body.width as f32, body.height as f32],
0,
1,
)
},
|r| {
Ok(r.screen_space(parameters.edge)
.normalized(body.width as f32, body.height as f32))
},
)?;
add(&mut scene, grid, body, radii, true, 0)?;
for group in occupied.iter().take(occupied.len() - 1) {
let seam = grid.snap(group.bounds.x + group.bounds.width);
scene
.dividers
.push([seam, bounds.y + 8., seam, bounds.y + bounds.height - 8.]);
}
if style == Style::Notch && zone == ZonePosition::Center {
let r = parameters
.fillet
.min(bounds.height)
.min((x - bounds.x).max(0.))
.min((bounds.x + bounds.width - end).max(0.));
if r > 0. {
let (y, left, right) = if parameters.edge == Edge::Top {
(bounds.y, 2, 3)
} else {
(bounds.y + bounds.height - r, 5, 4)
};
for (x, operation) in [(x - r, left), (end, right)] {
scene.paint.push(Primitive {
bounds: grid.rect(Rect::new(x, y, r, r))?,
radii: CornerRadii::uniform(0.),
operation,
blend_zone: 0,
});
scene.damage.push(Rect::new(x, y, r, r));
}
}
}
} else {
for group in occupied {
let body = Rect::new(group.bounds.x, bounds.y, group.bounds.width, bounds.height);
let radii = parameters
.radius
.map_or(CornerRadii::uniform((bounds.height / 2.) as f32), |r| {
r.screen_space(parameters.edge)
});
add(
&mut scene,
grid,
body,
radii,
true,
if style == Style::Fused {
(zone_index + 1) as u8
} else {
0
},
)?;
}
}
}
// Polynomial smooth-min only bridges when k > 2*gap. A strength
// of 0.8*gap cannot form a neck with this kernel.
if style == Style::Fused {
scene.fusion = parameters.gap * 2.4;
scene.damage.clear();
for zone in 1..=3 {
let mut shapes = scene.paint.iter().filter(|p| p.blend_zone == zone);
if let Some(first) = shapes.next() {
let mut region = first.bounds;
for shape in shapes {
let right = (region.x + region.width).max(shape.bounds.x + shape.bounds.width);
region.x = region.x.min(shape.bounds.x);
region.width = right - region.x;
}
scene.damage.push(region);
}
}
}
}
}
if parameters.edge == Edge::Bottom && matches!(parameters.style, Style::Frame | Style::CornerWrap) {
let mirror = |mut r: Rect| {
r.y = 2. * bounds.y + bounds.height - r.y - r.height;
r
};
for primitive in &mut scene.paint {
primitive.bounds = mirror(primitive.bounds);
let [tl, tr, br, bl] = primitive.radii.0;
primitive.radii = CornerRadii([bl, br, tr, tl]);
primitive.operation = match primitive.operation {
2 => 5,
3 => 4,
4 => 3,
5 => 2,
op => op,
};
}
for input in &mut scene.input {
let [tl, tr, br, bl] = input.radii().0;
*input = RoundedSurface::new(mirror(input.bounds()), CornerRadii([bl, br, tr, tl]), grid)?;
}
for damage in &mut scene.damage {
*damage = mirror(*damage);
}
}
if scene.paint.len() > MAX_PRIMITIVES {
return Err("panel shape exceeds renderer primitive capacity");
}
scene.damage.retain(|r| r.width > 0. && r.height > 0.);
Ok(scene)
}
#[cfg(test)]
mod tests {
use super::*;
fn groups() -> [GroupBounds; 3] {
[
GroupBounds {
zone: ZonePosition::Start,
bounds: Rect::new(8., 0., 100., 36.),
},
GroupBounds {
zone: ZonePosition::Start,
bounds: Rect::new(116., 0., 80., 36.),
},
GroupBounds {
zone: ZonePosition::Center,
bounds: Rect::new(350., 0., 100., 36.),
},
]
}
fn scene(style: Style) -> Scene {
derive(
Parameters {
style,
..Default::default()
},
Rect::new(0., 0., 800., 36.),
&groups(),
PixelGrid::new(1.25).unwrap(),
)
.unwrap()
}
#[test]
fn floating_gaps_are_unpainted_and_click_through() {
let scene = scene(Style::Floating);
assert_eq!(scene.paint.len(), 3);
assert!(scene.distance([112., 18.]) > 0. && !scene.contains([112., 18.]));
assert!(scene.contains([50., 18.]));
}
#[test]
fn capsules_use_one_outline_per_occupied_zone() {
let scene = scene(Style::Capsules);
assert_eq!(scene.paint.len(), 2);
assert_eq!(scene.dividers.len(), 1);
assert!(scene.distance([112., 18.]) < 0.);
}
#[test]
fn notch_fillets_are_painted_but_never_interactive() {
let scene = scene(Style::Notch);
assert!(scene.distance([349., 1.]) < 0.);
assert!(!scene.contains([349., 1.]));
assert!(scene.distance([340.5, 9.5]) > 0.);
assert_eq!(scene.paint[0].bounds.x, 0.);
}
#[test]
fn fused_neighbors_grow_a_neck_without_capturing_the_gap() {
let scene = scene(Style::Fused);
assert!(scene.distance([112., 18.]) < 0.);
assert!(!scene.contains([112., 18.]));
assert!(scene.distance([250., 18.]) > 0.);
}
#[test]
fn fused_capture_includes_the_neck() {
let scene = scene(Style::Fused);
assert!(
scene
.damage
.iter()
.any(|r| r.x <= 112. && r.x + r.width > 112. && r.y <= 18. && r.y + r.height > 18.)
);
}
#[test]
fn corner_wrap_capture_is_disjoint_and_the_workspace_arm_is_interactive() {
let scene = derive(
Parameters {
style: Style::CornerWrap,
..Default::default()
},
Rect::new(0., 0., 800., 600.),
&[],
PixelGrid::new(1.25).unwrap(),
)
.unwrap();
assert!(scene.contains([18., 200.]));
assert!(!scene.contains([38., 38.])); // the decorative elbow is not input
for (i, a) in scene.damage.iter().enumerate() {
for b in &scene.damage[i + 1..] {
assert!(a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y);
}
}
for x in (0..800).step_by(3) {
for y in (0..600).step_by(3) {
let point = [x as f64 + 0.5, y as f64 + 0.5];
if scene.distance(point) < 0. {
assert!(scene.damage.iter().any(|r| point[0] >= r.x
&& point[0] < r.x + r.width
&& point[1] >= r.y
&& point[1] < r.y + r.height));
}
}
}
}
#[test]
fn frame_has_a_cutout_and_bounded_damage() {
let scene = derive(
Parameters {
style: Style::Frame,
..Default::default()
},
Rect::new(0., 0., 1600., 1000.),
&[],
PixelGrid::new(1.5).unwrap(),
)
.unwrap();
assert!(scene.distance([800., 20.]) < 0. && scene.distance([800., 500.]) > 0.);
assert!(scene.contains([800., 20.]) && !scene.contains([2., 500.]));
assert!(scene.damage.iter().map(|r| r.width * r.height).sum::<f64>() < 1600. * 1000. / 5.);
}
#[test]
fn primitive_packets_reject_malformed_geometry() {
let mut packet = scene(Style::Floating).packets()[0];
for (index, value) in [(0, f32::NAN), (2, 0.), (4, -1.), (8, 2.5), (8, 6.), (9, 4.)] {
let previous = packet[index];
packet[index] = value;
assert!(Primitive::from_packet(packet).is_err());
packet[index] = previous;
}
}
#[test]
fn physical_input_runs_exclude_corners_and_coalesce() {
let scene = scene(Style::Floating);
let rects = scene.input_rectangles(1.25);
assert!(rects.len() < 3 * 45);
assert!(
!rects
.iter()
.any(|r| r[0] <= 140 && 140 < r[0] + r[2] && r[1] <= 22 && 22 < r[1] + r[3])
);
}
#[test]
fn attached_styles_mirror_the_whole_outline_at_the_bottom() {
for style in [Style::Notch, Style::Frame, Style::CornerWrap] {
let bounds = Rect::new(0., 0., 800., if style == Style::Notch { 36. } else { 600. });
let grid = PixelGrid::new(1.).unwrap();
let top = derive(
Parameters {
style,
..Default::default()
},
bounds,
&groups(),
grid,
)
.unwrap();
let bottom = derive(
Parameters {
style,
edge: Edge::Bottom,
..Default::default()
},
bounds,
&groups(),
grid,
)
.unwrap();
for x in (0..800).step_by(13) {
for y in (0..bounds.height as usize).step_by(11) {
let a = top.distance([x as f64 + 0.5, y as f64 + 0.5]);
let b = bottom.distance([x as f64 + 0.5, bounds.height - y as f64 - 0.5]);
assert!((a - b).abs() < 1e-6, "{style:?}: {x},{y}: {a} != {b}");
}
}
}
}
}
+379
View File
@@ -0,0 +1,379 @@
use crate::{CornerRadii, Edge};
use ferese_layout::Rect;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Style {
Floating,
Capsules,
Notch,
#[default]
Flat,
Frame,
CornerWrap,
Fused,
Morph,
Adaptive,
WindowHug,
}
impl Style {
/// Styles with a complete shell surface contract.
pub const AVAILABLE: [Self; 7] = [
Self::Floating,
Self::Capsules,
Self::Notch,
Self::Flat,
Self::Fused,
Self::Adaptive,
Self::Morph,
];
pub const ALL: [Self; 10] = [
Self::Floating,
Self::Capsules,
Self::Notch,
Self::Flat,
Self::Adaptive,
Self::Morph,
Self::Fused,
Self::Frame,
Self::CornerWrap,
Self::WindowHug,
];
pub const fn key(self) -> &'static str {
match self {
Self::Floating => "floating",
Self::Capsules => "capsules",
Self::Notch => "notch",
Self::Flat => "flat",
Self::Frame => "frame",
Self::CornerWrap => "corner-wrap",
Self::Fused => "fused",
Self::Morph => "morph",
Self::Adaptive => "adaptive",
Self::WindowHug => "window-hug",
}
}
pub const fn label(self) -> &'static str {
match self {
Self::Floating => "Floating",
Self::Capsules => "Capsules",
Self::Notch => "Notch",
Self::Flat => "Edge to edge",
Self::Frame => "Frame",
Self::CornerWrap => "Corner wrap",
Self::Fused => "Fused islands",
Self::Morph => "Morph",
Self::Adaptive => "Adaptive",
Self::WindowHug => "Window hug",
}
}
pub const fn connected(self) -> bool {
matches!(self, Self::Capsules | Self::Notch)
}
pub const fn detached(self) -> bool {
matches!(
self,
Self::Floating | Self::Capsules | Self::Fused | Self::Morph | Self::Adaptive | Self::WindowHug
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ZonePosition {
Start,
Center,
End,
}
/// Clockwise from the left corner on the anchored edge. The same values are
/// used for top and bottom panels; only conversion to screen space differs.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AnchoredRadii(pub [f32; 4]);
impl AnchoredRadii {
pub fn screen_space(self, edge: Edge) -> CornerRadii {
let [flat_left, flat_right, free_right, free_left] = self.0;
CornerRadii(match edge {
Edge::Top => [flat_left, flat_right, free_right, free_left],
Edge::Bottom => [free_left, free_right, flat_right, flat_left],
})
}
/// Membership refers to the occupied groups after availability and
/// overflow have been resolved, never to the stored group count.
pub fn joined(mut self, index: usize, count: usize) -> Result<Self, &'static str> {
if index >= count {
return Err("a joined group must belong to a nonempty zone");
}
if index > 0 {
self.0[0] = 0.;
self.0[3] = 0.;
}
if index + 1 < count {
self.0[1] = 0.;
self.0[2] = 0.;
}
Ok(self)
}
}
pub fn effective_radii(
style: Style,
zone: ZonePosition,
edge: Edge,
size: [f32; 2],
index: usize,
count: usize,
) -> Result<CornerRadii, &'static str> {
if !size.into_iter().all(|value| value.is_finite() && value >= 0.) {
return Err("panel dimensions must be finite and nonnegative");
}
if index >= count {
return Err("a group must belong to a nonempty zone");
}
let [width, height] = size;
let base = AnchoredRadii(match style {
Style::Floating | Style::Capsules | Style::Fused | Style::Morph | Style::Adaptive | Style::WindowHug => {
[height / 2.; 4]
}
Style::Notch => match zone {
ZonePosition::Start => [0., 0., 14., 0.],
ZonePosition::Center => [0., 0., 20., 20.],
ZonePosition::End => [0., 0., 0., 14.],
},
Style::Flat | Style::Frame | Style::CornerWrap => [0.; 4],
});
let radii = if matches!(style, Style::Capsules | Style::Notch) {
base.joined(index, count)?
} else {
base
};
Ok(radii.screen_space(edge).normalized(width, height))
}
/// Snap rendered boundaries, without quantizing the stored animation state.
#[derive(Clone, Copy, Debug)]
pub struct PixelGrid {
scale: f64,
}
impl PixelGrid {
pub fn new(scale: f64) -> Result<Self, &'static str> {
if !scale.is_finite() || scale <= 0. {
return Err("output scale must be finite and positive");
}
Ok(Self { scale })
}
pub fn snap(self, position: f64) -> f64 {
(position * self.scale).round() / self.scale
}
pub fn rect(self, bounds: Rect) -> Result<Rect, &'static str> {
if ![bounds.x, bounds.y, bounds.width, bounds.height]
.into_iter()
.all(f64::is_finite)
|| bounds.width < 0.
|| bounds.height < 0.
{
return Err("surface bounds must be finite with nonnegative dimensions");
}
let left = self.snap(bounds.x);
let top = self.snap(bounds.y);
let right = self.snap(bounds.x + bounds.width);
let bottom = self.snap(bounds.y + bounds.height);
if ![left, top, right, bottom].into_iter().all(f64::is_finite) {
return Err("surface bounds exceed the physical pixel grid");
}
Ok(Rect::new(left, top, (right - left).max(0.), (bottom - top).max(0.)))
}
}
/// Exact circular-corner hit testing, including asymmetric radii larger than
/// half the surface height. Decorative fillets and shadows are separate shapes.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RoundedSurface {
bounds: Rect,
radii: CornerRadii,
}
impl RoundedSurface {
pub fn new(bounds: Rect, radii: CornerRadii, grid: PixelGrid) -> Result<Self, &'static str> {
if radii.validate().is_err() {
return Err("surface radii must be finite and nonnegative");
}
let bounds = grid.rect(bounds)?;
if bounds.width > f64::from(f32::MAX) || bounds.height > f64::from(f32::MAX) {
return Err("surface dimensions exceed the radius coordinate range");
}
let radii = radii.normalized(bounds.width as f32, bounds.height as f32);
Ok(Self { bounds, radii })
}
pub fn bounds(self) -> Rect {
self.bounds
}
pub fn radii(self) -> CornerRadii {
self.radii
}
/// Right and bottom boundaries are excluded so a shared seam belongs to
/// exactly one surface, rather than both neighbors.
pub fn contains(self, point: [f64; 2]) -> bool {
let [x, y] = [point[0] - self.bounds.x, point[1] - self.bounds.y];
let (width, height) = (self.bounds.width, self.bounds.height);
if !(x >= 0. && y >= 0. && x < width && y < height) {
return false;
}
for (radius, dx, dy) in [
(self.radii.0[0], x, y),
(self.radii.0[1], width - x, y),
(self.radii.0[2], width - x, height - y),
(self.radii.0[3], x, height - y),
] {
let radius = f64::from(radius);
if dx < radius && dy < radius && (dx - radius).hypot(dy - radius) > radius {
return false;
}
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hiding_the_last_group_rounds_the_new_end() {
let radius = |index, count| {
effective_radii(
Style::Capsules,
ZonePosition::Start,
Edge::Top,
[100., 36.],
index,
count,
)
.unwrap()
};
assert_eq!(radius(1, 3).0, [0.; 4]);
assert_eq!(radius(1, 2).0, [0., 18., 18., 0.]);
assert_eq!(radius(0, 1).0, [18.; 4]);
assert_eq!(radius(0, 2).0, [18., 0., 0., 18.]);
assert!(effective_radii(Style::Capsules, ZonePosition::Start, Edge::Top, [100., 36.], 0, 0).is_err());
}
#[test]
fn anchor_flip_mirrors_all_styles_and_zone_positions() {
for style in [Style::Floating, Style::Capsules, Style::Notch, Style::Flat] {
for zone in [ZonePosition::Start, ZonePosition::Center, ZonePosition::End] {
for index in 0..3 {
let top = effective_radii(style, zone, Edge::Top, [100., 28.], index, 3)
.unwrap()
.0;
let bottom = effective_radii(style, zone, Edge::Bottom, [100., 28.], index, 3)
.unwrap()
.0;
assert_eq!(bottom, [top[3], top[2], top[1], top[0]]);
}
}
}
}
#[test]
fn asymmetric_corners_preserve_notch_shape_and_scale_proportionally() {
let notch = effective_radii(Style::Notch, ZonePosition::Center, Edge::Top, [100., 28.], 0, 1).unwrap();
assert_eq!(notch.0, [0., 0., 20., 20.]);
assert_eq!(notch.normalized(30., 28.).0, [0., 0., 15., 15.]);
assert_eq!(
CornerRadii([10., 20., 40., 30.]).normalized(35., 25.).0,
[25. / 6., 25. / 3., 50. / 3., 12.5]
);
assert_eq!(CornerRadii::uniform(f32::MAX).normalized(40., 28.).0, [14.; 4]);
assert_eq!(notch.normalized(0., 28.).0, [0.; 4]);
}
#[test]
fn shared_boundaries_snap_without_seams_at_fractional_scales() {
for scale in [1., 1.25, 1.5, 2.] {
let grid = PixelGrid::new(scale).unwrap();
for origin in [-1800.3, -0.4, 0., 23.3] {
let left = grid.rect(Rect::new(origin, 0., 33.3, 36.)).unwrap();
let right = grid.rect(Rect::new(origin + 33.3, 0., 42.7, 36.)).unwrap();
assert!((left.x + left.width - right.x).abs() < 1e-9);
for boundary in [left.x, left.x + left.width, right.x + right.width] {
assert!((boundary * scale - (boundary * scale).round()).abs() < 1e-9);
}
}
}
}
#[test]
fn floating_gaps_and_round_corners_do_not_capture_input() {
let grid = PixelGrid::new(1.25).unwrap();
let left = RoundedSurface::new(Rect::new(8., 8., 100., 36.), CornerRadii::uniform(18.), grid).unwrap();
let right = RoundedSurface::new(Rect::new(116., 8., 100., 36.), CornerRadii::uniform(18.), grid).unwrap();
for point in [[0., 0.], [8., 8.], [112., 26.], [216., 26.], [f64::NAN, 26.]] {
assert!(!left.contains(point) && !right.contains(point), "{point:?}");
}
assert!(left.contains([30., 26.]));
assert!(right.contains([150., 26.]));
}
#[test]
fn asymmetric_hit_testing_matches_a_vertically_mirrored_surface() {
let grid = PixelGrid::new(1.).unwrap();
let bounds = Rect::new(0., 0., 100., 28.);
let top = RoundedSurface::new(bounds, AnchoredRadii([0., 0., 20., 20.]).screen_space(Edge::Top), grid).unwrap();
let bottom = RoundedSurface::new(
bounds,
AnchoredRadii([0., 0., 20., 20.]).screen_space(Edge::Bottom),
grid,
)
.unwrap();
assert!(top.contains([1., 1.]));
assert!(!top.contains([1., 27.]));
for x in 0..100 {
for y in 0..28 {
assert_eq!(
top.contains([x as f64 + 0.5, y as f64 + 0.5]),
bottom.contains([x as f64 + 0.5, 27.5 - y as f64])
);
}
}
}
#[test]
fn a_connected_seam_has_exactly_one_input_owner() {
let grid = PixelGrid::new(1.5).unwrap();
let left = RoundedSurface::new(Rect::new(0., 0., 30., 36.), CornerRadii([18., 0., 0., 18.]), grid).unwrap();
let right = RoundedSurface::new(Rect::new(30., 0., 40., 36.), CornerRadii([0., 18., 18., 0.]), grid).unwrap();
for y in [0., 18., 35.9] {
assert!(!left.contains([30., y]));
assert!(right.contains([30., y]));
}
}
#[test]
fn invalid_runtime_geometry_is_rejected() {
for scale in [0., -1., f64::NAN, f64::INFINITY] {
assert!(PixelGrid::new(scale).is_err());
}
let grid = PixelGrid::new(1.).unwrap();
for bounds in [
Rect::new(0., 0., -1., 28.),
Rect::new(f64::NAN, 0., 10., 28.),
Rect::new(f64::MAX, 0., f64::MAX, 28.),
] {
assert!(grid.rect(bounds).is_err());
}
assert!(RoundedSurface::new(Rect::new(0., 0., 10., 28.), CornerRadii::uniform(-1.), grid).is_err());
assert!(effective_radii(Style::Floating, ZonePosition::Start, Edge::Top, [100., f32::NAN], 0, 1).is_err());
}
}