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:
parent
a7cd859317
commit
28010fd260
1 changed files with 104 additions and 16 deletions
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue