Replace glass effects with plain backdrop blur and refine shell layout

This commit is contained in:
Samuel Onoja committed 2026-09-26 09:12:51 +01:00
1 parent af32c29a49
commit 64c95f0b97
13 files changed
+823 -1036

No files matched your search

+1 -1
View File
@@ -86,7 +86,7 @@ impl Default for ShellTheme {
}
impl ShellTheme {
// The bar has its own foreground tokens, matched to its dark glass surface.
// The bar has its own foreground tokens, matched to its surface.
pub(crate) fn for_bar(mut self) -> Self {
self.text_primary = self.bar_text_primary;
self.text_muted = self.bar_text_muted;
+84 -19
View File
@@ -22,8 +22,7 @@ use cosmic::iced::platform_specific::{
shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer},
};
use cosmic::iced::{
Background, Border, Color, ContentFit, Event, Length, Limits, Shadow, Subscription, Vector,
window,
Background, Border, Color, ContentFit, Event, Length, Limits, Subscription, window,
};
use cosmic::theme;
use cosmic::widget::{button, container, icon, image, row};
@@ -218,6 +217,9 @@ impl cosmic::Application for FereseShell {
anchor: Anchor::TOP | Anchor::RIGHT | Anchor::BOTTOM | Anchor::LEFT,
output: IcedOutput::Active,
namespace: "ferese-shell-wallpaper".to_owned(),
// Wallpaper covers the full output, including beneath the bar
// and its floating margins. Zero would use the remaining workspace.
exclusive_zone: -1,
size: Some((None, None)),
size_limits: Limits::NONE,
..Default::default()
@@ -294,6 +296,14 @@ impl cosmic::Application for FereseShell {
}
fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
if let Some(menu) = &self.menu
&& let Some(effects) = &menu.effects
{
let regions = menu.regions.lock().unwrap().clone();
if let Err(error) = effects.set_regions(&regions) {
eprintln!("ferese-shell: could not update card materials: {error}");
}
}
match message {
Message::WallpaperLoaded(result) => {
match result {
@@ -312,10 +322,7 @@ impl cosmic::Application for FereseShell {
&& menu.id == id
&& menu.effects.is_none()
{
match EffectsBinding::attach_role(
&surface,
Some(ferese_surface_effects_v1::Role::Popover),
) {
match EffectsBinding::attach_role(&surface, menu.kind.material_role()) {
Ok(binding) => menu.effects = Some(binding),
Err(error) => {
eprintln!("ferese-shell: popover material unavailable: {error}")
@@ -467,7 +474,7 @@ impl FereseShell {
if menu.effects.is_none() {
match EffectsBinding::attach_role(
&surface,
Some(ferese_surface_effects_v1::Role::Popover),
menu.kind.material_role(),
) {
Ok(binding) => menu.effects = Some(binding),
Err(error) => eprintln!(
@@ -481,7 +488,7 @@ impl FereseShell {
if menu.effects.is_none() {
match EffectsBinding::attach_role(
&surface,
Some(ferese_surface_effects_v1::Role::Popover),
menu.kind.material_role(),
) {
Ok(binding) => menu.effects = Some(binding),
Err(error) => eprintln!(
@@ -513,10 +520,7 @@ impl FereseShell {
))) if self.menu.as_ref().is_some_and(|menu| menu.id == frame_id) => {
if let Some(menu) = &mut self.menu {
if menu.effects.is_none() {
match EffectsBinding::attach_role(
&surface,
Some(ferese_surface_effects_v1::Role::Popover),
) {
match EffectsBinding::attach_role(&surface, menu.kind.material_role()) {
Ok(binding) => menu.effects = Some(binding),
Err(error) => {
eprintln!("ferese-shell: popover material unavailable: {error}")
@@ -634,10 +638,13 @@ impl FereseShell {
});
windows = windows.push(
button::custom(
text(label)
.size(bar.text_size)
.wrapping(cosmic::iced::core::text::Wrapping::None)
.class(theme::Text::Color(foreground)),
container(
text(label)
.size(bar.text_size)
.wrapping(cosmic::iced::core::text::Wrapping::None)
.class(theme::Text::Color(foreground)),
)
.center_y(bar.control_height),
)
.padding([0, 8])
.height(bar.control_height)
@@ -664,7 +671,7 @@ impl FereseShell {
)
.padding([0, 6]);
let right = row![self.view_status_bar(), clock]
.spacing(10)
.spacing(6)
.align_y(cosmic::iced::Alignment::Center);
let content = row![
container(left).width(Length::Fill),
@@ -860,7 +867,23 @@ fn display_app_name(window: &control::WindowSnapshot) -> String {
}
fn current_time() -> String {
Zoned::now().strftime("%a · %H:%M").to_string()
format_bar_time(&Zoned::now())
}
fn format_bar_time(now: &Zoned) -> String {
let months = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sept", "oct", "nov", "dec",
];
let hour = now.hour();
let hour12 = if hour % 12 == 0 { 12 } else { hour % 12 };
format!(
"{} {}, {}:{:02} {}",
now.day(),
months[(now.month() - 1) as usize],
hour12,
now.minute(),
if hour < 12 { "am" } else { "pm" }
)
}
fn compact_window_label(window: &crate::control::WindowSnapshot) -> String {
@@ -963,6 +986,7 @@ struct EffectsBinding {
_manager: FereseEffectsManagerV1,
surface: FereseSurfaceEffectsV1,
_queue: EventQueue<EffectsState>,
regions: std::cell::RefCell<Option<Vec<[i32; 5]>>>,
}
impl EffectsBinding {
@@ -987,7 +1011,7 @@ impl EffectsBinding {
let connection = Connection::from_backend(backend);
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
let qh = queue.handle();
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=1, ())?;
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=2, ())?;
let effects = manager.get_surface_effects(surface, &qh, ());
if let Some(role) = role {
@@ -1002,9 +1026,31 @@ impl EffectsBinding {
_manager: manager,
surface: effects,
_queue: queue,
regions: Default::default(),
})
}
fn set_regions(&self, regions: &[[i32; 5]]) -> Result<(), Box<dyn std::error::Error>> {
if self.surface.version() < 2 || self.regions.borrow().as_deref() == Some(regions) {
return Ok(());
}
self.surface.set_regions(
regions
.iter()
.flat_map(|r| r.iter().flat_map(|v| v.to_ne_bytes()))
.collect(),
);
if regions.is_empty() {
self.surface.clear_role();
} else {
self.surface
.set_role(ferese_surface_effects_v1::Role::Popover);
}
self.connection.flush()?;
*self.regions.borrow_mut() = Some(regions.to_vec());
Ok(())
}
fn set_visible(&self, visible: bool) -> Result<(), Box<dyn std::error::Error>> {
if visible {
self.surface
@@ -1037,6 +1083,25 @@ delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1);
#[cfg(test)]
mod tests {
#[test]
fn clock_uses_lowercase_date_and_twelve_hour_time() {
for (stamp, expected) in [
(
"2026-09-28T21:32:00+01:00[Africa/Lagos]",
"28 sept, 9:32 pm",
),
(
"2026-09-28T00:05:00+01:00[Africa/Lagos]",
"28 sept, 12:05 am",
),
(
"2026-09-28T12:00:00+01:00[Africa/Lagos]",
"28 sept, 12:00 pm",
),
] {
assert_eq!(super::format_bar_time(&stamp.parse().unwrap()), expected);
}
}
use super::*;
use crate::control::{OutputSnapshot, WindowSnapshot};
+91 -2
View File
@@ -4,12 +4,23 @@ use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, re
use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector};
use cosmic::{Element, Theme};
pub fn animated<'a, M: 'a>(content: Element<'a, M>, progress: f32) -> Element<'a, M> {
Element::new(Motion { content, progress })
pub type Regions = std::sync::Arc<std::sync::Mutex<Vec<[i32; 5]>>>;
pub fn animated<'a, M: 'a>(
content: Element<'a, M>,
progress: f32,
regions: Regions,
) -> Element<'a, M> {
Element::new(Motion {
content,
progress,
regions,
})
}
struct Motion<'a, M> {
content: Element<'a, M>,
progress: f32,
regions: Regions,
}
impl<M> Motion<'_, M> {
fn translation(&self) -> Vector {
@@ -56,6 +67,35 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
shell: &mut Shell<'_, M>,
viewport: &Rectangle,
) {
let mut collector = CollectRegions(Vec::new());
self.content
.as_widget_mut()
.operate(tree, layout, renderer, &mut collector);
if collector.0.is_empty() {
// A single-content popover (e.g. battery) has one inner card, not
// a full-surface rectangle including the outer transparent padding.
if let Some(content) = layout.children().next() {
collector.0.push(content.bounds());
}
}
let origin = layout.bounds().position();
let translation = self.translation();
let regions = collector
.0
.into_iter()
.take(32)
.filter(|r| r.width > 0.0 && r.height > 0.0)
.map(|r| {
[
(r.x - origin.x + translation.x).round() as i32,
(r.y - origin.y + translation.y).round() as i32,
r.width.round() as i32,
r.height.round() as i32,
11,
]
})
.collect();
*self.regions.lock().unwrap() = regions;
let cursor = self.cursor(cursor);
self.content.as_widget_mut().update(
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
@@ -113,3 +153,52 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
.operate(tree, layout, renderer, operation);
}
}
struct CollectRegions(Vec<Rectangle>);
impl widget::Operation for CollectRegions {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("ferese-blur-card")) {
let contains = |outer: &Rectangle, inner: &Rectangle| {
outer.x <= inner.x
&& outer.y <= inner.y
&& outer.x + outer.width >= inner.x + inner.width
&& outer.y + outer.height >= inner.y + inner.height
};
// Nested controls belong to their enclosing card's single blur pass.
if self.0.iter().any(|outer| contains(outer, &bounds)) {
return;
}
self.0.retain(|inner| !contains(&bounds, inner));
self.0.push(bounds);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn material_regions_include_cards_but_not_the_parent_or_gaps() {
let mut collector = CollectRegions(Vec::new());
let id = widget::Id::new("ferese-blur-card");
widget::Operation::container(
&mut collector,
None,
Rectangle::new((0.0, 0.0).into(), (320.0, 200.0).into()),
);
let first = Rectangle::new((12.0, 12.0).into(), (140.0, 80.0).into());
let second = Rectangle::new((160.0, 12.0).into(), (140.0, 80.0).into());
widget::Operation::container(&mut collector, Some(&id), first);
widget::Operation::container(&mut collector, Some(&id), second);
assert_eq!(collector.0, vec![first, second]);
widget::Operation::container(
&mut collector,
Some(&id),
Rectangle::new((20.0, 20.0).into(), (40.0, 40.0).into()),
);
assert_eq!(collector.0, vec![first, second]);
}
}
+28 -56
View File
@@ -17,15 +17,9 @@ pub enum Menu {
System,
}
impl Menu {
fn title(self) -> &'static str {
match self {
Self::Network => "Wi-Fi",
Self::Bluetooth => "Bluetooth",
Self::Audio => "Volume",
Self::Battery => "Battery",
Self::Notifications => "Notifications",
Self::System => "Control Center",
}
pub(super) fn material_role(self) -> Option<ferese_surface_effects_v1::Role> {
// Popovers contain independent cards, with no shared material backing.
None
}
fn available(self, status: &Snapshot) -> bool {
match self {
@@ -45,6 +39,7 @@ pub struct OpenMenu {
opened: Instant,
pub confirm: Option<Action>,
pub effects: Option<EffectsBinding>,
pub regions: super::motion::Regions,
}
impl OpenMenu {
pub fn progress(&self) -> f32 {
@@ -77,6 +72,7 @@ impl FereseShell {
opened: Instant::now(),
confirm: None,
effects: None,
regions: Default::default(),
});
super::EFFECT_FRAME_PENDING.store(true, std::sync::atomic::Ordering::Relaxed);
self.status_error = None;
@@ -150,7 +146,7 @@ impl FereseShell {
pub fn view_status_bar(&self) -> Element<'_, cosmic::Action<Message>> {
let theme = self.config.theme.for_bar();
let metrics = BarMetrics::from(theme);
let mut controls = row::with_capacity(6).spacing(3).align_y(Alignment::Center);
let mut controls = row::with_capacity(6).spacing(1).align_y(Alignment::Center);
for kind in [
Menu::System,
Menu::Network,
@@ -210,7 +206,7 @@ impl FereseShell {
.name(status_label(kind, &self.status))
.padding([
0.0,
((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(7.0),
((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0),
])
.height(metrics.height)
.class(button_style(foreground, selected, 1.0))
@@ -238,15 +234,7 @@ impl FereseShell {
let p = menu.progress();
let primary = color_with_opacity(theme.text_primary, p);
let muted = color_with_opacity(theme.text_muted, p);
// The bar has already established whether this connection supports
// Ferese materials. Keep a glass popup's client buffer transparent
// from its FIRST frame, not only after its own focus/effects event.
// Otherwise the initial opaque fallback can be captured beneath glass
// while popup registration and client repaint happen asynchronously.
let compositor_material = self.effects.is_some();
let mut rows = column::with_capacity(12).spacing(8).width(Length::Fill);
let mut heading =
row![text(menu.kind.title()).size(16).width(Length::Fill)].align_y(Alignment::Center);
let radio = match menu.kind {
Menu::Network => self
.status
@@ -261,14 +249,13 @@ impl FereseShell {
_ => None,
};
if let Some((enabled, action)) = radio {
heading = heading.push(menu_button(
rows = rows.push(menu_button(
if enabled { "Turn off" } else { "Turn on" },
Message::Control(action),
primary,
p,
));
}
rows = rows.push(heading);
if let Some(action) = &menu.confirm {
let title = match action {
Action::Poweroff => "Power off this computer?",
@@ -471,10 +458,8 @@ impl FereseShell {
}
if combined || menu.kind == Menu::Notifications {
if let Some(n) = &self.status.notifications {
rows = rows.push(toggle_row(
"Do Not Disturb",
n.dnd,
Action::Dnd(!n.dnd),
rows = rows.push(control_card(
toggle_row("Do Not Disturb", n.dnd, Action::Dnd(!n.dnd), primary, p),
primary,
p,
));
@@ -581,7 +566,7 @@ impl FereseShell {
p,
));
}
rows = rows.push(actions.wrap());
rows = rows.push(control_card(actions.wrap().into(), primary, p));
}
if !menu.kind.available(&self.status) {
rows = rows.push(
@@ -601,39 +586,18 @@ impl FereseShell {
.width(WIDTH)
.padding(12)
.class(theme::Container::custom(move |_| container::Style {
background: if compositor_material {
None
} else {
Some(Background::Color(color_with_opacity(
theme.surface_popover,
p,
)))
},
background: None,
text_color: Some(primary),
icon_color: Some(primary),
border: Border {
radius: theme.material_radius.into(),
..Default::default()
},
// Iced's shadow fills the silhouette behind the quad. With
// transparent client content that fill remains visible over
// the compositor's glass, creating a second dark rectangle.
shadow: if compositor_material {
Shadow::default()
} else {
Shadow {
color: color_with_opacity(theme.shadow, p * theme.shadow_opacity.min(0.18)),
offset: Vector::new(0.0, 2.0),
blur_radius: 6.0,
}
},
..Default::default()
}));
// Keep the client panel and compositor material on the same surface
// bounds. A second outer padding box exposes a glass ring around the
// fallback fill and makes the first committed frame visibly jump.
// Keep the outer surface transparent; only inner cards paint a background.
cosmic::widget::autosize::autosize(
super::motion::animated(panel.into(), p),
super::motion::animated(panel.into(), p, menu.regions.clone()),
cosmic::iced::advanced::widget::Id::new("ferese-status-menu"),
)
.limits(
@@ -684,6 +648,7 @@ fn status_summary<'a>(
.spacing(12)
.align_y(Alignment::Center),
)
.id("ferese-blur-card")
.padding([12, 0])
.into()
}
@@ -730,6 +695,7 @@ fn control_card<'a>(
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
container(content)
.id("ferese-blur-card")
.padding(10)
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
@@ -798,16 +764,21 @@ fn connection_control<'a>(
// giving it a misleading clickable/disabled-button appearance.
let Some(action) = action else {
return container(content)
.id("ferese-blur-card")
.width(Length::FillPortion(1))
.padding(4)
.into();
};
button::custom(content)
.width(Length::FillPortion(1))
.padding(4)
.class(button_style(foreground, false, opacity))
.on_press(cosmic::Action::App(Message::Control(action)))
.into()
container(
button::custom(content)
.width(Length::FillPortion(1))
.padding(4)
.class(button_style(foreground, false, opacity))
.on_press(cosmic::Action::App(Message::Control(action))),
)
.id("ferese-blur-card")
.width(Length::FillPortion(1))
.into()
}
fn menu_button<'a>(
@@ -1056,6 +1027,7 @@ mod tests {
opened: past,
confirm: None,
effects: None,
regions: Default::default(),
};
assert_eq!(menu.progress(), 1.0);
assert!(!menu.animating());