diff --git a/cosmic-app-list/src/app.rs b/cosmic-app-list/src/app.rs index 7fd4337f..d4d579a1 100755 --- a/cosmic-app-list/src/app.rs +++ b/cosmic-app-list/src/app.rs @@ -412,6 +412,18 @@ struct CosmicAppList { /// 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, } @@ -430,6 +442,10 @@ enum Message { /// 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), @@ -699,6 +715,10 @@ impl CosmicAppList { /// /// 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 — @@ -707,43 +727,54 @@ impl CosmicAppList { // close to 5 icons wide, Tahoe-ish). const SIGMA_FACTOR: f32 = 1.4; - let Some(hovered_id) = self.hovered_dock_item.as_ref() else { + // 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 + }; }; - // Without tracker data we can't compute distance — still peak on - // the hovered one so something visibly responds immediately. - let (Some(hovered_rect), Some(this_rect)) = - (self.rectangles.get(hovered_id), self.rectangles.get(id)) - else { - return if id == hovered_id { 1.0 + PEAK } 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 hovered_center = if is_horizontal { - hovered_rect.x + hovered_rect.width / 2.0 - } else { - hovered_rect.y + hovered_rect.height / 2.0 - }; let this_center = if is_horizontal { this_rect.x + this_rect.width / 2.0 } else { this_rect.y + this_rect.height / 2.0 }; - let distance = (this_center - hovered_center).abs(); + 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 { - hovered_rect.width + this_rect.width } else { - hovered_rect.height + 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 * (-t * t).exp() + 1.0 + PEAK * self.anim_hover_intensity * (-t * t).exp() } fn is_on_current_monitor_and_workspace(&self, toplevel_info: &ToplevelInfo) -> bool { @@ -1683,6 +1714,49 @@ impl cosmic::Application for CosmicAppList { } 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(); @@ -2605,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(