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