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]>
This commit is contained in:
Samuel OnojaandSamuel Onoja authored and GitHub committed 2026-10-12 22:52:25 +01:00
1 parent 7a7df0106c
commit 6b0d903ad0
107 files changed
+9340 -291

No files matched your search

+2
View File
@@ -163,6 +163,7 @@ fn status_label(kind: Menu, s: &Snapshot) -> String {
Some(n) => format!("Notifications: {} unread", n.count),
_ => "Notifications: unavailable".into(),
},
Menu::Activities => "Activities".into(),
Menu::Workspaces => "Workspaces".into(),
Menu::Calendar => "Calendar".into(),
Menu::Overflow => "More panel items".into(),
@@ -243,6 +244,7 @@ pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
}
}
Menu::Workspaces => (ferese_theme::icons::OVERVIEW, true),
Menu::Activities => (ferese_theme::icons::FERESE, true),
Menu::System => (ferese_theme::icons::CONTROL_CENTER, true),
}
}
@@ -100,6 +100,7 @@ impl FereseShell {
if kind == Menu::Notifications && self.notifications.ready {
self.notifications.toggle_history();
self.sync_activity_presentations();
}
let notifications = self.sync_notification_surface();
+131 -3
View File
@@ -32,6 +32,7 @@ struct MenuStyle {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Menu {
Activities,
Workspaces,
Network,
Bluetooth,
@@ -75,6 +76,7 @@ impl Menu {
fn title(self) -> &'static str {
match self {
Self::Activities => "Activities",
Self::Workspaces => "Workspaces",
Self::System => "Control Center",
Self::Overflow => "Panel items",
@@ -97,7 +99,7 @@ impl Menu {
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 => true,
Self::System | Self::Overflow | Self::Workspaces | Self::Activities => true,
}
}
}
@@ -281,7 +283,7 @@ impl FereseShell {
style.primary,
opacity,
)),
Menu::Overflow | Menu::Workspaces => rows,
Menu::Activities | Menu::Overflow | Menu::Workspaces => rows,
};
if !kind.available(&self.status) {
rows = rows.push(
@@ -389,6 +391,7 @@ impl FereseShell {
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),
@@ -414,7 +417,7 @@ impl FereseShell {
}
let compositor_material = menu.effects.is_some();
let body: Element<'_, cosmic::Action<Message>> = if kind == Menu::Overflow {
let body: Element<'_, cosmic::Action<Message>> = if matches!(kind, Menu::Overflow | Menu::Activities) {
cosmic::widget::scrollable(rows).height(Length::Shrink).into()
} else {
rows.into()
@@ -510,6 +513,131 @@ fn notification_controls<'a>(
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 [