Files
feresaria/shell/ferese-shell/src/notification_ui.rs
T

1225 lines
46 KiB
Rust

use cosmic::iced::border::Shape as BorderShape;
use cosmic::iced::widget::scrollable::{Direction, Scrollbar};
use cosmic::widget::{column, scrollable};
use super::{
Anchor, Background, Border, Color, Duration, EffectsBinding, Element, FereseShell, IcedOutput,
KeyboardInteractivity, Layer, Length, Limits, Message, SctkLayerSurfaceSettings, Task,
accented_icon, alignment, bar_icon, button, color, color_with_opacity, container,
destroy_layer_surface, ferese_surface_effects_v1, icon, motion, notifications, row, set_size,
status, status_ui, theme, window, wl_output,
};
const POPUP_WIDTH: u32 = 368;
const HISTORY_MAX_HEIGHT: f32 = 620.0;
const STACK_PEEK: u32 = 6;
fn stack_depth(count: usize) -> u32 {
count.saturating_sub(1).min(2) as u32
}
fn stacked_card<'a>(
card: Element<'a, cosmic::Action<Message>>,
count: usize,
radius: f32,
foreground: Color,
) -> Element<'a, cosmic::Action<Message>> {
let depth = stack_depth(count);
if depth == 0 {
return card;
}
let mut layers = column([]);
for depth in 0..depth {
// Start at the face's bottom tangent, then tuck each following edge
// inside the previous one. Fixed insets protrude past rounded corners.
let inset = radius + (depth * STACK_PEEK) as f32;
let back = container(
container(
cosmic::iced::widget::Space::new()
.width(Length::Fill)
.height(STACK_PEEK as f32),
)
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(foreground.scale_alpha(0.045))),
border: Border {
shape: BorderShape::Continuous,
radius: [
0.0,
0.0,
radius.min(STACK_PEEK as f32),
radius.min(STACK_PEEK as f32),
]
.into(),
..Default::default()
},
..Default::default()
})),
)
.padding([0.0, inset]);
layers = layers.push(back);
}
// Paint the backing before the clipped face, while keeping its reserved
// space in layout. The face alone publishes the compositor material region.
cosmic::iced::widget::stack([container(card)
.padding(cosmic::iced::Padding {
bottom: (depth * STACK_PEEK) as f32,
..Default::default()
})
.into()])
.push_under(container(layers).align_bottom(Length::Fill))
.into()
}
fn popup_height(notice: &notifications::Notice, count: usize) -> u32 {
let body = if notice.body.is_empty() && count == 1 {
0
} else {
22
};
let actions = if notice.live && notice.actions.iter().any(|(key, _)| key != "default") {
39
} else {
0
};
76 + body + actions + if count > 1 { 4 } else { 0 }
}
fn blend(base: Color, foreground: Color, amount: f32) -> Color {
Color {
r: base.r + (foreground.r - base.r) * amount,
g: base.g + (foreground.g - base.g) * amount,
b: base.b + (foreground.b - base.b) * amount,
a: base.a,
}
}
fn icon_well<'a>(
render: crate::rendering::Rendering,
content: impl Into<Element<'a, cosmic::Action<Message>>>,
foreground: Color,
extent: u16,
) -> Element<'a, cosmic::Action<Message>> {
container(content)
.center_x(extent)
.center_y(extent)
.class(ferese_theme::controls::surface(
foreground.scale_alpha(0.07),
render.radius(f32::from(extent) / 2.0),
))
.into()
}
fn age_label(age: Duration) -> String {
match age.as_secs() {
0..60 => "now".into(),
60..3600 => format!("{}m ago", age.as_secs() / 60),
3600..86400 => format!("{}h ago", age.as_secs() / 3600),
seconds => format!("{}d ago", seconds / 86400),
}
}
fn card_button<'a>(
render: crate::rendering::Rendering,
content: impl Into<Element<'a, cosmic::Action<Message>>>,
message: Message,
foreground: Color,
round: bool,
accessible_name: String,
) -> Element<'a, cosmic::Action<Message>> {
button::custom(content)
.name(accessible_name)
.width(if round { Length::Shrink } else { Length::Fill })
.padding(0)
.on_press(cosmic::Action::App(message))
.class(ferese_theme::controls::notification_button(
foreground,
foreground.scale_alpha(0.08),
if round { render.radius(11.) } else { 0. },
round,
))
.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>(
render: crate::rendering::Rendering,
source: &'static [u8],
label: &'static str,
message: Message,
foreground: Color,
background: Color,
extent: u16,
) -> Element<'a, cosmic::Action<Message>> {
let control = card_button(
render,
container(bar_icon(source, 14, foreground))
.center_x(extent)
.center_y(extent),
message,
foreground,
true,
label.into(),
);
cosmic::widget::tooltip(
control,
render
.text(label)
.size(11)
.class(theme::Text::Color(foreground)),
cosmic::widget::tooltip::Position::Left,
)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(background)),
border: Border {
shape: BorderShape::Continuous,
radius: render.radius(12.0).into(),
..Default::default()
},
text_color: Some(foreground),
..Default::default()
}))
.into()
}
pub(super) struct NotificationSurface {
pub id: window::Id,
output: wl_output::WlOutput,
height: u32,
pub effects: Option<EffectsBinding>,
pub regions: motion::Regions,
}
impl FereseShell {
pub(super) fn notification_history_height_limit(&self) -> f32 {
self.outputs
.iter()
.find(|output| {
Some(output.bar)
== self
.menu
.as_ref()
.map(|menu| menu.anchor.parent)
.or_else(|| self.active_bar())
})
.and_then(|output| output.size)
.map_or(HISTORY_MAX_HEIGHT, |(_, height)| {
let geometry = self
.menu
.as_ref()
.map(|menu| self.panel_for_bar(menu.anchor.parent))
.unwrap_or(&self.config.panels[0])
.resolved_geometry();
(height as f32 - geometry.edge_margin as f32 - geometry.height - 8.0 - 12.0)
.max(1.0)
})
.min(HISTORY_MAX_HEIGHT)
}
pub(super) fn toggle_notification_history(&mut self) -> Task<Message> {
if let Some(parent) = self.active_bar()
&& self.hub_mode(parent).is_some()
{
return if self.hub_mode(parent)
== Some(ferese_panel::morph::Mode::Expanded(
ferese_panel::morph::Content::Notifications,
)) {
self.close_hub(parent)
} else {
self.open_hub(parent, status_ui::Menu::Notifications)
};
}
if self.notifications.history_open {
return self.close_menu();
}
// Toast requests use the focused output without changing popup parent selection.
let Some(parent) = self.active_bar() else {
return Task::none();
};
let panel = &self.config.panels[0];
let width = self
.outputs
.iter()
.find(|entry| entry.bar == parent)
.and_then(|entry| entry.size)
.map_or(POPUP_WIDTH as i32, |size| size.0);
let anchor = status_ui::PopoverAnchor {
parent,
panel: panel.id.clone(),
item: None,
rectangle: cosmic::iced::Rectangle {
x: (width - 48).max(0),
y: 0,
width: 24,
height: self.config.panels[0].resolved_geometry().height.round() as i32,
},
};
self.open_menu(status_ui::Menu::Notifications, anchor)
}
pub(super) fn close_notification_history(&mut self) -> Task<Message> {
self.close_menu()
}
pub(super) fn sync_notification_surface(&mut self) -> Task<Message> {
// Hidden toast surfaces receive no frame callbacks. Their closes have
// no visible transition to finish, so release them immediately.
self.notifications.tick();
if self.notifications.ready {
self.status.notifications = Some(status::Notifications {
count: self.notifications.unread(),
dnd: self.notifications.dnd,
});
}
let count = self.notifications.popup_groups().len();
let wanted = self.config.panels[0].style != ferese_panel::PanelStyle::Morph
&& self.notifications.ready
&& !self.notifications.history_open
&& count > 0;
let selected = self
.outputs
.iter()
.find(|entry| {
self.snapshot.outputs.iter().any(|output| {
output.focused && Some(output.name.as_str()) == entry.name.as_deref()
})
})
.or_else(|| self.outputs.first())
.map(|entry| entry.output.clone());
let mut tasks = Vec::new();
if self
.notification_surface
.as_ref()
.is_some_and(|surface| !wanted || selected.as_ref() != Some(&surface.output))
{
let surface = self
.notification_surface
.take()
.expect("notification teardown selected an existing notification surface");
tasks.push(destroy_layer_surface(surface.id));
}
if !wanted {
return Task::batch(tasks);
}
let Some(output) = selected else {
return Task::batch(tasks);
};
let desired_height = self
.notifications
.popup_groups()
.iter()
.map(|(notice, _, count)| {
popup_height(notice, *count) + stack_depth(*count) * STACK_PEEK
})
.sum::<u32>()
+ count.saturating_sub(1) as u32 * 8
+ 8;
let output_height = self
.outputs
.iter()
.find(|entry| entry.output == output)
.and_then(|entry| entry.size)
.map_or(1080, |size| size.1);
let geometry = self
.outputs
.iter()
.find(|entry| entry.output == output)
.map(|entry| {
entry
.runtime
.panel(&self.config.panels[0])
.resolved_geometry()
})
.unwrap_or_else(|| self.config.panels[0].resolved_geometry());
let top = geometry.height + geometry.edge_margin as f32 + 12.0;
let height = desired_height.min((output_height as f32 - top - 12.0).max(100.0) as u32);
if let Some(surface) = &mut self.notification_surface {
if surface.height != height {
surface.height = height;
tasks.push(set_size(surface.id, Some(POPUP_WIDTH), Some(height)));
}
} else {
let id = window::Id::unique();
self.notification_surface = Some(NotificationSurface {
id,
output: output.clone(),
height,
effects: None,
regions: Default::default(),
});
let (edge_anchor, mut margin) =
crate::surfaces::panel_placement(self.config.panels[0].edge, top as i32, 12);
margin.left = 0;
let action = cosmic::surface::action::app_layer_shell::<Self>(
|_| Default::default(),
move |_| SctkLayerSurfaceSettings {
id,
layer: Layer::Overlay,
keyboard_interactivity: KeyboardInteractivity::None,
anchor: edge_anchor | Anchor::RIGHT,
output: IcedOutput::Output(output.clone()),
margin,
exclusive_zone: -1,
size: Some((Some(POPUP_WIDTH), Some(height))),
size_limits: Limits::NONE,
namespace: "ferese-shell-notifications".into(),
..Default::default()
},
Some(Box::new(Self::view_notifications)),
);
tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
}
Task::batch(tasks)
}
fn notification_card(
&self,
notice: &notifications::Notice,
opacity: f32,
history: bool,
count: usize,
) -> Element<'_, cosmic::Action<Message>> {
let palette = self.config.theme;
let tint = move |rgba| {
let mut value = color(rgba);
value.a *= opacity;
value
};
let foreground = tint(palette.text_primary);
let muted = tint(palette.text_muted);
let accent = tint(palette.accent);
let base = tint(if history {
palette.surface_base
} else {
palette.surface_popover
});
let compositor_material = !history
&& self
.notification_surface
.as_ref()
.is_some_and(|surface| surface.effects.is_some());
let divider = tint(palette.border);
let well = blend(base, foreground, 0.07);
let id = notice.id;
let mut bold = self.render.font;
bold.weight = cosmic::iced::font::Weight::Bold;
let app_icon = if notice.app.to_lowercase().starts_with("ferese") || notice.icon.is_empty()
{
accented_icon(ferese_theme::icons::FERESE, 18, accent, accent)
} else if notice.icon.starts_with('/') {
icon::icon(icon::from_path(notice.icon.clone().into())).size(18)
} else {
icon::from_name(notice.icon.as_str()).size(18).icon()
}
.opacity(opacity);
let icon_well = icon_well(self.render, app_icon, foreground, 30);
let icon: Element<'_, cosmic::Action<Message>> = if count > 1 {
let badge_fill = ferese_theme::composite(
color(palette.accent),
color(if history {
palette.surface_base
} else {
palette.surface_popover
}),
);
let (badge_fill, badge_text) =
ferese_theme::accent_pair(badge_fill, color(palette.text_primary));
let badge = container(
self.render
.text(count.to_string())
.size(10)
.font(bold)
.class(theme::Text::Color(badge_text.scale_alpha(opacity))),
)
.center_x(16)
.center_y(16)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(badge_fill.scale_alpha(opacity))),
border: Border {
shape: BorderShape::Continuous,
radius: self.render.radius(8.0).into(),
..Default::default()
},
..Default::default()
}));
cosmic::iced::widget::stack([
container(icon_well)
.padding(cosmic::iced::Padding {
top: 4.0,
..Default::default()
})
.into(),
container(badge).align_right(34).into(),
])
.into()
} else {
icon_well
};
let app = if notice.app.is_empty() {
"NOTIFICATION".to_owned()
} else {
notice.app.to_uppercase()
};
let app = if count > 1 {
format!("{app} · {count} NOTIFICATIONS")
} else {
app
};
let header = row([])
.spacing(10)
.align_y(alignment::Vertical::Center)
.push(icon)
.push(
container(
self.render
.text(app)
.size(11)
.font(bold)
.class(theme::Text::Color(muted)),
)
.width(Length::Fill)
.height(16)
.clip(true),
)
.push(
self.render
.text(age_label(notice.received_at.elapsed()))
.size(11)
.class(theme::Text::Color(muted)),
)
.push(icon_control(
self.render,
ferese_theme::icons::CLOSE,
"Dismiss notification",
if history && count > 1 {
Message::RemoveNotificationGroup(notice.app.clone())
} else if history {
Message::RemoveNotification(id)
} else {
Message::DismissNotification(id)
},
muted,
well,
22,
));
let mut content = column([]).spacing(4).push(header).push(
container(
self.render
.text(notice.title.clone())
.size(14)
.font(bold)
.class(theme::Text::Color(foreground)),
)
.height(18)
.clip(true),
);
let body = if count > 1 {
if notice.body.is_empty() {
format!("{} more", count - 1)
} else {
format!("{} · {} more", notice.body, count - 1)
}
} else {
notice.body.clone()
};
if !body.is_empty() {
content = content.push(
container(
self.render
.text(body)
.size(13)
.class(theme::Text::Color(muted)),
)
.height(if history && count == 1 {
Length::Shrink
} else {
Length::Fixed(18.0)
})
.clip(true),
);
}
let mut face = column([]).push(container(content).padding([12, 14]).width(Length::Fill));
if notice.live {
let available: Vec<_> = notice
.actions
.iter()
.filter(|(key, _)| key != "default")
.take(2)
.collect();
if !available.is_empty() {
let mut actions = row([]);
for (index, (key, label)) in available.into_iter().enumerate() {
if index > 0 {
actions = actions.push(
container(cosmic::iced::widget::Space::new().width(1).height(38))
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(divider)),
..Default::default()
})),
);
}
actions = actions.push(card_button(
self.render,
container(
self.render
.text(label.clone())
.font(bold)
.size(13)
.class(theme::Text::Color(accent)),
)
.center_x(Length::Fill)
.center_y(38),
Message::InvokeNotification(id, key.clone()),
accent,
false,
label.clone(),
));
}
face = face
.push(
container(
cosmic::iced::widget::Space::new()
.width(Length::Fill)
.height(1),
)
.class(theme::Container::custom(move |_| {
container::Style {
background: Some(Background::Color(divider)),
..Default::default()
}
})),
)
.push(actions);
}
}
let card = card_surface(
face,
history,
container::Style {
background: if history {
Some(Background::Color(Color {
a: 0.045 * opacity,
..foreground
}))
} else {
(!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()
},
);
// Only exposed edges are painted, so translucent cards do not tint
// the face repeatedly. The count includes the face itself.
let radius = palette
.material_radius
.min(popup_height(notice, count) as f32 / 2.0);
let card = stacked_card(card, count, radius, foreground);
let mut area = cosmic::widget::mouse_area(card)
.on_enter(cosmic::Action::App(Message::HoverNotification(id, true)))
.on_exit(cosmic::Action::App(Message::HoverNotification(id, false)));
if count > 1 {
area = area.on_press(cosmic::Action::App(if history {
Message::ToggleNotificationGroup(notice.app.clone())
} else {
Message::ToggleNotificationHistory
}));
} else if notice.live && notice.actions.iter().any(|(key, _)| key == "default") {
area = area.on_press(cosmic::Action::App(Message::InvokeNotification(
id,
"default".into(),
)));
}
area.into()
}
pub(super) fn view_notifications(&self) -> Element<'_, cosmic::Action<Message>> {
motion::frame_driven(
self.notifications.motion_revision(),
|now| self.view_notifications_at(now),
|now| self.notifications.frame_active(now),
|now| {
if let Some(surface) = &self.notification_surface
&& let Some(effects) = &surface.effects
{
let _ = effects.set_presentation(
&surface.regions.lock().expect(
"surface rendering state must remain consistent across callbacks",
),
1.0,
self.notifications
.popup_groups_at(now)
.iter()
.map(|(_, opacity, _)| *opacity),
ferese_surface_effects_v1::Role::Popover,
);
}
},
|now| {
self.notifications
.has_finished_closes(now)
.then_some(cosmic::Action::App(Message::NotificationTick))
},
)
}
pub(super) fn view_notifications_at(
&self,
now: std::time::Instant,
) -> Element<'_, cosmic::Action<Message>> {
if self.notifications.history_open {
let palette = self.config.theme;
let surface = self.menu.as_ref();
let progress = surface.map_or(1.0, |menu| menu.motion.progress_at(now));
let opacity = 1.0;
let color = move |rgba| color_with_opacity(rgba, opacity);
let primary = color(palette.text_primary);
let muted = color(palette.text_muted);
let accent = color(palette.accent);
let base = color(palette.surface_base);
let compositor_material = surface.is_some_and(|menu| menu.effects.is_some());
let well = blend(base, primary, 0.07);
let mut bold = self.render.font;
bold.weight = cosmic::iced::font::Weight::Bold;
let groups = self.notifications.history_groups();
let count = self.notifications.entries.len();
let subtitle = if count == 0 {
"A quiet moment".to_owned()
} else {
format!(
"{count} {} · {} {}",
if count == 1 {
"notification"
} else {
"notifications"
},
groups.len(),
if groups.len() == 1 { "app" } else { "apps" }
)
};
let heading = column([])
.spacing(3)
.push(
self.render
.text("Notifications")
.size(16)
.font(bold)
.class(theme::Text::Color(primary)),
)
.push(
self.render
.text(subtitle)
.size(11)
.class(theme::Text::Color(muted)),
);
let header = row([])
.spacing(10)
.align_y(alignment::Vertical::Center)
.push(container(heading).width(Length::Fill))
.push(icon_control(
self.render,
ferese_theme::icons::CLOSE,
"Close notification center",
Message::ToggleNotificationHistory,
muted,
well,
28,
));
let dnd_color = if self.notifications.dnd {
accent
} else {
muted
};
let dnd_icon = if self.notifications.dnd {
ferese_theme::icons::NOTIFICATIONS_OFF
} else {
ferese_theme::icons::NOTIFICATIONS
};
let dnd = row([])
.spacing(10)
.align_y(alignment::Vertical::Center)
.push(accented_icon(dnd_icon, 20, dnd_color, dnd_color))
.push(
container(
column([])
.spacing(2)
.push(
self.render
.text("Do Not Disturb")
.size(12)
.font(bold)
.class(theme::Text::Color(primary)),
)
.push(
self.render
.text(if self.notifications.dnd {
"Popups paused"
} else {
"Popups enabled"
})
.size(11)
.class(theme::Text::Color(muted)),
),
)
.width(Length::Fill),
)
.push(motion::button(
self.render,
button::custom(
container(
self.render
.text(if self.notifications.dnd { "On" } else { "Off" })
.size(12)
.font(bold)
.class(theme::Text::Color(dnd_color)),
)
.center_x(40)
.center_y(26),
)
.on_press(cosmic::Action::App(Message::Control(status::Action::Dnd(
!self.notifications.dnd,
))))
.padding(0),
dnd_color,
self.notifications.dnd,
1.0,
));
let controls =
ferese_theme::menus::section(dnd.into(), primary, opacity, palette.material_radius);
let mut content = column([]).spacing(10).push(header).push(controls);
if count == 0 {
let empty = column([])
.spacing(10)
.align_x(alignment::Horizontal::Center)
.push(icon_well(
self.render,
accented_icon(ferese_theme::icons::NOTIFICATIONS, 26, muted, accent),
primary,
56,
))
.push(
self.render
.text("You're all caught up")
.size(15)
.font(bold)
.class(theme::Text::Color(primary)),
)
.push(
self.render
.text("New notifications will appear here.")
.size(11)
.class(theme::Text::Color(muted)),
);
content = content.push(
container(empty)
.center_x(Length::Fill)
.center_y(Length::Fill),
);
} else {
content = content.push(
row([])
.align_y(alignment::Vertical::Center)
.push(
self.render
.text("Recent")
.size(11)
.font(bold)
.class(theme::Text::Color(muted))
.width(Length::Fill),
)
.push(icon_control(
self.render,
ferese_theme::icons::TRASH,
"Clear all notifications",
Message::ClearNotifications,
muted,
well,
26,
)),
);
let mut entries = column([]).spacing(8);
for group in groups {
let latest = group[0];
if group.len() > 1 && self.notifications.expanded_apps.contains(&latest.app) {
let app = if latest.app.is_empty() {
"Notifications"
} else {
&latest.app
};
let group_header = row([])
.spacing(6)
.align_y(alignment::Vertical::Center)
.push(
self.render
.text(format!("{} · {}", app, group.len()))
.size(11)
.font(bold)
.class(theme::Text::Color(muted))
.width(Length::Fill),
)
.push(icon_control(
self.render,
ferese_theme::icons::CHEVRON_UP,
"Collapse group",
Message::ToggleNotificationGroup(latest.app.clone()),
muted,
well,
24,
))
.push(icon_control(
self.render,
ferese_theme::icons::TRASH,
"Clear group",
Message::RemoveNotificationGroup(latest.app.clone()),
muted,
well,
24,
));
let mut cards = column([]).spacing(8).push(group_header);
for notice in group {
cards = cards.push(self.notification_card(notice, opacity, true, 1));
}
entries = entries.push(cards);
} else {
entries = entries.push(self.notification_card(
latest,
opacity,
true,
group.len(),
));
}
}
content = content.push(
scrollable(entries)
.direction(Direction::Vertical(Scrollbar::hidden()))
.height(Length::Shrink),
);
}
let panel = container(content)
.id("ferese-blur-card")
.padding(16)
.height(if count == 0 {
Length::Fixed(280.0_f32.min(self.notification_history_height_limit()))
} else {
Length::Shrink
})
.max_height(self.notification_history_height_limit())
.width(Length::Fill)
.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()
}))
.into();
motion::animated_from_edge(
panel,
progress,
surface
.map(|surface| surface.regions.clone())
.unwrap_or_default(),
palette.material_radius,
self.config.panels[0].edge,
)
} else {
let mut cards = column([]).spacing(8);
for (notice, opacity, count) in self.notifications.popup_groups_at(now) {
cards = cards.push(self.notification_card(notice, opacity, false, count));
}
motion::animated(
container(
scrollable(cards)
.direction(Direction::Vertical(Scrollbar::hidden()))
.height(Length::Fill),
)
.padding(4)
.into(),
1.0,
self.notification_surface
.as_ref()
.map(|surface| surface.regions.clone())
.unwrap_or_default(),
self.config.theme.material_radius,
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::iced::advanced::renderer::{Headless, Renderer as _};
use cosmic::iced::advanced::widget::Tree;
use cosmic::iced::advanced::{Layout, layout, mouse};
use cosmic::iced::{Font, Pixels, Rectangle, Size};
#[test]
fn grouped_notification_backing_edges_follow_the_face_corners() {
check_backing_edges("tiny-skia");
}
#[test]
#[ignore = "requires a GPU or software Vulkan adapter"]
fn gpu_grouped_notification_backing_edges_follow_the_face_corners() {
check_backing_edges("wgpu");
}
fn check_backing_edges(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();
for radius in [0.0, 14.0, 21.0, 32.0] {
for (count, expected_height) in [(1, 100.0), (2, 106.0), (3, 112.0), (8, 112.0)] {
for scale in [1.0, 1.25, 1.5, 1.75] {
let face = card_surface(
cosmic::widget::Space::new().height(100),
false,
container::Style {
background: Some(Color::from_rgba(0.1, 0.1, 0.1, 0.6).into()),
border: Border {
shape: BorderShape::Continuous,
radius: radius.into(),
..Default::default()
},
..Default::default()
},
);
let mut card = stacked_card(face, count, radius, Color::WHITE);
let mut tree = Tree::new(card.as_widget());
let node = card.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(360.0, 160.0)),
);
let bounds = node.bounds();
assert_eq!(bounds.height, expected_height, "count={count}");
renderer.reset(bounds);
card.as_widget().draw(
&tree,
&mut renderer,
&cosmic::Theme::dark(),
&Default::default(),
Layout::new(&node),
mouse::Cursor::Unavailable,
&bounds,
);
let size = Size::new(
(bounds.width * scale).ceil() as u32,
(bounds.height * scale).ceil() as u32,
);
let pixels =
Headless::screenshot(&mut renderer, size, scale, Color::TRANSPARENT);
let alpha = |x: f32, y: f32| {
pixels[(((y * scale) as u32 * size.width + (x * scale) as u32) * 4 + 3)
as usize]
};
assert_eq!(alpha(180.0, 50.0), 153, "backing must not tint the face");
if count > 1 {
assert!(
alpha(180.0, 102.0) > 0,
"backing must remain visible: {backend}, r={radius}, count={count}, scale={scale}, painted bottom={:?}",
pixels
.chunks_exact(4)
.enumerate()
.rfind(|(_, p)| p[3] > 0)
.map(|(i, _)| i / size.width as usize)
);
if radius > 0.0 {
assert_eq!(
alpha(radius / 2.0, 102.0),
0,
"backing protrudes past the face: {backend}, r={radius}, scale={scale}"
);
}
}
}
}
}
}
#[test]
fn notification_insets_reveal_the_modal_material() {
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("tiny-skia"),
))
.unwrap();
for foreground in [Color::BLACK, Color::WHITE] {
for extent in [30, 56] {
let inset = icon_well(
crate::rendering::Rendering::new(&crate::config::ShellConfig::default()),
cosmic::widget::Space::new(),
foreground,
extent,
);
let controls = ferese_theme::menus::section(
cosmic::widget::Space::new().width(100).height(40).into(),
foreground,
1.0,
14.0,
);
for mut element in [inset, controls] {
let bounds = Rectangle::with_size(Size::new(120.0, 60.0));
let mut tree = Tree::new(element.as_widget());
let node = element.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, bounds.size()),
);
renderer.reset(bounds);
element.as_widget().draw(
&tree,
&mut renderer,
&cosmic::Theme::dark(),
&Default::default(),
Layout::new(&node),
mouse::Cursor::Unavailable,
&bounds,
);
let pixels = Headless::screenshot(
&mut renderer,
Size::new(120, 60),
1.0,
Color::TRANSPARENT,
);
let center = node.bounds().center();
let alpha = pixels[((center.y as usize * 120 + center.x as usize) * 4) + 3];
assert!(
alpha > 0 && alpha < 32,
"an inset must be visible without hiding the material: {alpha}"
);
}
}
}
}
#[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(
crate::rendering::Rendering::new(&crate::config::ShellConfig::default()),
cosmic::widget::Space::new().height(38),
Message::InvokeNotification(1, "action".into()),
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}"
);
let hover_alpha = alpha(size.width / 2, size.height - 4);
assert!(
hover_alpha > 0 && hover_alpha < 32,
"the action hover must render without hiding the material: {hover_alpha}"
);
}
}
}
}