Replace glass effects with plain backdrop blur and refine shell layout
This commit is contained in:
1 parent
af32c29a49
commit
64c95f0b97
13 files changed
+823
-1036
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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(®ions) {
|
||||
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};
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user