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, ]; #[must_use] 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", } } #[must_use] 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", } } #[must_use] pub const fn connected(self) -> bool { matches!(self, Self::Capsules | Self::Notch) } #[must_use] 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 { #[must_use] 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 { 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 { 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 try_new(scale: f64) -> Result { if !scale.is_finite() || scale <= 0. { return Err("output scale must be finite and positive"); } Ok(Self { scale }) } #[must_use] pub fn snap(self, position: f64) -> f64 { (position * self.scale).round() / self.scale } pub fn rect(self, bounds: Rect) -> Result { 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 try_new( bounds: Rect, radii: CornerRadii, grid: PixelGrid, ) -> Result { 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 }) } #[must_use] pub fn bounds(self) -> Rect { self.bounds } #[must_use] 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. #[must_use] 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::try_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::try_new(1.25).unwrap(); let left = RoundedSurface::try_new( Rect::new(8., 8., 100., 36.), CornerRadii::uniform(18.), grid, ) .unwrap(); let right = RoundedSurface::try_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::try_new(1.).unwrap(); let bounds = Rect::new(0., 0., 100., 28.); let top = RoundedSurface::try_new( bounds, AnchoredRadii([0., 0., 20., 20.]).screen_space(Edge::Top), grid, ) .unwrap(); let bottom = RoundedSurface::try_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::try_new(1.5).unwrap(); let left = RoundedSurface::try_new( Rect::new(0., 0., 30., 36.), CornerRadii([18., 0., 0., 18.]), grid, ) .unwrap(); let right = RoundedSurface::try_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::try_new(scale).is_err()); } let grid = PixelGrid::try_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::try_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() ); } }