781 lines
27 KiB
Rust
781 lines
27 KiB
Rust
//! 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(®ions) {
|
||
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 ¬ice.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));
|
||
}
|
||
}
|