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

+1 -1
View File
@@ -43,7 +43,7 @@ pub struct Display {
}
pub fn load() -> Result<Vec<Display>, String> {
let mut connection = ferese_ipc::theme::Connection::connect().map_err(|error| error.to_string())?;
let mut connection = ferese_ipc::Connection::connect().map_err(|error| error.to_string())?;
let value = connection.call("get-outputs", json!({}))?;
let values = value.as_array().ok_or("Invalid display response")?;
Ok(values
+1 -1
View File
@@ -800,7 +800,7 @@ impl cosmic::Application for App {
suffix += 1;
}
snapshot.edit(&set(&format!("theme.custom_themes.{id}.file"), path.as_str()))?;
let mut connection = ferese_ipc::theme::Connection::connect().map_err(|e| e.to_string())?;
let mut connection = ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
let value = connection.call(
"theme-preview",
serde_json::json!({"source": snapshot.source, "directory": directory}),
+53 -1
View File
@@ -34,13 +34,14 @@ impl std::fmt::Display for StyleChoice {
}
}
const CATALOG: [CatalogItem; 12] = [
const CATALOG: [CatalogItem; 13] = [
CatalogItem(ItemKind::Overview),
CatalogItem(ItemKind::Workspaces {
style: ferese_panel::WorkspaceStyle::Dots,
}),
CatalogItem(ItemKind::FocusedWindow),
CatalogItem(ItemKind::Media),
CatalogItem(ItemKind::Activities),
CatalogItem(ItemKind::QuickSettings),
CatalogItem(ItemKind::Network),
CatalogItem(ItemKind::Audio),
@@ -197,7 +198,45 @@ impl App {
.into(),
),
);
let maximum = self.draft.number("activities.max_visible", 2.) as u64;
let choices = widget::row([1_u64, 2].into_iter().map(|count| {
widget::button::custom(self.label(count.to_string(), 12.))
.name(format!("Show up to {count} activities"))
.padding([8, 10])
.width(cosmic::iced::Length::Fill)
.class(crate::visuals::panel_button(palette, count == maximum))
.on_press_maybe(
(count != maximum)
.then(|| Message::Change(store::set("activities.max_visible", count))),
)
.into()
}))
.spacing(2);
appearance = appearance.push(self.panel_divider()).push(
self.panel_appearance_row(
"Visible activities",
"Maximum activities in the collapsed and peek island.",
widget::container(choices)
.padding(3)
.class(crate::visuals::surface(palette.sidebar, 9.))
.into(),
),
);
}
let show_badge = self.draft.boolean("activities.show_badge", true);
appearance = appearance.push(self.panel_divider()).push(
self.panel_appearance_row(
"Show activity count",
"Show +n for additional activities.",
widget::container(
ferese_theme::controls::switch(show_badge, palette)
.name("Show activity count")
.on_press(Message::Change(store::set("activities.show_badge", !show_badge))),
)
.align_right(cosmic::iced::Length::Fill)
.into(),
),
);
rows = rows.push(
widget::container(appearance)
.id("panel-appearance-settings")
@@ -327,6 +366,18 @@ impl App {
fn panel_opacity_controls(&self) -> Element<'static, Message> {
use cosmic::iced::{Alignment, Length};
if self.draft.boolean("theme.accessibility.reduce_transparency", false) {
return self.panel_appearance_row(
"Background opacity",
"Reduce transparency is on. Turn it off in Accessibility to change opacity.",
self.settings_button(
"Accessibility",
"M9 18l6-6-6-6",
Some(Message::Page(Page::Accessibility)),
false,
),
);
}
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let tokens = &self.resolved.presented.tokens;
let inherited = if tokens.material.style == "translucent" {
@@ -809,6 +860,7 @@ pub(super) fn item_icon(kind: ItemKind, tint: cosmic::iced::Color) -> widget::ic
ItemKind::Workspaces { .. } => icons::OVERVIEW,
ItemKind::FocusedWindow => icons::DISPLAY,
ItemKind::Media => icons::MEDIA,
ItemKind::Activities => icons::FERESE,
ItemKind::QuickSettings => icons::CONTROL_CENTER,
ItemKind::Network => icons::WIFI_FULL,
ItemKind::Audio => icons::VOLUME_HIGH,
+56 -13
View File
@@ -7,6 +7,7 @@ use ferese_theme::panel::{Sample, Spacing, frame};
use crate::{App, Element, Message, panel_controls, visuals};
const SAMPLE_AVAILABILITY: Availability = Availability {
activities: true,
media: true,
network: true,
audio: true,
@@ -19,6 +20,18 @@ fn available(item: &Item) -> bool {
item.available(SAMPLE_AVAILABILITY)
}
fn panel_background(theme: &ferese_config::theme::ResolvedTheme, panel: &Panel) -> cosmic::iced::Color {
let fill = if panel.style == ferese_panel::PanelStyle::Morph {
&theme.tokens.colors.surface_raised
} else {
&theme.tokens.surface.bar.background
};
cosmic::iced::Color {
a: theme.panel_opacity(panel.background_opacity),
..visuals::color(fill, cosmic::iced::Color::TRANSPARENT)
}
}
impl App {
pub(super) fn panel_preview(&self) -> Element<'_, Message> {
let panel = match self.preview_panel() {
@@ -115,14 +128,7 @@ impl App {
groups: Vec::new(),
scale: self.render_scale,
high_contrast: palette.high_contrast,
background: cosmic::iced::Color {
a: if self.resolved.presented.accessibility.reduce_transparency {
1.
} else {
panel.background_opacity.unwrap_or(palette.card.a)
},
..palette.card
},
background: panel_background(&self.resolved.presented, &panel),
border: if panel.border {
visuals::color(
&self.resolved.presented.tokens.colors.border,
@@ -194,7 +200,7 @@ impl App {
ferese_panel::shape::ZonePosition::End,
)))
.collect();
let opacity = self.resolved.presented.panel_opacity(panel.background_opacity);
let background = panel_background(&self.resolved.presented, &panel);
let height = panel.resolved_geometry().height;
let padding = panel.resolved_geometry().inner_padding;
let border = panel.border;
@@ -265,10 +271,7 @@ impl App {
groups,
scale: self.render_scale,
high_contrast: palette.high_contrast,
background: cosmic::iced::Color {
a: opacity,
..palette.card
},
background,
border: if border {
visuals::color(
&self.resolved.presented.tokens.colors.border,
@@ -367,6 +370,7 @@ impl App {
ItemKind::Workspaces { .. } => icons::OVERVIEW,
ItemKind::FocusedWindow => icons::DISPLAY,
ItemKind::Media => icons::MEDIA,
ItemKind::Activities => icons::FERESE,
ItemKind::QuickSettings => icons::CONTROL_CENTER,
ItemKind::Network => icons::WIFI_FULL,
ItemKind::Audio => icons::VOLUME_HIGH,
@@ -380,6 +384,14 @@ impl App {
let label = match (item.kind, representation) {
(ItemKind::Workspaces { .. }, _) => "1 2 3",
(ItemKind::FocusedWindow, _) => "Settings",
(ItemKind::Activities, Representation::Wide) => {
if self.draft.boolean("activities.show_badge", true) {
"Build · 62% +1"
} else {
"Build · 62%"
}
}
(ItemKind::Activities, Representation::Compact) => "Build",
(ItemKind::Media, Representation::Wide) => "Song title · Artist ▷",
(ItemKind::Media, Representation::Compact) => "Song title",
(ItemKind::Battery { percentage: true }, Representation::Wide) => "75%",
@@ -478,6 +490,37 @@ mod tests {
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, renderer::Headless, widget};
use cosmic::iced::{Event, Font, Pixels, Point, Rectangle, Size};
#[test]
fn preview_uses_bar_tokens_and_inherited_opacity_for_every_available_style() {
let mut theme = ferese_config::theme::default_theme();
theme.tokens.colors.surface_raised = "#112233".into();
theme.tokens.surface.bar.background = "#abcdef".into();
theme.tokens.material.style = "translucent".into();
theme.tokens.material.opacity = 0.35;
for style in ferese_panel::PanelStyle::AVAILABLE {
let mut panel = Panel::from_preset(style);
let expected = if style == ferese_panel::PanelStyle::Morph {
cosmic::iced::Color::from_rgb8(0x11, 0x22, 0x33)
} else {
cosmic::iced::Color::from_rgb8(0xab, 0xcd, 0xef)
};
for (override_opacity, opacity) in [(None, 0.35), (Some(0.), 0.), (Some(0.7), 0.7)] {
panel.background_opacity = override_opacity;
for reduce in [false, true] {
theme.accessibility.reduce_transparency = reduce;
assert_eq!(
panel_background(&theme, &panel),
cosmic::iced::Color {
a: if reduce { 1. } else { opacity },
..expected
},
"{style:?}, opacity={override_opacity:?}, reduce={reduce}",
);
}
}
}
}
#[derive(Default)]
struct Bounds {
targets: Vec<(widget::Id, ItemId)>,
+27
View File
@@ -232,6 +232,33 @@ pub fn reload_running() -> bool {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn activity_display_settings_roundtrip_without_changing_rules() {
for source in [
"",
"// keep this rule\nactivities { rule cargo { match \"^cargo\"; title Custom; }; }\n",
] {
let mut snapshot = Snapshot::parse(source.into()).unwrap();
let rules = snapshot.doc.value()["activities"]["rules"].clone();
snapshot.edit(&set("activities.max_visible", 1)).unwrap();
snapshot.edit(&set("activities.show_badge", false)).unwrap();
let loaded = Snapshot::parse(snapshot.source.clone()).unwrap();
let settings: ferese_config::activities::Settings =
serde_json::from_value(loaded.doc.value()["activities"].clone()).unwrap();
assert_eq!(settings.limits().max_visible, 1);
assert!(!settings.limits().show_badge);
if !source.is_empty() {
assert_eq!(loaded.doc.value()["activities"]["rules"], rules);
assert!(loaded.source.contains("// keep this rule"));
}
snapshot.edit(&set("activities.max_visible", 2)).unwrap();
snapshot.edit(&set("activities.show_badge", true)).unwrap();
let loaded = Snapshot::parse(snapshot.source).unwrap();
assert_eq!(loaded.number("activities.max_visible", 0.), 2.);
assert!(loaded.boolean("activities.show_badge", false));
}
}
#[test]
fn edits_preserve_comments_custom_keys_and_other_rules() {
let source = r#"// personal theme