Compare commits

...
Sign in to create a new pull request.

4 commits

Author SHA1 Message Date
Votre Nom
22d6f353f1 fix(wayland): graceful exit on compositor disconnect
Some checks failed
Continuous Integration / formatting (push) Has been cancelled
Continuous Integration / linting (push) Has been cancelled
Replace 3 panicking unwrap() in cosmic-app-list/wayland_handler.rs
(event loop dispatch + 2 conn.flush in screencopy) with logged
errors that break/return None instead.

Wrap cosmic-applets/main.rs entry point in panic::catch_unwind to
catch panics propagating from libcosmic/iced/winit (which we cannot
patch locally without forking) when the COSMIC compositor closes
the Wayland connection at logout. This eliminates the cascade of
~12 SIGABRT coredumps observed at session shutdown.

Panic strategy is unwind (default), catch_unwind is sound here.

Leyoda 2026 – GPLv3
2026-04-26 11:10:19 +02:00
28010fd260 yoda: smooth animated fisheye for dock hover (phase B v3, closes b)
Some checks failed
Continuous Integration / formatting (push) Has been cancelled
Continuous Integration / linting (push) Has been cancelled
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.
2026-04-24 14:15:43 +02:00
a7cd859317 yoda: fisheye magnification for dock hover (phase B v2 / c)
Some checks are pending
Continuous Integration / formatting (push) Waiting to run
Continuous Integration / linting (push) Waiting to run
Replaces the binary 1.3× hover with a true gaussian bell curve — the
hovered icon still peaks at ~1.35×, but the ±1 neighbours also bulge
noticeably, ±2 a bit, and ±3+ relax to 1.0×. Footprint ~5 icons wide,
matching the macOS Dock fisheye feel.

Implementation in fn icon_scale_for(id):
- Reads the hovered icon's and the current icon's bounds from
  self.rectangles (already populated by the existing RectangleTracker
  subscription — no new plumbing).
- Distance = |this_center - hovered_center| along the panel's long axis
  (horizontal for Top/Bottom anchors, vertical for Left/Right).
- sigma = hovered_extent * 1.4 so the bell's half-width matches one
  icon width (neighbors clearly pulled, far icons untouched).
- scale = 1.0 + PEAK * exp(-(d/sigma)²) with PEAK = 0.35.
- Falls back to binary 1.35×/1.0× when rectangle data isn't populated
  yet (first render / resize) — visibly responsive even before the
  tracker catches up.

No widget signature changes vs v1, just a smarter formula. All five
as_icon call sites already pass the result of icon_scale_for so this
update propagates everywhere.

Still on the TODO list: smooth animation (b). Right now icon→icon
transitions snap instantly; a smoothed_hover_center + tick subscription
would lerp it. Deferred to a follow-up commit.
2026-04-24 13:13:52 +02:00
5cb0dbb0c5 yoda: dock icon hover magnification (macOS Tahoe-style, phase B v1)
Some checks are pending
Continuous Integration / formatting (push) Waiting to run
Continuous Integration / linting (push) Waiting to run
First pass at the signature macOS Dock effect — the icon under the
pointer grows, adjacent icons stay at base size. Full fisheye (smooth
bell-curve scaling on neighbors) can be a later iteration.

Changes in cosmic-app-list/src/app.rs:
- CosmicAppList gains a hovered_dock_item: Option<DockItemId>
  auto-initialized to None via #[derive(Default)].
- New Message::DockItemHover(Option<DockItemId>) handled in update()
  by just writing the field; view() then reads it to decide scale.
- DockItem::as_icon gains an icon_scale: f32 parameter. Inside it the
  cosmic_icon width/height = (base_icon_size * icon_scale) clamped
  to u16; indicator dot and other surrounding layout stay at base
  size so only the icon visually bulges.
- New App::icon_scale_for(id) helper: 1.3 if Some(id) == hovered,
  1.0 otherwise. Single place to tune the magnification factor.
- The two main dock rows (favorites + filtered_active_list) wrap
  their rendered applet_tooltip in widget::mouse_area with
  on_enter(DockItemHover(Some(id))) / on_exit(DockItemHover(None))
  and call icon_scale_for before rendering.
- The three remaining as_icon call sites (DnD preview, favorites
  overflow popup, active overflow popup) pass icon_scale = 1.0 —
  hover magnification on those surfaces would look jittery and isn't
  needed anyway.

Build: cargo build --release -p cosmic-app-list (≈ 7s). Binary
installed at /usr/local/bin/cosmic-app-list, backup kept as
.pre-magnification.
2026-04-24 12:39:19 +02:00
3 changed files with 278 additions and 37 deletions

View file

@ -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<WlOutput, OutputInfo>,
locales: Vec<String>,
hovered_toplevel: Option<ExtForeignToplevelHandleV1>,
/// 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>,
}
@ -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<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),
@ -647,6 +704,79 @@ impl CosmicAppList {
.collect::<Vec<_>>();
}
/// 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::<Message>(
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<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(

View file

@ -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;
}
}
}

View file

@ -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::<String>().cloned())
.unwrap_or_else(|| "<non-string panic>".to_string());
tracing::error!(
"`{cmd}` panicked (likely compositor disconnect), exiting cleanly: {msg}"
);
Ok(())
}
}
}