yoda: smooth animated fisheye for dock hover (phase B v3, closes b)

Inter-icon hover changes were snapping because icon_scale_for read the
hovered icon's real rectangle directly. This adds a small animation
layer so the bell center lerps toward the target and the whole effect
fades in/out at the dock's edges.

CosmicAppList gains three fields:
- anim_hover_center: Option<(f32, f32)> — virtual cursor position,
  chases the hovered icon's center across ticks.
- anim_hover_intensity: f32 (0..1) — global fade-in/out of the
  fisheye. Target 1.0 while a dock icon is hovered, 0.0 otherwise.
- anim_last_tick: Option<Instant> — for dt-based exponential smoothing
  (time-constant tau = 60ms, ~99% of target reached in ~120ms).

A new Message::AnimTick(Instant) is emitted at ~60 fps by a
conditional iced::time::every subscription — only active when the
pointer is over a dock icon OR intensity hasn't faded back to ~0 yet,
so the panel stays idle when no one is hovering the dock.

icon_scale_for now reads anim_hover_center instead of rectangles[hovered]
and multiplies the bell's peak by anim_hover_intensity. Behaviour:
- Pointer slides A → B: bell glides continuously, both icons animate.
- Pointer enters dock: icons inflate smoothly over ~120 ms.
- Pointer leaves dock: icons deflate smoothly over ~120 ms.

Fallback paths (first frame, missing rectangles) still respond
instantly so the feature never looks 'stuck' before the animation
kicks in.
This commit is contained in:
Lionel DARNIS 2026-04-24 14:15:43 +02:00 committed by Lionel DARNIS
parent 8fc11581ad
commit 0fa93ba21f

View file

@ -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<DockItemId>,
/// 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<std::time::Instant>,
overflow_favorites_popup: Option<window::Id>,
overflow_active_popup: Option<window::Id>,
}
@ -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<DockItemId>),
/// 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<Message> {
// 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(