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
@@ -34,7 +34,7 @@ impl Appearance {
let mut theme = ferese_theme_client::service::current().presented;
if let Some(path) = path.filter(|path| ferese_config::config_path().as_deref() != Some(*path))
&& let Some(document) = &doc
&& let Ok(mut connection) = ferese_ipc::theme::Connection::connect()
&& let Ok(mut connection) = ferese_ipc::Connection::connect()
{
let args = serde_json::json!({
"source": document.to_string(),
+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
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
rust-version.workspace = true
[dependencies]
ferese-activities = { path = "../../crates/ferese-activities" }
ferese-layout = { path = "../../crates/ferese-layout" }
ferese-panel = { path = "../../crates/ferese-panel" }
ferese-animation = { path = "../../crates/ferese-animation" }
File diff suppressed because it is too large. Load diff
@@ -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;
}
})
}
+4
View File
@@ -54,6 +54,7 @@ impl FereseShell {
let effects = output.and_then(|output| output.effects.as_ref());
let compositor_material = effects.is_some();
let availability = Availability {
activities: self.activity_available(id),
media: self.media.snapshot.selected.is_some(),
network: self.status.network.is_some(),
audio: self.status.audio.is_some(),
@@ -340,6 +341,9 @@ impl FereseShell {
.into();
return (container(element).max_width(360).into(), !title.is_empty());
}
ItemKind::Activities => {
return self.view_activity_item(id, item, representation, selected, width, control_height);
}
ItemKind::Media => self.view_media_item(representation, selected, width),
ItemKind::QuickSettings => self.view_status_item(status_ui::Menu::System, false, selected),
ItemKind::Network => self.view_status_item(status_ui::Menu::Network, false, selected),
+5
View File
@@ -8,6 +8,7 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
#[derive(Clone, Debug)]
pub(crate) struct ShellConfig {
pub(crate) activities: ferese_config::activities::Settings,
pub(crate) notifications: ferese_config::notifications::NotificationConfig,
pub(crate) animations: crate::motion::Settings,
pub(crate) font_family: Option<String>,
@@ -22,6 +23,7 @@ impl Default for ShellConfig {
fn default() -> Self {
let status = StatusConfig::default();
Self {
activities: Default::default(),
notifications: Default::default(),
animations: Default::default(),
font_family: None,
@@ -183,6 +185,8 @@ pub(crate) enum WallpaperMode {
#[derive(Debug, Default, Deserialize)]
struct FereseConfig {
#[serde(default)]
activities: ferese_config::activities::Settings,
#[serde(default)]
panels: Option<Vec<crate::panel::Panel>>,
#[serde(default)]
@@ -398,6 +402,7 @@ fn parse_document(
theme.bar_radius = theme.material_radius;
Ok(ShellConfig {
activities: config.activities,
notifications: config.notifications,
animations: config.animations,
font_family: config.theme.typography.font_family,
-4
View File
@@ -211,10 +211,6 @@ impl ShellControl {
let _ = self.connection.flush();
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "Window-targeting API; the current UI targets workspaces.")
)]
pub(crate) fn activate_window(&self, id: u64) {
let (hi, lo) = split_id(id);
self.shell.activate_window(hi, lo);
+53 -10
View File
@@ -1,3 +1,4 @@
mod activities;
mod bar;
mod surfaces;
@@ -161,6 +162,7 @@ struct FereseShell {
status: status::Snapshot,
recorder: recording::Recorder,
media: media::Model,
activities: activities::Model,
calendar_offset: i32,
status_error: Option<status::ActionError>,
theme_error: Option<String>,
@@ -219,6 +221,15 @@ enum Message {
MediaAction(serde_json::Value),
MediaCompleted(Result<(), String>),
MediaTick,
ActivitiesUpdated(std::sync::Arc<ferese_ipc::activities::Snapshot>),
ActivityTick,
ActivityHover(window::Id, bool),
ActivityActivate(window::Id, ferese_activities::Id),
ActivityNavigation(
window::Id,
Option<ferese_activities::Id>,
Option<cosmic::iced::keyboard::key::Named>,
),
MediaChoose,
MediaSeek(u64),
MediaSeekCommit,
@@ -294,14 +305,17 @@ impl cosmic::Application for FereseShell {
// The compositor reports the complete configuration error through its
// accepted-theme snapshot. Use the local warning only when standalone.
let pending_config_error = if control.is_none() { startup_error } else { None };
let mut notification_center = notifications::Center::new(config.notifications.clone(), config.animations);
notification_center.activity_bridge = config.activities.sources.notification_bridge;
let app = Self {
core,
notifications: notifications::Center::new(config.notifications.clone(), config.animations),
notifications: notification_center,
notification_surface: None,
status_service: status::Service::start(config.status.settings_command.clone()),
status: status::Snapshot::default(),
recorder: recording::Recorder::default(),
media: media::Model::default(),
activities: activities::Model::default(),
calendar_offset: 0,
status_error: None,
theme_error: None,
@@ -366,6 +380,8 @@ impl cosmic::Application for FereseShell {
_ => None,
}),
clock::subscription(self.outputs.iter().any(|output| !output.hidden)).map(Message::ClockChanged),
activities::subscription::subscription().map(Message::ActivitiesUpdated),
self.activity_tick_subscription().map(|_| Message::ActivityTick),
self.media.subscription().map(Message::MediaUpdated),
self.media_tick_subscription().map(|_| Message::MediaTick),
self.media.art.subscription().map(Message::MediaArt),
@@ -437,6 +453,19 @@ impl cosmic::Application for FereseShell {
)
}
Message::ActivitiesUpdated(snapshot) => {
self.activities.snapshot = snapshot;
self.sync_hub_activity()
}
Message::ActivityTick => self.sync_hub_activity(),
Message::ActivityHover(id, entered) => {
self.activity_hover(id, entered);
self.sync_hub_activity()
}
Message::ActivityActivate(id, activity) => self.activate_activity(id, activity),
Message::ActivityNavigation(id, focused, key) => {
self.activity_navigation(id, focused, key).unwrap_or_else(Task::none)
}
Message::MediaUpdated(snapshot) => {
self.media.receive(snapshot);
self.refresh_media_art(
@@ -488,7 +517,7 @@ impl cosmic::Application for FereseShell {
}
Message::ThemeMode(mode) => cosmic::task::future(async move {
let result = tokio::task::spawn_blocking(move || {
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())?;
connection
.call("theme-set-mode", serde_json::json!({"mode": mode}))
.map(|_| ())
@@ -578,24 +607,35 @@ impl cosmic::Application for FereseShell {
dnd: self.notifications.dnd,
});
}
let hub = notice.map_or_else(Task::none, |notice| self.hub_notice(notice));
Task::batch([hub, self.sync_hub_notices(), self.sync_notification_surface()])
let hub = notice
.filter(|_| !self.config.activities.sources.notification_bridge)
.map_or_else(Task::none, |notice| self.hub_notice(notice));
Task::batch([
hub,
self.sync_hub_activity(),
self.sync_hub_notices(),
self.sync_notification_surface(),
])
}
Message::NotificationTick => {
self.notifications.tick();
Task::batch([self.sync_hub_notices(), self.sync_notification_surface()])
Task::batch([
self.sync_hub_activity(),
self.sync_hub_notices(),
self.sync_notification_surface(),
])
}
Message::DismissNotification(id) => {
self.notifications.close(id, 2);
self.sync_notification_surface()
Task::batch([self.sync_hub_activity(), self.sync_notification_surface()])
}
Message::RemoveNotification(id) => {
self.notifications.dismiss(id);
self.sync_notification_surface()
Task::batch([self.sync_hub_activity(), self.sync_notification_surface()])
}
Message::InvokeNotification(id, action) => {
self.notifications.invoke(id, action);
self.sync_notification_surface()
Task::batch([self.sync_hub_activity(), self.sync_notification_surface()])
}
Message::HoverNotification(id, hovered) => {
self.notifications.hover(id, hovered);
@@ -608,11 +648,11 @@ impl cosmic::Application for FereseShell {
}
Message::RemoveNotificationGroup(app) => {
self.notifications.dismiss_group(&app);
self.sync_notification_surface()
Task::batch([self.sync_hub_activity(), self.sync_notification_surface()])
}
Message::ClearNotifications => {
self.notifications.clear();
self.sync_notification_surface()
Task::batch([self.sync_hub_activity(), self.sync_notification_surface()])
}
Message::AnimateMenu => self.animate_menu(),
Message::PanelAction(message) => {
@@ -956,6 +996,8 @@ impl FereseShell {
let geometry = config.panels[0].resolved_geometry();
let geometry_changed = edge_changed || old_geometry != geometry;
self.notifications.configure(config.notifications.clone());
self.notifications
.configure_activity_bridge(config.activities.sources.notification_bridge);
self.config = config;
if composition_changed || geometry_changed {
for entry in &mut self.outputs {
@@ -1098,6 +1140,7 @@ mod tests {
status: Default::default(),
recorder: Default::default(),
media: Default::default(),
activities: Default::default(),
calendar_offset: 0,
status_error: None,
theme_error: None,
+1 -1
View File
@@ -106,7 +106,7 @@ impl Model {
self.error = None;
cosmic::task::future(async move {
let result = tokio::task::spawn_blocking(move || {
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())?;
connection.call("media-action", args).map(|_| ())
})
.await
+2 -2
View File
@@ -13,7 +13,7 @@ pub(super) fn subscription() -> Subscription<Arc<Snapshot>> {
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::theme::Connection::connect).await;
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;
@@ -93,7 +93,7 @@ mod tests {
return;
}
let path = ferese_ipc::theme::socket_path().unwrap();
let path = ferese_ipc::socket_path().unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let listener = UnixListener::bind(path).unwrap();
let (watch_send, watch_receive) = std::sync::mpsc::channel();
+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();
File diff suppressed because it is too large. Load diff
+21 -5
View File
@@ -8,6 +8,9 @@ use super::{Event, Peek};
use crate::{FereseShell, Message, recording, status_ui};
impl FereseShell {
pub(crate) fn protected_activity_slots(&self) -> usize {
usize::from(self.hub_priority().is_some()) * 2
}
pub(super) fn hub_low_battery(&self) -> Option<u8> {
self.status
.battery
@@ -41,6 +44,17 @@ impl FereseShell {
},
);
}
if self.has_compact_activities(id) {
let width = self
.outputs
.iter()
.find(|entry| entry.bar == id)
.and_then(|entry| entry.runtime.hub.as_ref())
.and_then(|runtime| runtime.borrow().idle_content_width)
.unwrap_or(self.panel_for_bar(id).morph.idle_width);
let workspaces = self.hub_workspace_width(id);
return (Peek::Date, width + if workspaces > 0. { workspaces + 10. } else { 0. });
}
let player = self.media.snapshot.selected.as_ref().filter(|player| {
matches!(
player.status,
@@ -65,6 +79,7 @@ impl FereseShell {
}
pub(crate) fn sync_hub_activity(&mut self) -> Task<Message> {
self.sync_activity_presentations();
let priority = self.hub_priority();
let changes: Vec<_> = self
.outputs
@@ -72,11 +87,12 @@ impl FereseShell {
.filter(|entry| entry.runtime.hub.is_some())
.map(|entry| (entry.bar, self.hub_peek_content(entry.bar)))
.collect();
Task::batch(
changes
.into_iter()
.map(|(id, peek)| self.transition_hub(id, Event::Activity { priority, peek })),
)
Task::batch(changes.into_iter().map(|(id, peek)| {
Task::batch([
self.transition_hub(id, Event::Activity { priority, peek }),
self.transition_hub(id, Event::Resize),
])
}))
}
pub(super) fn hub_activity_strip(
@@ -0,0 +1,84 @@
//! A fixed-width battery silhouette; the percentage is text, not regenerated SVG.
use super::*;
use cosmic::iced::{Background, Border};
use cosmic::widget::Space;
pub(super) const WIDTH: f64 = 34.;
impl FereseShell {
pub(super) fn hub_battery(
&self,
id: window::Id,
active: bool,
opacity: f32,
) -> Option<Element<'_, cosmic::Action<Message>>> {
let battery = self.status.battery.as_ref()?;
let percent = battery.percent.min(100);
let palette = self.config.theme.palette();
let foreground = color_with_opacity(self.config.theme.text_primary, opacity);
let outline = if self.hub_low_battery().is_some() {
palette.error.scale_alpha(opacity)
} else if matches!(battery.status.as_str(), "Charging" | "Pending charge") {
palette.accent.scale_alpha(opacity)
} else {
foreground
};
let fill = container(Space::new().width(20. * f32::from(percent) / 100.).height(10)).class(
theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(outline.scale_alpha(0.16))),
border: Border {
shape: cosmic::iced::border::Shape::Continuous,
radius: 4.0.into(),
..Default::default()
},
..Default::default()
}),
);
let number = self
.render
.text(percent.to_string())
.size(9.5)
.line_height(cosmic::iced::widget::text::LineHeight::Relative(1.))
.class(theme::Text::Color(foreground));
let body = container(cosmic::iced::widget::stack![
container(fill).padding(2).width(24).height(14),
container(number).center_x(24).center_y(14),
])
.width(24)
.height(14)
.class(theme::Container::custom(move |_| container::Style {
border: Border {
color: outline,
width: 1.2,
shape: cosmic::iced::border::Shape::Continuous,
radius: 5.5.into(),
..Default::default()
},
..Default::default()
}));
let terminal =
container(Space::new().width(1.5).height(4)).class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(outline)),
border: Border {
radius: 1.0.into(),
..Default::default()
},
..Default::default()
}));
Some(motion::button(
self.render,
button::custom(row![body, terminal].spacing(1).align_y(Alignment::Center))
.name(format!(
"Battery, {percent} percent, {}. Open battery controls",
battery.status
))
.padding([7, 3])
.width(WIDTH as f32)
.height(28)
.on_press(cosmic::Action::App(Message::HubOpen(id, status_ui::Menu::Battery))),
foreground,
active,
opacity,
))
}
}
@@ -135,7 +135,7 @@ impl FereseShell {
)
}
pub(super) fn hub_workspace_width(&self, id: window::Id) -> f64 {
pub(crate) fn hub_workspace_width(&self, id: window::Id) -> f64 {
f64::from(self.hub_workspace_selection(id).width())
}
@@ -257,7 +257,7 @@ impl FereseShell {
self.dismiss_workspace_tooltip_for_bar(id)
}
pub(super) fn hub_workspaces(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
pub(crate) fn hub_workspaces(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
let selection = self.hub_workspace_selection(id);
let width = selection.width();
let output = self.output_for_bar(id);
+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 [
+13
View File
@@ -258,6 +258,17 @@ impl FereseShell {
{
return self.display_mode_key(key);
}
if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(key),
modifiers,
..
}) = &event
&& !modifiers.control()
&& !modifiers.alt()
&& let Some(task) = self.activity_key(id, *key)
{
return task;
}
if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(key),
modifiers,
@@ -301,6 +312,8 @@ impl FereseShell {
ferese_panel::morph::Content::Workspaces,
)) {
task.chain(self.query_hub_workspace_focus(id, None))
} else if let Some(query) = self.query_activity_focus(id, None) {
task.chain(query)
} else {
task
};