Files
feresaria/shell/ferese-shell/src/status_ui/mod.rs
T

781 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Status popup lifecycle, shared frame, and content dispatch.
mod audio;
mod bar;
mod battery;
mod bluetooth;
mod calendar;
mod controls;
mod lifecycle;
mod media;
mod network;
mod night_light;
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};
use super::*;
type MenuHeading<'a> =
cosmic::widget::Row<'a, cosmic::Action<Message>, cosmic::Theme, cosmic::Renderer>;
type MenuRows<'a> =
cosmic::widget::Column<'a, cosmic::Action<Message>, cosmic::Theme, cosmic::Renderer>;
#[derive(Clone, Copy)]
struct MenuStyle {
theme: ShellTheme,
primary: Color,
muted: Color,
opacity: f32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Menu {
NightLight,
Activities,
Workspaces,
Network,
Bluetooth,
Audio,
Battery,
Calendar,
Recording,
Media,
Notifications,
System,
Overflow,
}
impl Menu {
fn action_error(self, error: &status::ActionError) -> Option<&str> {
let relevant = self == Self::System
|| matches!(
(self, &error.action),
(
Self::NightLight,
Action::NightLight(_) | Action::NightLightTemperature(_)
) | (Self::Network, Action::Wifi(_))
| (Self::Bluetooth, Action::Bluetooth(_))
| (Self::Audio, Action::Volume(_) | Action::Mute(_))
| (
Self::Battery,
Action::Brightness(_) | Action::PowerProfile(_)
)
| (Self::Notifications, Action::Dnd(_) | Action::Notifications)
);
relevant.then_some(error.message.as_str())
}
pub(crate) fn width(self) -> f32 {
match self {
Self::System => 360.0,
Self::Battery => 328.0,
Self::Calendar => 268.0,
Self::Notifications | Self::Media => 368.0,
_ => 300.0,
}
}
fn height_limit(self) -> f32 {
if self == Self::Calendar { 270.0 } else { 720.0 }
}
fn title(self) -> &'static str {
match self {
Self::NightLight => "Night light",
Self::Activities => "Activities",
Self::Workspaces => "Workspaces",
Self::System => "Control Center",
Self::Overflow => "Panel items",
Self::Network => "Wi-Fi",
Self::Bluetooth => "Bluetooth",
Self::Audio => "Sound",
Self::Battery => "Battery",
Self::Calendar => "Calendar",
Self::Recording => "Screen recording",
Self::Media => "Now playing",
Self::Notifications => "Notifications",
}
}
fn available(self, status: &Snapshot) -> bool {
match self {
Self::NightLight => true,
Self::Network => status.network.is_some(),
Self::Bluetooth => status.bluetooth.is_some(),
Self::Audio => status.audio.is_some(),
Self::Battery => status.battery.is_some(),
Self::Calendar | Self::Recording | Self::Media => true,
Self::Notifications => status.notifications.is_some(),
Self::System | Self::Overflow | Self::Workspaces | Self::Activities => true,
}
}
}
pub struct OpenMenu {
pub id: window::Id,
pub anchor: PopoverAnchor,
pub kind: Menu,
pub motion: super::motion::PopupMotion,
pub effects: Option<EffectsBinding>,
pub regions: super::motion::Regions,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct PopoverAnchor {
pub parent: window::Id,
pub panel: crate::panel::PanelId,
// Notifications can be opened from a toast without a panel item.
pub item: Option<crate::panel::ItemId>,
pub rectangle: cosmic::iced::Rectangle<i32>,
}
impl PopoverAnchor {
pub(crate) fn belongs_to(&self, panel: &crate::panel::Panel) -> bool {
self.panel == panel.id
&& self.item.as_ref().is_none_or(|id| {
id.0 == "_overflow" || panel.item(id).is_some_and(|item| item.visible)
})
}
pub fn same_item(&self, other: &Self) -> bool {
self.parent == other.parent && self.panel == other.panel && self.item == other.item
}
}
impl OpenMenu {
pub(super) fn prepare_surface(
&mut self,
context: &mut Option<std::rc::Rc<EffectsContext>>,
surface: &wl_surface::WlSurface,
) {
if self.effects.is_none() {
match EffectsBinding::attach_role(context, surface, None, 0.0) {
Ok(binding) => self.effects = Some(binding),
Err(error) => eprintln!("ferese-shell: popover material unavailable: {error}"),
}
}
if let Some(effects) = &self.effects {
let regions = self.regions.lock().unwrap();
if let Err(error) = effects.set_regions(&regions) {
eprintln!("ferese-shell: could not prepare popover material: {error}");
}
}
self.motion.begin(Instant::now());
super::EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
}
#[cfg(test)]
pub fn progress(&self) -> f32 {
self.motion.progress()
}
pub fn animating(&self) -> bool {
self.motion.animating()
}
}
impl FereseShell {
pub(crate) fn view_hub_menu(
&self,
kind: Menu,
opacity: f32,
) -> Element<'_, cosmic::Action<Message>> {
let theme = self.config.theme;
let style = MenuStyle {
theme,
primary: color_with_opacity(theme.text_primary, opacity),
muted: color_with_opacity(theme.text_muted, opacity),
opacity,
};
let mut rows = column::with_capacity(8).spacing(8).width(Length::Fill);
if kind == Menu::Network
&& let Some(network) = &self.status.network
{
rows = rows.push(toggle_row(
self.render,
"Wi-Fi",
network.enabled,
Action::Wifi(!network.enabled),
theme,
opacity,
));
}
if kind == Menu::Audio
&& let Some(audio) = &self.status.audio
{
rows = rows.push(toggle_row(
self.render,
"Mute",
audio.muted,
Action::Mute(!audio.muted),
theme,
opacity,
));
}
let mut rows = match kind {
Menu::System => system::view(self, rows, style, true),
Menu::NightLight => night_light::view(self, rows, style),
Menu::Network => network::view(self, rows, style),
Menu::Bluetooth => bluetooth::view(self, rows, style, true),
Menu::Audio => audio::view(self, rows, style, false),
Menu::Battery => battery::view(self, rows, style),
Menu::Calendar => rows.push(calendar::hub(
self.render,
self.calendar_offset,
theme,
opacity,
)),
Menu::Media => media::hub(self, rows, style),
Menu::Notifications => {
let mut rows = notification_controls(self, rows, style, true);
if self.notifications.entries.is_empty() {
rows = rows.push(
self.render
.text("No notifications")
.size(13)
.class(theme::Text::Color(style.muted)),
);
} else {
rows = rows.push(menu_button(
self.render,
"Clear notifications",
Message::ClearNotifications,
style.muted,
opacity,
));
for notice in &self.notifications.entries {
rows = rows.push(control_card(
self.render,
column![
row![
self.render
.text(notice.app.clone())
.size(11)
.class(theme::Text::Color(style.muted))
.width(Length::Fill),
menu_button(
self.render,
"×",
Message::RemoveNotification(notice.id),
style.muted,
opacity
)
],
self.render
.text(notice.title.clone())
.size(13)
.class(theme::Text::Color(style.primary)),
self.render
.text(notice.body.clone())
.size(12)
.class(theme::Text::Color(style.muted)),
]
.spacing(4)
.into(),
style.primary,
opacity,
));
for (action, label) in &notice.actions {
rows = rows.push(menu_button(
self.render,
label,
Message::InvokeNotification(notice.id, action.clone()),
style.primary,
opacity,
));
}
}
}
rows
}
Menu::Recording => rows.push(menu_button(
self.render,
if self.recorder.busy() {
"Stop recording"
} else {
"Record screen"
},
if self.recorder.busy() {
Message::StopRecording
} else {
Message::StartRecording
},
style.primary,
opacity,
)),
Menu::Activities | Menu::Overflow | Menu::Workspaces => rows,
};
if !kind.available(&self.status) {
rows = rows.push(
self.render
.text("Service unavailable")
.size(13)
.class(theme::Text::Color(style.muted)),
);
}
if let Some(error) = self
.status_error
.as_ref()
.and_then(|error| kind.action_error(error))
{
rows = rows.push(
self.render
.text(error)
.size(12)
.class(theme::Text::Color(style.muted)),
);
}
container(rows)
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
text_color: Some(style.primary),
icon_color: Some(style.primary),
..Default::default()
}))
.into()
}
fn view_status_menu(&self) -> Element<'_, cosmic::Action<Message>> {
let Some(menu) = &self.menu else {
return self.render.text("").into();
};
motion::frame_driven(
menu.motion.revision(),
|now| self.view_status_menu_at(now),
|now| menu.motion.frame_active(now),
|now| {
if let Some(effects) = &menu.effects {
let _ = effects.set_presentation(
&menu.regions.lock().unwrap(),
menu.motion.progress_at(now),
[],
super::ferese_surface_effects_v1::Role::Popover,
);
}
},
|now| {
(menu.motion.closing() && !menu.motion.animating_at(now))
.then_some(cosmic::Action::App(Message::AnimateMenu))
},
)
}
fn view_status_menu_at(&self, now: Instant) -> Element<'_, cosmic::Action<Message>> {
let Some(menu) = &self.menu else {
return self.render.text("").into();
};
if menu.kind == Menu::Notifications && self.notifications.ready {
return cosmic::widget::autosize::autosize(
self.view_notifications_at(now),
cosmic::iced::advanced::widget::Id::new("ferese-notification-center"),
)
.limits(
Limits::NONE
.min_width(menu.kind.width())
.max_width(menu.kind.width())
.max_height(720.0),
)
.into();
}
let theme = self.config.theme;
let p = 1.0;
let kind = menu.kind;
let primary = color_with_opacity(theme.text_primary, p);
let muted = color_with_opacity(theme.text_muted, p);
let heading_icon = if kind == Menu::Battery {
self.battery_indicator(theme, p, false)
} else {
accented_icon(
status_icon(kind, &self.status).0,
22,
primary,
color_with_opacity(theme.accent, p),
)
.width(Length::Fixed(22.))
.into()
};
let badge = ferese_theme::menus::badge(
heading_icon,
color(theme.accent),
p,
self.render.radius(20.),
);
let mut heading = ferese_theme::menus::heading(badge, kind.title(), self.render.font);
let style = MenuStyle {
theme,
primary,
muted,
opacity: p,
};
heading = match kind {
Menu::System => system::heading(self, heading, style),
Menu::Network => network::heading(self, heading, style),
Menu::Audio => audio::heading(self, heading, style),
_ => heading,
};
let mut rows = column::with_capacity(12).spacing(12).width(Length::Fill);
if kind != Menu::Calendar {
rows = rows.push(heading);
}
rows = match kind {
Menu::System => system::view(self, rows, style, false),
Menu::NightLight => night_light::view(self, rows, style),
Menu::Network => network::view(self, rows, style),
Menu::Bluetooth => bluetooth::view(self, rows, style, false),
Menu::Audio => audio::view(self, rows, style, false),
Menu::Battery => battery::view(self, rows, style),
Menu::Calendar => {
rows.push(calendar::view(self.render, self.calendar_offset, theme, p))
}
Menu::Notifications => notification_controls(self, rows, style, false),
Menu::Activities => rows.push(self.activity_list(menu.anchor.parent, p)),
Menu::Recording | Menu::Workspaces => rows,
Menu::Overflow => self.view_panel_overflow(rows),
Menu::Media => media::view(self, rows, style),
};
if kind != Menu::Calendar && !kind.available(&self.status) {
rows = rows.push(
self.render
.text("Service unavailable")
.size(13)
.class(theme::Text::Color(muted)),
);
}
if let Some(error) = self
.status_error
.as_ref()
.and_then(|error| menu.kind.action_error(error))
{
rows = rows.push(
self.render
.text(error)
.size(12)
.class(theme::Text::Color(Color {
a: p,
..Color::from_rgb8(230, 172, 90)
})),
);
}
let compositor_material = menu.effects.is_some();
let body: Element<'_, cosmic::Action<Message>> =
if matches!(kind, Menu::Overflow | Menu::Activities) {
cosmic::widget::scrollable(rows)
.height(Length::Shrink)
.into()
} else {
rows.into()
};
let panel = container(body)
.id("ferese-blur-card")
.width(kind.width())
.padding(16)
.class(theme::Container::custom(move |_| container::Style {
background: (!compositor_material).then_some(Background::Color(
color_with_opacity(theme.surface_popover, p),
)),
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()
}));
cosmic::widget::autosize::autosize(
super::motion::animated_from_edge(
panel.into(),
menu.motion.progress_at(now),
menu.regions.clone(),
theme.material_radius,
self.config.panels[0].edge,
),
cosmic::iced::advanced::widget::Id::new("ferese-status-menu"),
)
.limits(
Limits::NONE
.min_width(kind.width())
.max_width(kind.width())
.max_height(kind.height_limit()),
)
.into()
}
}
fn notification_controls<'a>(
shell: &'a FereseShell,
mut rows: MenuRows<'a>,
style: MenuStyle,
combined: bool,
) -> MenuRows<'a> {
let MenuStyle {
theme,
primary,
muted,
opacity: p,
} = style;
if let Some(n) = &shell.status.notifications {
let toggle = toggle_row(
shell.render,
"Do Not Disturb",
n.dnd,
Action::Dnd(!n.dnd),
theme,
p,
);
rows = rows.push(if combined {
control_card(shell.render, toggle, primary, p)
} else {
toggle
});
if !combined {
rows = rows
.push(
shell
.render
.text(if n.count == 0 {
"No notifications".to_owned()
} else if n.count == 1 {
"1 notification".to_owned()
} else {
format!("{} notifications", n.count)
})
.size(13)
.class(theme::Text::Color(muted)),
)
.push(menu_button(
shell.render,
"Open notification center",
Message::Control(Action::Notifications),
primary,
p,
));
}
}
rows
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn activity_popups_scroll_to_the_last_row_in_regular_panels() {
use cosmic::iced::advanced::{
Layout, Shell, clipboard, layout, renderer::Headless, widget,
};
use cosmic::iced::{Event, Rectangle, Size, Vector, mouse};
use ferese_activities::{Data, Owner, Source, Store, Template, Value};
#[derive(Default)]
struct ScrollBounds(Option<(Rectangle, Rectangle, Vector)>);
impl widget::Operation for ScrollBounds {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn scrollable(
&mut self,
_: Option<&widget::Id>,
bounds: Rectangle,
content: Rectangle,
translation: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
self.0 = Some((bounds, content, translation));
}
}
let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
cosmic::iced::Font::default(),
cosmic::iced::Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
for style in [
ferese_panel::PanelStyle::Flat,
ferese_panel::PanelStyle::Floating,
ferese_panel::PanelStyle::Adaptive,
] {
let mut app = crate::tests::shell_with_measured_panel();
let bar = app.outputs[0].bar;
app.config.panels[0].style = style;
app.snapshot.outputs = vec![crate::control::OutputSnapshot {
id: 1,
name: "test".into(),
active_workspace: 1,
focused: true,
}];
app.outputs[0]
.runtime
.reset(&app.config.panels[0], &app.snapshot, Some("test"));
let mut store = Store::new(1);
for n in 0..24 {
store
.start(
Owner {
pid: n + 1,
birth: 1,
},
n.to_string(),
Data {
source: Source::Publisher,
output: Some("test".into()),
priority: 100,
icon: "hammer".into(),
title: format!("Task {n}"),
value: Value::Indeterminate,
template: Template::Progress,
},
0,
0,
None,
true,
true,
None,
)
.unwrap();
}
app.activities.snapshot = std::sync::Arc::new(ferese_ipc::activities::Snapshot {
activities: store.entries().cloned().collect(),
..Default::default()
});
app.sync_activity_presentations();
drop(app.open_menu(
Menu::Activities,
PopoverAnchor {
parent: bar,
panel: app.config.panels[0].id.clone(),
item: None,
rectangle: Rectangle {
x: 0,
y: 0,
width: 100,
height: 28,
},
},
));
let mut view = app.view_status_menu_at(Instant::now() + Duration::from_secs(1));
let mut tree = widget::Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(300., 500.)),
);
let mut before = ScrollBounds::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut before);
let (viewport, content, _) = before.0.expect("activity popup must be scrollable");
assert!(content.height > viewport.height);
let mut messages = Vec::new();
view.as_widget_mut().update(
&mut tree,
&Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: -1000. },
}),
Layout::new(&node),
mouse::Cursor::Available(viewport.center()),
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
&node.bounds(),
);
let mut after = ScrollBounds::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut after);
let (viewport, content, translation) = after.0.unwrap();
assert!(translation.y > 0., "{style:?} did not scroll");
assert!(
(translation.y - (content.height - viewport.height)).abs() < 1.,
"last row must be reachable"
);
}
}
#[test]
fn failed_controls_only_show_errors_in_related_popups() {
for (action, relevant) in [
(Action::Bluetooth(true), Menu::Bluetooth),
(Action::Wifi(true), Menu::Network),
(Action::Volume(50), Menu::Audio),
(Action::Mute(true), Menu::Audio),
(Action::Brightness(50), Menu::Battery),
(Action::PowerProfile("balanced"), Menu::Battery),
(Action::Dnd(true), Menu::Notifications),
] {
let error = status::ActionError {
action,
message: "control failed".into(),
};
for menu in [
Menu::Network,
Menu::Bluetooth,
Menu::Audio,
Menu::Battery,
Menu::Calendar,
Menu::Recording,
Menu::Notifications,
Menu::System,
] {
assert_eq!(
menu.action_error(&error),
(menu == relevant || menu == Menu::System).then_some("control failed"),
"{menu:?} showed the wrong control error: {:?}",
error.action
);
}
}
}
#[test]
fn smaller_popups_have_content_specific_widths() {
assert_eq!(Menu::System.width(), 360.0);
assert_eq!(Menu::Network.width(), 300.0);
assert_eq!(Menu::Bluetooth.width(), 300.0);
assert_eq!(Menu::Battery.width(), 328.0);
assert_eq!(Menu::Calendar.width(), 268.0);
assert_eq!(Menu::Calendar.height_limit(), 270.0);
assert_eq!(Menu::Audio.width(), 300.0);
assert_eq!(Menu::Notifications.width(), 368.0);
}
#[test]
fn completed_open_stops_requesting_animation_ticks() {
let past = Instant::now() - Duration::from_secs(1);
let mut motion = super::motion::PopupMotion::new(Default::default());
motion.begin(past);
let menu = OpenMenu {
id: window::Id::unique(),
anchor: PopoverAnchor {
parent: window::Id::unique(),
panel: crate::panel::PanelId("main".into()),
item: None,
rectangle: Default::default(),
},
kind: Menu::Network,
motion,
effects: None,
regions: Default::default(),
};
assert_eq!(menu.progress(), 1.0);
assert!(!menu.animating());
}
#[test]
fn services_are_hidden_until_available() {
let s = Snapshot::default();
assert!(!Menu::Network.available(&s));
assert!(!Menu::Notifications.available(&s));
assert!(Menu::System.available(&s));
}
}