Files
feresaria/shell/ferese-shell/src/status_ui/mod.rs
T
Samuel OnojaandSamuel Onoja 6b0d903ad0 Add Live Activities as configurable bar items (#30)
* Add Live Activities as configurable bar items

* Fix activity owner monitoring and Morph list interactions

* Keep activity rows stable during updates and tighten their layout

* Use subtle theme tint for activity cards

* Tighten activity title and progress spacing

* Use theme accent for active progress rings

* Fit compact activities to measured titles and badge

* Expose Live Activities display controls in Settings

* Open activity terminals and streamline the Morph header

* Give Morph continuous icons and prioritize workspace navigation

* Show activity progress inside its ring

* Inset activity progress rings and strengthen their stroke

* Fix activity lifecycle, command output handling and list navigation

* Reduce activity update work and harden publisher lifetimes

* Set up Live Activities for the detected user shell during installation

* Document Activities setup, daily use and troubleshooting

* Preserve Morph idle width across empty activity updates

* Fix dimming flicker, hide inactive music tab and consolidate Activities docs

* Fix panel clearance across styles and match previews to theme settings

* Prevent panel blur from repainting neighboring islands

---------

Co-authored-by: Samuel Onoja <[email protected]>
2026-10-12 22:52:25 +01:00

718 lines
26 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 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 {
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::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::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::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, 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(&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::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 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,
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::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));
}
}