diff --git a/cosmic-app-list/src/app.rs b/cosmic-app-list/src/app.rs index 932ffd6c..d4d579a1 100755 --- a/cosmic-app-list/src/app.rs +++ b/cosmic-app-list/src/app.rs @@ -187,6 +187,11 @@ impl DockItem { dot_border_radius: [f32; 4], window_id: window::Id, filter: Option<&dyn Fn(&ToplevelInfo) -> bool>, + // Yoda: multiplier on the computed icon size (1.0 = default, + // >1.0 = magnified e.g. on hover for the macOS Tahoe effect). + // Applied to the icon's rendered width/height only — indicator + // dot and surrounding layout stay at base size. + icon_scale: f32, ) -> Element<'_, Message> { let Self { toplevels, @@ -205,17 +210,35 @@ impl DockItem { }; let toplevel_count = filtered_toplevels.len(); + // Cairo-like : pastille plus petite + atténuée quand toutes les fenêtres + // de cette app sont minimisées. + let all_minimized = toplevel_count > 0 + && filtered_toplevels + .iter() + .all(|(info, _)| info.state.contains(&State::Minimized)); + let app_icon = AppletIconData::new(applet); + // Yoda: scaled icon size for hover magnification. Clamped so + // tiny floats don't round to 0 and huge ones stay within u16. + let scaled_icon_size = ((f32::from(app_icon.icon_size) * icon_scale).round() as i32) + .clamp(1, u16::MAX as i32) as u16; let cosmic_icon = cosmic::widget::icon( fde::IconSource::from_unknown(desktop_info.icon().unwrap_or_default()).as_cosmic_icon(), ) // sets the preferred icon size variant .size(128) - .width(app_icon.icon_size.into()) - .height(app_icon.icon_size.into()); + .width(scaled_icon_size.into()) + .height(scaled_icon_size.into()); let indicator = { + // Padding réduit quand minimisée → pastille plus petite. + let effective_radius = if all_minimized { + (app_icon.dot_radius * 0.55).max(1.0) + } else { + app_icon.dot_radius + }; + let container = if toplevel_count <= 1 { vertical_space().height(Length::Fixed(0.0)) } else { @@ -229,22 +252,34 @@ impl DockItem { } } .apply(container) - .padding(app_icon.dot_radius); + .padding(effective_radius); if toplevel_count == 0 { container } else { - container.class(theme::Container::custom(move |theme| container::Style { - background: if is_focused { - Some(Background::Color(theme.cosmic().accent_color().into())) + container.class(theme::Container::custom(move |theme| { + let cosmic = theme.cosmic(); + let accent: iced::Color = cosmic.accent_color().into(); + let on_bg: iced::Color = cosmic.on_bg_color().into(); + // Teinte neutre atténuée quand toutes les fenêtres sont minimisées. + let muted = iced::Color { a: 0.45, ..on_bg }; + + let fill = if all_minimized { + muted + } else if is_focused { + accent } else { - Some(Background::Color(theme.cosmic().on_bg_color().into())) - }, - border: Border { - radius: dot_border_radius.into(), + on_bg + }; + + container::Style { + background: Some(Background::Color(fill)), + border: Border { + radius: dot_border_radius.into(), + ..Default::default() + }, ..Default::default() - }, - ..Default::default() + } })) } }; @@ -374,6 +409,21 @@ struct CosmicAppList { output_list: FxHashMap, locales: Vec, hovered_toplevel: Option, + /// Yoda: which dock icon the pointer is currently over (for hover + /// magnification). None = no dock icon hovered. + hovered_dock_item: Option, + /// Yoda: animated "virtual cursor" center used by the fisheye + /// formula — lerps toward the real hovered icon's center on each + /// AnimTick, so the bell curve slides smoothly from one icon to the + /// next instead of snapping. + anim_hover_center: Option<(f32, f32)>, + /// Yoda: fade-in/out intensity of the magnification effect + /// (0.0 = icons flat, 1.0 = full fisheye). Targets 1.0 while the + /// pointer is over any dock icon, 0.0 otherwise. Lerped on AnimTick. + anim_hover_intensity: f32, + /// Yoda: timestamp of the last AnimTick, for dt-based exponential + /// smoothing. `None` on first tick. + anim_last_tick: Option, overflow_favorites_popup: Option, overflow_active_popup: Option, } @@ -389,6 +439,13 @@ enum Message { Wayland(WaylandUpdate), PinApp(u32), UnpinApp(u32), + /// Yoda: pointer entered (Some) or left (None) a dock icon — drives + /// the macOS Tahoe-style hover magnification effect. + DockItemHover(Option), + /// Yoda: ticked at ~60fps by the animation subscription. Advances + /// anim_hover_center + anim_hover_intensity toward their targets so + /// the fisheye effect transitions smoothly instead of snapping. + AnimTick(std::time::Instant), Popup(u32, window::Id), Pressed(window::Id), ToplevelListPopup(u32, window::Id), @@ -647,6 +704,79 @@ impl CosmicAppList { .collect::>(); } + /// Yoda: macOS-Tahoe fisheye-style magnification. Returns the + /// per-icon size multiplier based on the distance (in pixels) from + /// the currently hovered icon's center to this icon's center. + /// + /// Uses a gaussian bell curve so the hovered icon peaks at + /// 1.0 + PEAK, immediate neighbors still bulge noticeably, and icons + /// further away relax back to 1.0× — that's the smooth neighbour + /// deformation people associate with the macOS Dock. + /// + /// Falls back to binary 1.3×/1.0× when the rectangle tracker hasn't + /// populated yet (first render, or just after layout changes). + /// + /// Uses the animated hover center (anim_hover_center) and intensity + /// (anim_hover_intensity) so inter-icon transitions slide smoothly + /// and the whole effect fades in/out at the dock's edges. + fn icon_scale_for(&self, id: &DockItemId) -> f32 { + const PEAK: f32 = 0.35; + // sigma expressed in multiples of the hovered icon's size — + // 1.4 means the ±1 neighbors sit ~0.7σ away and still bulge + // visibly, while ±3+ has collapsed to ~1.0× (fisheye footprint + // close to 5 icons wide, Tahoe-ish). + const SIGMA_FACTOR: f32 = 1.4; + + // No intensity at all → skip the rest. + if self.anim_hover_intensity < 0.001 { + return 1.0; + } + + // Prefer the animated center (smooth); fall back to the real + // hovered icon's center (first render, before tick fires). + let hover_center = self.anim_hover_center.or_else(|| { + let hovered_id = self.hovered_dock_item.as_ref()?; + let r = self.rectangles.get(hovered_id)?; + Some((r.x + r.width / 2.0, r.y + r.height / 2.0)) + }); + let Some(hover_center) = hover_center else { + // No coords yet — visibly peak on the exact hovered id so + // the very first frame still responds. + return if self.hovered_dock_item.as_ref() == Some(id) { + 1.0 + PEAK * self.anim_hover_intensity + } else { + 1.0 + }; + }; + + let this_rect = match self.rectangles.get(id) { + Some(r) => r, + None => return 1.0, + }; + + let is_horizontal = matches!( + self.core.applet.anchor, + PanelAnchor::Top | PanelAnchor::Bottom + ); + let this_center = if is_horizontal { + this_rect.x + this_rect.width / 2.0 + } else { + this_rect.y + this_rect.height / 2.0 + }; + let hover_axis = if is_horizontal { hover_center.0 } else { hover_center.1 }; + let distance = (this_center - hover_axis).abs(); + let icon_extent = if is_horizontal { + this_rect.width + } else { + this_rect.height + } + .max(1.0); + let sigma = icon_extent * SIGMA_FACTOR; + // exp(-t²) bell curve, t = distance / sigma + let t = distance / sigma; + 1.0 + PEAK * self.anim_hover_intensity * (-t * t).exp() + } + fn is_on_current_monitor_and_workspace(&self, toplevel_info: &ToplevelInfo) -> bool { use cosmic_app_list_config::ToplevelFilter; @@ -1582,6 +1712,52 @@ impl cosmic::Application for CosmicAppList { Message::GpuRequest(gpus) => { self.gpus = gpus; } + Message::DockItemHover(id) => { + self.hovered_dock_item = id; + // Seed the animated center on the very first hover so + // the bell doesn't "fly in" from (0,0). + if self.anim_hover_center.is_none() + && let Some(hovered_id) = self.hovered_dock_item.as_ref() + && let Some(r) = self.rectangles.get(hovered_id) + { + self.anim_hover_center = Some(( + r.x + r.width / 2.0, + r.y + r.height / 2.0, + )); + } + } + Message::AnimTick(now) => { + // dt-based exponential smoothing: reach ~99% of the + // target in ~120ms at 60fps (about 7 ticks). + let dt = self + .anim_last_tick + .map(|prev| now.saturating_duration_since(prev).as_secs_f32()) + .unwrap_or(0.016) + .min(0.1); // clamp so a long pause doesn't snap + self.anim_last_tick = Some(now); + + // Intensity: target 1.0 when any icon is hovered, 0.0 else. + let intensity_target = if self.hovered_dock_item.is_some() { 1.0 } else { 0.0 }; + let tau = 0.060_f32; // time-constant (s); smaller = snappier + let alpha = 1.0 - (-dt / tau).exp(); + self.anim_hover_intensity += (intensity_target - self.anim_hover_intensity) * alpha; + + // Hovered-center smoothing: chase the real rect's center. + if let Some(hovered_id) = self.hovered_dock_item.as_ref() + && let Some(r) = self.rectangles.get(hovered_id) + { + let target = (r.x + r.width / 2.0, r.y + r.height / 2.0); + let current = self.anim_hover_center.unwrap_or(target); + self.anim_hover_center = Some(( + current.0 + (target.0 - current.0) * alpha, + current.1 + (target.1 - current.1) * alpha, + )); + } else if self.anim_hover_intensity < 0.01 { + // Nothing hovered + intensity faded out → forget the + // animated center so the next hover seeds fresh. + self.anim_hover_center = None; + } + } Message::OpenActive => { let create_new = self.overflow_active_popup.is_none(); let mut cmds = vec![self.close_popups()]; @@ -1763,7 +1939,9 @@ impl cosmic::Application for CosmicAppList { .filter(|(info, _)| self.is_on_current_monitor_and_workspace(info)) .any(|y| focused_item.contains(&y.0.foreign_toplevel)); - self.core + let dock_id = dock_item.id; + let icon_scale = self.icon_scale_for(&DockItemId::from(dock_id)); + let tooltip = self.core .applet .applet_tooltip::( dock_item.as_icon( @@ -1776,6 +1954,7 @@ impl cosmic::Application for CosmicAppList { dot_radius, self.core.main_window_id().unwrap(), Some(&|info| self.is_on_current_monitor_and_workspace(info)), + icon_scale, ), dock_item .desktop_info @@ -1785,7 +1964,10 @@ impl cosmic::Application for CosmicAppList { self.popup.is_some(), Message::Surface, None, - ) + ); + cosmic::widget::mouse_area(tooltip) + .on_enter(Message::DockItemHover(Some(DockItemId::from(dock_id)))) + .on_exit(Message::DockItemHover(None)) .into() }) .collect(); @@ -1836,6 +2018,10 @@ impl cosmic::Application for CosmicAppList { dot_radius, self.core.main_window_id().unwrap(), Some(&|info| self.is_on_current_monitor_and_workspace(info)), + // Yoda: no magnification on DnD-preview icons — these + // float around as the user drags, so static 1.0 is + // less visually confusing. + 1.0, ), ); } else if self.is_listening_for_dnd && self.pinned_list.is_empty() { @@ -1875,8 +2061,10 @@ impl cosmic::Application for CosmicAppList { .iter() .filter(|(info, _)| self.is_on_current_monitor_and_workspace(info)) .any(|y| focused_item.contains(&y.0.foreign_toplevel)); + let dock_id = dock_item.id; + let icon_scale = self.icon_scale_for(&DockItemId::from(dock_id)); - self.core + let tooltip = self.core .applet .applet_tooltip( dock_item.as_icon( @@ -1889,6 +2077,7 @@ impl cosmic::Application for CosmicAppList { dot_radius, self.core.main_window_id().unwrap(), Some(&|info| self.is_on_current_monitor_and_workspace(info)), + icon_scale, ), dock_item .desktop_info @@ -1898,7 +2087,10 @@ impl cosmic::Application for CosmicAppList { self.popup.is_some(), Message::Surface, None, - ) + ); + cosmic::widget::mouse_area(tooltip) + .on_enter(Message::DockItemHover(Some(DockItemId::from(dock_id)))) + .on_exit(Message::DockItemHover(None)) .into() }) .collect(); @@ -2313,6 +2505,10 @@ impl cosmic::Application for CosmicAppList { dot_radius, id, Some(&|info| self.is_on_current_monitor_and_workspace(info)), + // Yoda: icons in the overflow popup are + // already smaller-grid — keep them at 1.0 + // so the popup doesn't reshuffle on hover. + 1.0, ), dock_item .desktop_info @@ -2421,6 +2617,10 @@ impl cosmic::Application for CosmicAppList { dot_radius, id, Some(&|info| self.is_on_current_monitor_and_workspace(info)), + // Yoda: popup icons stay at 1.0 (hover + // magnification is applied to the main + // dock row only). + 1.0, ), dock_item .desktop_info @@ -2479,7 +2679,21 @@ impl cosmic::Application for CosmicAppList { } fn subscription(&self) -> Subscription { + // Yoda: ~60fps animation ticks for the fisheye magnification. + // Only emitted when an animation is actually in progress (hover + // intensity >0 OR still fading out) — keeps the panel idle when + // the pointer is nowhere near the dock. + let anim_active = self.hovered_dock_item.is_some() + || self.anim_hover_intensity > 0.001; + let anim_subscription = if anim_active { + cosmic::iced::time::every(std::time::Duration::from_millis(16)) + .map(Message::AnimTick) + } else { + Subscription::none() + }; + Subscription::batch([ + anim_subscription, wayland_subscription().map(Message::Wayland), listen_with(|e, _, id| match e { cosmic::iced::core::Event::PlatformSpecific(event::PlatformSpecific::Wayland( diff --git a/cosmic-app-list/src/wayland_handler.rs b/cosmic-app-list/src/wayland_handler.rs index 03c16973..d27409dc 100644 --- a/cosmic-app-list/src/wayland_handler.rs +++ b/cosmic-app-list/src/wayland_handler.rs @@ -388,7 +388,10 @@ impl CaptureData { }, ) .unwrap(); - self.conn.flush().unwrap(); + if let Err(err) = self.conn.flush() { + tracing::error!("Wayland flush failed during screencopy session create: {err}"); + return None; + } let formats = session .wait_while(|data| data.formats.is_none()) @@ -437,7 +440,10 @@ impl CaptureData { session: capture_session.clone(), }, ); - self.conn.flush().unwrap(); + if let Err(err) = self.conn.flush() { + tracing::error!("Wayland flush failed during screencopy capture: {err}"); + return None; + } // TODO: wait for server to release buffer? let res = session @@ -709,7 +715,10 @@ pub(crate) fn wayland_handler( if app_data.exit { break; } - event_loop.dispatch(None, &mut app_data).unwrap(); + if let Err(err) = event_loop.dispatch(None, &mut app_data) { + tracing::error!("Wayland event loop terminated: {err}"); + break; + } } } diff --git a/cosmic-applet-notifications/src/lib.rs b/cosmic-applet-notifications/src/lib.rs index 438696b9..f759fd88 100644 --- a/cosmic-applet-notifications/src/lib.rs +++ b/cosmic-applet-notifications/src/lib.rs @@ -16,7 +16,7 @@ use cosmic::{ Alignment, Length, Subscription, advanced::text::{Ellipsize, EllipsizeHeightLimit}, platform_specific::shell::wayland::commands::popup::{destroy_popup, get_popup}, - widget::{self, column, rich_text, row}, + widget::{self, column, row}, window, }, surface, theme, @@ -26,7 +26,7 @@ use cosmic::{ use cosmic::iced::futures::executor::block_on; use cosmic_notifications_config::NotificationsConfig; -use cosmic_notifications_util::{ActionId, Image, Notification, markup}; +use cosmic_notifications_util::{ActionId, Image, Notification}; use std::{borrow::Cow, collections::HashMap, path::PathBuf, sync::LazyLock}; use subscriptions::notifications::{self, NotificationsAppletProxy}; use tokio::sync::mpsc::Sender; @@ -456,11 +456,8 @@ impl cosmic::Application for Notifications { column![ text::body(n.summary.lines().next().unwrap_or_default()) .width(Length::Fill), - Element::from( - rich_text(markup::html_to_spans(&n.body)) - .size(12.0) - .width(Length::Fill) - ) + text::caption(n.body.lines().next().unwrap_or_default()) + .width(Length::Fill) ] ) .width(Length::Fill), diff --git a/cosmic-applets/src/main.rs b/cosmic-applets/src/main.rs index f62bbf21..dfd541d1 100644 --- a/cosmic-applets/src/main.rs +++ b/cosmic-applets/src/main.rs @@ -12,26 +12,44 @@ fn main() -> cosmic::iced::Result { }; let start = applet.rfind('/').map_or(0, |v| v + 1); - let cmd = &applet.as_str()[start..]; + let cmd = applet.as_str()[start..].to_string(); tracing::info!("Starting `{cmd}` with version {VERSION}"); - match cmd { - "cosmic-app-list" => cosmic_app_list::run(), - "cosmic-applet-a11y" => cosmic_applet_a11y::run(), - "cosmic-applet-audio" => cosmic_applet_audio::run(), - "cosmic-applet-battery" => cosmic_applet_battery::run(), - "cosmic-applet-bluetooth" => cosmic_applet_bluetooth::run(), - "cosmic-applet-minimize" => cosmic_applet_minimize::run(), - "cosmic-applet-network" => cosmic_applet_network::run(), - "cosmic-applet-notifications" => cosmic_applet_notifications::run(), - "cosmic-applet-power" => cosmic_applet_power::run(), - "cosmic-applet-status-area" => cosmic_applet_status_area::run(), - "cosmic-applet-tiling" => cosmic_applet_tiling::run(), - "cosmic-applet-time" => cosmic_applet_time::run(), - "cosmic-applet-workspaces" => cosmic_applet_workspaces::run(), - "cosmic-applet-input-sources" => cosmic_applet_input_sources::run(), - "cosmic-panel-button" => cosmic_panel_button::run(), - _ => Ok(()), + let cmd_for_run = cmd.clone(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + match cmd_for_run.as_str() { + "cosmic-app-list" => cosmic_app_list::run(), + "cosmic-applet-a11y" => cosmic_applet_a11y::run(), + "cosmic-applet-audio" => cosmic_applet_audio::run(), + "cosmic-applet-battery" => cosmic_applet_battery::run(), + "cosmic-applet-bluetooth" => cosmic_applet_bluetooth::run(), + "cosmic-applet-minimize" => cosmic_applet_minimize::run(), + "cosmic-applet-network" => cosmic_applet_network::run(), + "cosmic-applet-notifications" => cosmic_applet_notifications::run(), + "cosmic-applet-power" => cosmic_applet_power::run(), + "cosmic-applet-status-area" => cosmic_applet_status_area::run(), + "cosmic-applet-tiling" => cosmic_applet_tiling::run(), + "cosmic-applet-time" => cosmic_applet_time::run(), + "cosmic-applet-workspaces" => cosmic_applet_workspaces::run(), + "cosmic-applet-input-sources" => cosmic_applet_input_sources::run(), + "cosmic-panel-button" => cosmic_panel_button::run(), + _ => Ok(()), + } + })); + + match result { + Ok(r) => r, + Err(payload) => { + let msg = payload + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "".to_string()); + tracing::error!( + "`{cmd}` panicked (likely compositor disconnect), exiting cleanly: {msg}" + ); + Ok(()) + } } }