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:
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()
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user