Split status UI into focused menu modules
This commit is contained in:
1 parent
4ea1f1f222
commit
2c95e5e80f
11 files changed
+1664
-1450
No files matched your search
@@ -0,0 +1,365 @@
|
||||
//! Status popup lifecycle, shared frame, and content dispatch.
|
||||
mod audio;
|
||||
mod bar;
|
||||
mod battery;
|
||||
mod bluetooth;
|
||||
mod calendar;
|
||||
mod controls;
|
||||
mod lifecycle;
|
||||
mod network;
|
||||
mod system;
|
||||
|
||||
use bar::status_icon;
|
||||
use controls::{control_card, menu_button, toggle_row};
|
||||
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 {
|
||||
Network,
|
||||
Bluetooth,
|
||||
Audio,
|
||||
Battery,
|
||||
Calendar,
|
||||
Recording,
|
||||
Notifications,
|
||||
System,
|
||||
}
|
||||
|
||||
impl Menu {
|
||||
fn action_error(self, error: &status::ActionError) -> Option<&str> {
|
||||
let relevant = self == Self::System
|
||||
|| matches!(
|
||||
(self, &error.action),
|
||||
(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())
|
||||
}
|
||||
|
||||
fn width(self) -> f32 {
|
||||
match self {
|
||||
Self::System => 360.0,
|
||||
Self::Battery => 328.0,
|
||||
Self::Calendar => 268.0,
|
||||
Self::Notifications => 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::System => "Control Center",
|
||||
Self::Network => "Wi-Fi",
|
||||
Self::Bluetooth => "Bluetooth",
|
||||
Self::Audio => "Sound",
|
||||
Self::Battery => "Battery",
|
||||
Self::Calendar => "Calendar",
|
||||
Self::Recording => "Screen recording",
|
||||
Self::Notifications => "Notifications",
|
||||
}
|
||||
}
|
||||
|
||||
fn available(self, status: &Snapshot) -> bool {
|
||||
match self {
|
||||
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 => true,
|
||||
Self::Notifications => status.notifications.is_some(),
|
||||
Self::System => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OpenMenu {
|
||||
pub id: window::Id,
|
||||
pub kind: Menu,
|
||||
pub motion: super::motion::PopupMotion,
|
||||
pub effects: Option<EffectsBinding>,
|
||||
pub regions: super::motion::Regions,
|
||||
}
|
||||
|
||||
impl OpenMenu {
|
||||
pub(super) fn prepare_surface(&mut self, surface: &wl_surface::WlSurface) {
|
||||
if self.effects.is_none() {
|
||||
match EffectsBinding::attach_role(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(®ions) {
|
||||
eprintln!("ferese-shell: could not prepare popover material: {error}");
|
||||
}
|
||||
}
|
||||
self.motion.begin(Instant::now());
|
||||
super::EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn progress(&self) -> f32 {
|
||||
self.motion.progress()
|
||||
}
|
||||
|
||||
pub fn animating(&self) -> bool {
|
||||
self.motion.animating()
|
||||
}
|
||||
}
|
||||
|
||||
impl FereseShell {
|
||||
fn view_status_menu(&self) -> Element<'_, cosmic::Action<Message>> {
|
||||
let Some(menu) = &self.menu else {
|
||||
return text("").into();
|
||||
};
|
||||
if menu.kind == Menu::Notifications && self.notifications.ready {
|
||||
return cosmic::widget::autosize::autosize(
|
||||
self.view_notifications(),
|
||||
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 badge = ferese_theme::menus::badge(
|
||||
accented_icon(
|
||||
status_icon(kind, &self.status).0,
|
||||
22,
|
||||
primary,
|
||||
color_with_opacity(theme.accent, p),
|
||||
)
|
||||
.width(Length::Fixed(22.))
|
||||
.into(),
|
||||
color(theme.accent),
|
||||
p,
|
||||
motion::radius(20.),
|
||||
);
|
||||
let mut heading = ferese_theme::menus::heading(badge, kind.title(), shell_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),
|
||||
Menu::Network => network::view(self, rows, style),
|
||||
Menu::Bluetooth => bluetooth::view(self, rows, style),
|
||||
Menu::Audio => audio::view(self, rows, style, false),
|
||||
Menu::Battery => battery::view(self, rows, style),
|
||||
Menu::Calendar => rows.push(calendar::view(self.calendar_offset, theme, p)),
|
||||
Menu::Notifications => notification_controls(self, rows, style, false),
|
||||
Menu::Recording => rows,
|
||||
};
|
||||
if kind != Menu::Calendar && !kind.available(&self.status) {
|
||||
rows = rows.push(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(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 panel = container(rows)
|
||||
.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 {
|
||||
color: color_with_opacity(theme.text_muted, 0.18 * p),
|
||||
width: 1.0,
|
||||
radius: theme.material_radius.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}));
|
||||
|
||||
cosmic::widget::autosize::autosize(
|
||||
super::motion::animated(
|
||||
panel.into(),
|
||||
menu.progress(),
|
||||
menu.regions.clone(),
|
||||
theme.material_radius,
|
||||
),
|
||||
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("Do Not Disturb", n.dnd, Action::Dnd(!n.dnd), theme, p);
|
||||
rows = rows.push(if combined {
|
||||
control_card(toggle, primary, p)
|
||||
} else {
|
||||
toggle
|
||||
});
|
||||
|
||||
if !combined {
|
||||
rows = rows
|
||||
.push(
|
||||
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(
|
||||
"Open notification center",
|
||||
Message::Control(Action::Notifications),
|
||||
primary,
|
||||
p,
|
||||
));
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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(),
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user