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

+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}");
}
}
}
}
}