Match shell squircle outlines with compositor masks

Use shared corner geometry for shell controls, window clipping, materials and blur masks. Preserve circular pills and controls where appropriate, track contour changes in damage, and use tagged ferese-shape and libcosmic dependencies.
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-04 07:39:54 +01:00
1 parent 03a265b97f
commit d75c93db85
31 files changed
+453 -80

No files matched your search

+5
View File
@@ -2,6 +2,7 @@ use super::{
Background, BarMetrics, Border, Color, Element, FereseShell, Length, Message, ShellSnapshot, ShellTheme, alignment,
bar_icon, button, color, color_with_opacity, container, control, motion, row, status_ui, text, theme, window,
};
use cosmic::iced::border::Shape as BorderShape;
impl FereseShell {
pub(super) fn output_for_bar(&self, id: window::Id) -> Option<&control::OutputSnapshot> {
@@ -327,9 +328,11 @@ pub(super) fn workspace_selector_style(
None
},
border: Border {
shape: BorderShape::Continuous,
color: color_with_opacity(shell_theme.accent, 0.55),
width: if active_elsewhere { 1.0 } else { 0.0 },
radius: shell_theme.material_radius.min(14.0).into(),
..Default::default()
},
..Default::default()
}
@@ -350,9 +353,11 @@ pub(super) fn bar_group_style(theme: ShellTheme) -> container::Style {
container::Style {
background: Some(Background::Color(color_with_opacity(theme.border, 0.25))),
border: Border {
shape: BorderShape::Continuous,
color: color(theme.border),
width: 1.0,
radius: theme.material_radius.min(16.0).into(),
..Default::default()
},
..Default::default()
}
@@ -4,6 +4,7 @@ use super::{
configured_font, container, destroy_layer_surface, format_bar_time, motion, note_store, row, set_anchor,
set_margin, stacked_clock_digits, text, theme, window,
};
use cosmic::iced::border::Shape as BorderShape;
impl FereseShell {
pub(super) fn flush_note_changes(&mut self) -> Task<Message> {
@@ -398,6 +399,7 @@ impl FereseShell {
.class(theme::Container::custom(move |_| container::Style {
background,
border: Border {
shape: BorderShape::Continuous,
radius: radius.into(),
..Default::default()
},
@@ -613,6 +615,7 @@ impl FereseShell {
.class(theme::Container::custom(move |_| container::Style {
background,
border: Border {
shape: BorderShape::Continuous,
radius: radius.into(),
..Default::default()
},
+122 -11
View File
@@ -1,3 +1,4 @@
use cosmic::iced::border::Shape as BorderShape;
use cosmic::iced::widget::scrollable::{Direction, Scrollbar};
use cosmic::widget::{column, scrollable};
@@ -56,6 +57,23 @@ fn card_button<'a>(
.into()
}
fn card_surface<'a>(
content: impl Into<Element<'a, cosmic::Action<Message>>>,
history: bool,
style: container::Style,
) -> Element<'a, cosmic::Action<Message>> {
container(content)
.id(if history {
"ferese-history-card"
} else {
"ferese-blur-card"
})
.width(Length::Fill)
.clip_to_border(true)
.class(theme::Container::custom(move |_| style))
.into()
}
fn icon_control<'a>(
source: &'static [u8],
label: &'static str,
@@ -82,6 +100,7 @@ fn icon_control<'a>(
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(background)),
border: Border {
shape: BorderShape::Continuous,
radius: motion::radius(12.0).into(),
..Default::default()
},
@@ -287,6 +306,7 @@ impl FereseShell {
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(well)),
border: Border {
shape: BorderShape::Continuous,
radius: motion::radius(15.0).into(),
..Default::default()
},
@@ -313,6 +333,7 @@ impl FereseShell {
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(badge_fill.scale_alpha(opacity))),
border: Border {
shape: BorderShape::Continuous,
radius: motion::radius(8.0).into(),
..Default::default()
},
@@ -448,15 +469,10 @@ impl FereseShell {
.push(actions);
}
}
let card = container(face)
.id(if history {
"ferese-history-card"
} else {
"ferese-blur-card"
})
.width(Length::Fill)
.clip(true)
.class(theme::Container::custom(move |_| container::Style {
let card = card_surface(
face,
history,
container::Style {
background: if history {
Some(Background::Color(Color {
a: 0.045 * opacity,
@@ -466,14 +482,16 @@ impl FereseShell {
(!compositor_material).then_some(Background::Color(base))
},
border: Border {
shape: BorderShape::Continuous,
color: color_with_opacity(palette.text_muted, 0.18 * opacity),
width: if history { 0.0 } else { 1.0 },
radius: palette.material_radius.into(),
..Default::default()
},
snap: true,
..Default::default()
}));
let card: Element<'_, cosmic::Action<Message>> = card.into();
},
);
let card: Element<'_, cosmic::Action<Message>> = if count > 1 {
// With translucent materials, complete backing rectangles show
// through the face and multiply its tint. Paint only the exposed
@@ -488,6 +506,7 @@ impl FereseShell {
..foreground
})),
border: Border {
shape: BorderShape::Continuous,
radius: [0.0, 0.0, motion::radius(6.0), motion::radius(6.0)].into(),
..Default::default()
},
@@ -648,6 +667,7 @@ impl FereseShell {
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(base)),
border: Border {
shape: BorderShape::Continuous,
radius: palette.material_radius.into(),
..Default::default()
},
@@ -665,6 +685,7 @@ impl FereseShell {
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(well)),
border: Border {
shape: BorderShape::Continuous,
radius: motion::radius(28.0).into(),
..Default::default()
},
@@ -766,9 +787,11 @@ impl FereseShell {
.class(theme::Container::custom(move |_| container::Style {
background: (!compositor_material).then_some(Background::Color(color(palette.surface_popover))),
border: Border {
shape: BorderShape::Continuous,
color: color_with_opacity(palette.text_muted, 0.18 * opacity),
width: 1.0,
radius: palette.material_radius.into(),
..Default::default()
},
snap: true,
..Default::default()
@@ -803,3 +826,91 @@ impl FereseShell {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::iced::advanced::renderer::{Headless, Renderer as _};
use cosmic::iced::advanced::{Layout, layout, mouse, widget::Tree};
use cosmic::iced::{Font, Pixels, Rectangle, Size};
#[test]
fn notification_action_hover_stays_inside_card_corners() {
check_action_hover("tiny-skia");
}
#[test]
#[ignore = "requires a GPU or software Vulkan adapter"]
fn gpu_notification_action_hover_stays_inside_card_corners() {
check_action_hover("wgpu");
}
fn check_action_hover(backend: &str) {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let mut renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.0),
Some(backend),
))
.unwrap();
let theme = cosmic::Theme::dark();
let bounds = Rectangle::with_size(Size::new(368.0, 114.0));
for history in [false, true] {
for scale in [1.0, 1.25, 1.5] {
let action = card_button(
cosmic::widget::Space::new().height(38),
Message::InvokeNotification(1, "action".into()),
Color::WHITE,
Color::WHITE,
false,
"Action".into(),
);
let face = column([cosmic::widget::Space::new().height(76).into(), action]);
let mut card = card_surface(
face,
history,
container::Style {
border: Border {
shape: BorderShape::Continuous,
radius: 14.0.into(),
width: if history { 0.0 } else { 1.0 },
..Default::default()
},
snap: true,
..Default::default()
},
);
let mut tree = Tree::new(card.as_widget());
let node =
card.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
renderer.reset(bounds);
card.as_widget().draw(
&tree,
&mut renderer,
&theme,
&Default::default(),
Layout::new(&node),
mouse::Cursor::Available((80.0, 100.0).into()),
&bounds,
);
let size = Size::new((bounds.width * scale) as u32, (bounds.height * scale) as u32);
let pixels = Headless::screenshot(&mut renderer, size, scale, Color::TRANSPARENT);
let alpha = |x: u32, y: u32| pixels[((y * size.width + x) * 4 + 3) as usize];
assert_eq!(
alpha(0, size.height - 1),
0,
"{backend}, scale={scale}, history={history}"
);
assert!(
alpha(size.width / 2, size.height - 4) > 200,
"the action hover must still render"
);
}
}
}
}
+2
View File
@@ -1,3 +1,4 @@
use cosmic::iced::border::Shape as BorderShape;
use cosmic::iced::{Alignment, Background, Border, Rectangle, alignment};
use cosmic::widget::{button, container, row};
use cosmic::{Element, theme};
@@ -82,6 +83,7 @@ impl FereseShell {
move |_| container::Style {
background: Some(Background::Color(foreground)),
border: Border {
shape: BorderShape::Continuous,
radius: motion::radius(2.0).into(),
..Default::default()
},
@@ -1,3 +1,4 @@
use cosmic::iced::border::Shape as BorderShape;
use cosmic::iced::{Alignment, Background, Border, Color, Length};
use cosmic::widget::{button, column, container, row, slider};
use cosmic::{Element, theme};
@@ -32,6 +33,7 @@ pub(super) fn status_summary<'a>(
..badge_color
})),
border: Border {
shape: BorderShape::Continuous,
radius: motion::radius(16.0).into(),
..Default::default()
},
@@ -79,6 +81,7 @@ pub(super) fn level_meter(value: u8, foreground: Color, opacity: f32) -> Element
}
})),
border: Border {
shape: BorderShape::Continuous,
radius: motion::radius(2.5).into(),
..Default::default()
},
+3
View File
@@ -11,6 +11,7 @@ mod system;
use bar::status_icon;
use controls::{control_card, menu_button, toggle_row};
use cosmic::iced::border::Shape as BorderShape;
use cosmic::widget::column;
use status::{Action, Snapshot};
@@ -242,9 +243,11 @@ impl FereseShell {
text_color: Some(primary),
icon_color: Some(primary),
border: Border {
shape: BorderShape::Continuous,
color: color_with_opacity(theme.border, p),
width: 1.0,
radius: theme.material_radius.into(),
..Default::default()
},
snap: true,
..Default::default()
+3
View File
@@ -1,3 +1,4 @@
use cosmic::iced::border::Shape as BorderShape;
use cosmic::iced::widget::Space;
use cosmic::widget::column;
@@ -620,9 +621,11 @@ impl FereseShell {
text_color: Some(palette.text),
icon_color: Some(palette.text),
border: Border {
shape: BorderShape::Continuous,
color: palette.muted.scale_alpha(0.16),
width: 1.0,
radius: theme.material_radius.into(),
..Default::default()
},
snap: true,
..Default::default()