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

+76 -3
View File
@@ -269,6 +269,7 @@ struct Toast {
}
pub struct Center {
pub activity_bridge: bool,
config: ferese_config::notifications::NotificationConfig,
service: Service,
pub ready: bool,
@@ -285,6 +286,7 @@ pub struct Center {
impl Center {
pub fn new(config: ferese_config::notifications::NotificationConfig, motion: super::motion::Settings) -> Self {
Self {
activity_bridge: false,
service: Service::start(&config),
ready: false,
dnd: config.do_not_disturb,
@@ -306,6 +308,12 @@ impl Center {
.try_send(Command::Local(title.into(), body.into()));
}
pub fn configure_activity_bridge(&mut self, enabled: bool) {
self.activity_bridge = enabled;
if enabled {
self.toasts.clear();
}
}
pub fn configure(&mut self, config: ferese_config::notifications::NotificationConfig) {
if self.config.do_not_disturb != config.do_not_disturb {
self.dnd = config.do_not_disturb;
@@ -516,7 +524,7 @@ impl Center {
}
}
notice.unread = !self.history_open;
let show = self.config.show_popups && (!self.dnd || notice.critical);
let show = !self.activity_bridge && self.config.show_popups && (!self.dnd || notice.critical);
if show {
self.toasts.push_back(Toast {
id: notice.id,
@@ -553,7 +561,7 @@ impl Center {
}
self.toasts.retain(|toast| !ids.contains(&toast.id));
}
if !show {
if !show && !self.activity_bridge {
self.close(id, 1);
}
}
@@ -562,11 +570,21 @@ impl Center {
cosmic::iced::Subscription::run_with(self.service.events.clone(), Events::stream)
}
// Notices presented by Activities still own their timeout, even without a toast.
fn hidden_deadlines(&self) -> impl Iterator<Item = (u32, Instant)> + '_ {
self.entries.iter().filter_map(|notice| {
(notice.live && !self.toasts.iter().any(|toast| toast.id == notice.id))
.then(|| notice.timeout.map(|timeout| (notice.id, notice.received_at + timeout)))
.flatten()
})
}
fn expiry_deadline(&self) -> Option<Instant> {
self.toasts
.iter()
.filter(|toast| !toast.hovered && toast.closing.is_none())
.filter_map(|toast| toast.remaining.map(|remaining| self.last_tick + remaining))
.chain(self.hidden_deadlines().map(|(_, deadline)| deadline))
.min()
}
@@ -610,7 +628,10 @@ impl Center {
let now = Instant::now();
let delta = now.saturating_duration_since(self.last_tick);
self.last_tick = now;
let mut expired = Vec::new();
let mut expired: Vec<_> = self
.hidden_deadlines()
.filter_map(|(id, deadline)| (deadline <= now).then_some(id))
.collect();
for toast in &mut self.toasts {
if toast.closing.is_none()
&& !toast.hovered
@@ -643,6 +664,7 @@ mod tests {
let (_, events) = tokio::sync::mpsc::channel(256);
let (commands, receiver) = mpsc::sync_channel(256);
let center = Center {
activity_bridge: false,
config: NotificationConfig::default(),
motion: Default::default(),
service: Service {
@@ -681,6 +703,57 @@ mod tests {
}
}
#[test]
fn bridge_creates_an_activity_and_removes_it_on_expiry_and_dismissal() {
use cosmic::Application;
let mut app = crate::tests::shell_with_measured_panel();
app.snapshot.outputs.push(crate::control::OutputSnapshot {
id: 4,
name: "test".into(),
active_workspace: 1,
focused: true,
});
let (center, _commands) = fixture();
app.notifications = center;
app.config.activities.sources.notification_bridge = true;
app.notifications.configure_activity_bridge(true);
let bar = app.outputs[0].bar;
let mut multilingual = notice(1);
multilingual.title = "通知🛠️".repeat(100);
multilingual.app = "アプリ".repeat(100);
drop(app.update(crate::Message::NotificationEvent(Event::Notice(multilingual))));
assert!(app.activity_available(bar));
assert!(app.notifications.toasts.is_empty());
app.notifications.entries[0].received_at = Instant::now() - Duration::from_secs(7);
drop(app.update(crate::Message::NotificationTick));
assert!(!app.activity_available(bar));
drop(app.update(crate::Message::NotificationEvent(Event::Notice(notice(2)))));
assert!(app.activity_available(bar));
drop(app.update(crate::Message::DismissNotification(2)));
assert!(!app.activity_available(bar));
}
#[test]
fn bridged_notice_stays_live_without_toast_until_timeout_or_dismissal() {
let (mut center, commands) = fixture();
center.configure_activity_bridge(true);
center.receive(notice(1));
assert!(center.toasts.is_empty());
assert!(center.entries[0].live && center.entries[0].unread);
assert!(commands.try_recv().is_err());
assert!(center.expiry_deadline().is_some());
center.entries[0].received_at = Instant::now() - Duration::from_secs(7);
center.tick();
assert!(!center.entries[0].live);
assert!(matches!(commands.try_recv(), Ok(Command::Close(1, 1, 1))));
let mut persistent = notice(2);
persistent.timeout = None;
center.receive(persistent);
assert!(center.expiry_deadline().is_none());
center.dismiss(2);
assert!(matches!(commands.try_recv(), Ok(Command::Close(2, 1, 2))));
}
#[test]
fn hidden_toast_closes_do_not_wait_for_unavailable_frame_callbacks() {
let (mut center, _) = fixture();