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

@@ -0,0 +1,88 @@
use std::collections::BTreeMap;
use cosmic::font::Font;
use cosmic::iced::advanced::text::{self, Paragraph};
use ferese_activities::{Activity, Id, Selection};
pub(super) const PADDING: f32 = 14.;
pub(super) const ICON: f32 = 26.;
pub(super) const TEXT_GAP: f32 = 6.;
pub(super) const ITEM_GAP: f32 = 8.;
#[derive(Default)]
pub(super) struct Compact {
font: Option<Font>,
titles: BTreeMap<Id, (String, f32)>,
badge: Option<(usize, f32)>,
text_scale: f32,
pub width: f32,
}
impl Compact {
pub fn update(&mut self, entries: &[&Activity], selection: &Selection, font: Font, available: f32) {
if self.font != Some(font) {
self.font = Some(font);
self.titles.clear();
self.badge = None;
}
self.titles.retain(|id, _| selection.visible.contains(id));
for entry in entries.iter().filter(|entry| selection.visible.contains(&entry.id)) {
let title = &entry.data.title;
if self.titles.get(&entry.id).is_none_or(|(cached, _)| cached != title) {
self.titles.insert(
entry.id.clone(),
(title.clone(), text_width(title, 12., font).ceil().clamp(48., 100.)),
);
}
}
let hidden = selection.hidden;
if self.badge.is_none_or(|(count, _)| count != hidden) {
self.badge = Some((
hidden,
if hidden > 0 {
text_width(&format!("+{hidden}"), 11., font).ceil()
} else {
0.
},
));
}
let count = self.titles.len();
let fixed = 2. * PADDING
+ count as f32 * (ICON + TEXT_GAP)
+ count.saturating_sub(1) as f32 * (2. * ITEM_GAP + 1.)
+ if hidden > 0 {
ITEM_GAP + self.badge.unwrap().1
} else {
0.
};
let text: f32 = self.titles.values().map(|(_, width)| width).sum();
self.text_scale = if text > 0. {
((available - fixed).max(0.) / text).min(1.)
} else {
1.
};
self.width = (fixed + text * self.text_scale).ceil();
}
pub fn text_width(&self, id: &Id) -> f32 {
self.titles.get(id).map_or(48., |(_, width)| width * self.text_scale)
}
}
// Use the same font and shaping as the displayed labels; measure only when a
// title, font or badge count changes. Progress and countdown ticks do not resize.
fn text_width(content: &str, size: f32, font: Font) -> f32 {
<cosmic::Renderer as text::Renderer>::Paragraph::with_text(text::Text {
content,
bounds: cosmic::iced::Size::INFINITE,
size: size.into(),
line_height: text::LineHeight::Relative(1.1),
font,
align_x: Default::default(),
align_y: cosmic::iced::alignment::Vertical::Top,
shaping: Default::default(),
wrapping: text::Wrapping::None,
ellipsize: Default::default(),
})
.min_width()
}
+162
View File
@@ -0,0 +1,162 @@
//! A small ring using Iced's native canvas and Ferese's shared spring. The
//! stock progress widget owns an unconditional easing loop, so this version
//! exposes reduced-motion and visibility policy explicitly.
use crate::Message;
use cosmic::iced::widget::{Action, Canvas, canvas};
use cosmic::iced::{Color, Point, Radians, Rectangle, mouse, window};
use cosmic::{Element, Theme};
use ferese_animation::{AnimatedValue, SpringConfig};
use std::time::{Duration, Instant};
pub(super) fn view<'a>(
progress: Option<f32>,
animate: bool,
reduced: bool,
color: Color,
) -> Element<'a, cosmic::Action<Message>> {
Canvas::new(Ring {
progress,
animate,
reduced,
color,
})
.width(26)
.height(26)
.into()
}
struct Ring {
progress: Option<f32>,
animate: bool,
reduced: bool,
color: Color,
}
pub(crate) struct RingState {
value: AnimatedValue,
sampled: Option<Instant>,
elapsed: Duration,
}
impl Default for RingState {
fn default() -> Self {
Self {
value: AnimatedValue::new(0.),
sampled: None,
elapsed: Duration::ZERO,
}
}
}
impl RingState {
fn advance(&mut self, progress: Option<f32>, animate: bool, reduced: bool, now: Instant) -> bool {
let elapsed = self
.sampled
.map_or(Duration::ZERO, |before| now.saturating_duration_since(before));
let initial = self.sampled.is_none();
self.sampled = Some(now);
self.elapsed = self.elapsed.saturating_add(elapsed);
self.value.retarget_preserving_motion(f64::from(progress.unwrap_or(0.)));
if initial || reduced || !animate {
self.value.snap();
} else {
self.value.advance(
elapsed,
SpringConfig {
stiffness: 700.,
damping: 45.,
position_tolerance: 0.002,
velocity_tolerance: 0.002,
..Default::default()
},
);
}
animate && !reduced && (progress.is_none() || self.value.is_animating())
}
}
impl canvas::Program<cosmic::Action<Message>, Theme, cosmic::Renderer> for Ring {
type State = RingState;
fn update(
&self,
state: &mut RingState,
event: &canvas::Event,
_bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Option<Action<cosmic::Action<Message>>> {
if let canvas::Event::Window(window::Event::RedrawRequested(now)) = event
&& state.advance(self.progress, self.animate, self.reduced, *now)
{
return Some(Action::request_redraw());
}
None
}
fn draw(
&self,
state: &RingState,
renderer: &cosmic::Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry<cosmic::Renderer>> {
let mut frame = canvas::Frame::new(renderer, bounds.size());
let center = Point::new(bounds.width / 2., bounds.height / 2.);
let radius = bounds.width.min(bounds.height) / 2. - 3.;
frame.stroke(
&canvas::Path::circle(center, radius),
canvas::Stroke::default()
.with_color(self.color.scale_alpha(0.15))
.with_width(1.8),
);
let (start, fraction) = if self.progress.is_some() {
(
-std::f32::consts::FRAC_PI_2,
if state.sampled.is_none() {
self.progress.unwrap_or(0.)
} else {
state.value.current as f32
}
.clamp(0., 1.),
)
} else if self.animate && !self.reduced {
(state.elapsed.as_secs_f32() / 3. * std::f32::consts::TAU, 0.23)
} else {
(0., 0.)
};
if fraction > 0. {
let arc = canvas::Path::new(|path| {
path.arc(canvas::path::Arc {
center,
radius,
start_angle: Radians(start),
end_angle: Radians(start + fraction * std::f32::consts::TAU),
})
});
frame.stroke(
&arc,
canvas::Stroke::default()
.with_color(self.color)
.with_width(1.8)
.with_line_cap(canvas::LineCap::Round),
);
}
vec![frame.into_geometry()]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ring_stops_when_settled_hidden_or_reduced_and_keeps_interrupted_velocity() {
let now = Instant::now();
let mut state = RingState::default();
assert!(!state.advance(Some(0.1), true, false, now));
assert!(state.advance(Some(0.6), true, false, now + Duration::from_millis(50)));
let velocity = state.value.velocity;
state.value.retarget_preserving_motion(0.9);
assert_eq!(state.value.velocity, velocity);
for frame in 1..=200 {
state.advance(Some(0.9), true, false, now + Duration::from_millis(50 + frame * 16));
}
assert!(!state.value.is_animating());
assert!(!state.advance(None, false, false, now + Duration::from_secs(5)));
assert!(!state.advance(Some(0.2), true, true, now + Duration::from_secs(6)));
assert_eq!(state.value.current, 0.2_f32 as f64);
assert!(state.advance(None, true, false, now + Duration::from_secs(7)));
}
}
@@ -0,0 +1,62 @@
use std::sync::Arc;
use std::time::Duration;
use cosmic::iced::Subscription;
use cosmic::iced::futures::SinkExt;
use ferese_ipc::activities::Snapshot;
use serde_json::json;
pub(crate) fn subscription() -> Subscription<Arc<Snapshot>> {
Subscription::run(stream)
}
fn stream() -> impl cosmic::iced::futures::Stream<Item = Arc<Snapshot>> {
cosmic::iced::stream::channel(1, async |mut output| {
loop {
let connection = tokio::task::spawn_blocking(ferese_ipc::Connection::connect).await;
let Ok(Ok(mut connection)) = connection else {
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
};
let Ok(cancellation) = connection.cancellation() else {
return;
};
let (send, mut receive) = tokio::sync::watch::channel(None);
tokio::task::spawn_blocking(move || {
let Ok(value) = connection.call("activity-get", json!({})) else {
return;
};
let Ok(mut snapshot) = serde_json::from_value::<Snapshot>(value) else {
return;
};
loop {
let revision = snapshot.revision;
if send.send(Some(Arc::new(snapshot))).is_err() {
return;
}
match connection
.wait("activity-watch", json!({"since":revision}))
.and_then(|value| serde_json::from_value(value).map_err(|e| e.to_string()))
{
Ok(next) => snapshot = next,
Err(_) => return,
}
}
});
while receive.changed().await.is_ok() {
let snapshot = receive.borrow_and_update().clone();
if let Some(snapshot) = snapshot
&& output.send(snapshot).await.is_err()
{
return;
}
}
drop(cancellation);
if output.send(Arc::new(Snapshot::default())).await.is_err() {
return;
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
})
}