yoda: dock icon hover magnification (macOS Tahoe-style, phase B v1)

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.
This commit is contained in:
Lionel DARNIS 2026-04-24 12:39:19 +02:00 committed by Lionel DARNIS
parent 03c302d138
commit d090e60370

View file

@ -187,6 +187,11 @@ impl DockItem {
dot_border_radius: [f32; 4], dot_border_radius: [f32; 4],
window_id: window::Id, window_id: window::Id,
filter: Option<&dyn Fn(&ToplevelInfo) -> bool>, 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> { ) -> Element<'_, Message> {
let Self { let Self {
toplevels, toplevels,
@ -205,17 +210,35 @@ impl DockItem {
}; };
let toplevel_count = filtered_toplevels.len(); 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); 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( let cosmic_icon = cosmic::widget::icon(
fde::IconSource::from_unknown(desktop_info.icon().unwrap_or_default()).as_cosmic_icon(), fde::IconSource::from_unknown(desktop_info.icon().unwrap_or_default()).as_cosmic_icon(),
) )
// sets the preferred icon size variant // sets the preferred icon size variant
.size(128) .size(128)
.width(app_icon.icon_size.into()) .width(scaled_icon_size.into())
.height(app_icon.icon_size.into()); .height(scaled_icon_size.into());
let indicator = { 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 { let container = if toplevel_count <= 1 {
vertical_space().height(Length::Fixed(0.0)) vertical_space().height(Length::Fixed(0.0))
} else { } else {
@ -229,22 +252,34 @@ impl DockItem {
} }
} }
.apply(container) .apply(container)
.padding(app_icon.dot_radius); .padding(effective_radius);
if toplevel_count == 0 { if toplevel_count == 0 {
container container
} else { } else {
container.class(theme::Container::custom(move |theme| container::Style { container.class(theme::Container::custom(move |theme| {
background: if is_focused { let cosmic = theme.cosmic();
Some(Background::Color(theme.cosmic().accent_color().into())) 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 { } else {
Some(Background::Color(theme.cosmic().on_bg_color().into())) on_bg
}, };
border: Border {
radius: dot_border_radius.into(), container::Style {
background: Some(Background::Color(fill)),
border: Border {
radius: dot_border_radius.into(),
..Default::default()
},
..Default::default() ..Default::default()
}, }
..Default::default()
})) }))
} }
}; };
@ -374,6 +409,9 @@ struct CosmicAppList {
output_list: FxHashMap<WlOutput, OutputInfo>, output_list: FxHashMap<WlOutput, OutputInfo>,
locales: Vec<String>, locales: Vec<String>,
hovered_toplevel: Option<ExtForeignToplevelHandleV1>, 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>,
overflow_favorites_popup: Option<window::Id>, overflow_favorites_popup: Option<window::Id>,
overflow_active_popup: Option<window::Id>, overflow_active_popup: Option<window::Id>,
} }
@ -389,6 +427,9 @@ enum Message {
Wayland(WaylandUpdate), Wayland(WaylandUpdate),
PinApp(u32), PinApp(u32),
UnpinApp(u32), UnpinApp(u32),
/// Yoda: pointer entered (Some) or left (None) a dock icon — drives
/// the macOS Tahoe-style hover magnification effect.
DockItemHover(Option<DockItemId>),
Popup(u32, window::Id), Popup(u32, window::Id),
Pressed(window::Id), Pressed(window::Id),
ToplevelListPopup(u32, window::Id), ToplevelListPopup(u32, window::Id),
@ -647,6 +688,17 @@ impl CosmicAppList {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
} }
/// Yoda: macOS-Tahoe-style hover magnification. Returns the icon
/// size multiplier for a given dock item — 1.3× when the pointer
/// is over it, 1.0× otherwise. Called from the view() icon builders.
fn icon_scale_for(&self, id: &DockItemId) -> f32 {
if self.hovered_dock_item.as_ref() == Some(id) {
1.3
} else {
1.0
}
}
fn is_on_current_monitor_and_workspace(&self, toplevel_info: &ToplevelInfo) -> bool { fn is_on_current_monitor_and_workspace(&self, toplevel_info: &ToplevelInfo) -> bool {
use cosmic_app_list_config::ToplevelFilter; use cosmic_app_list_config::ToplevelFilter;
@ -1582,6 +1634,9 @@ impl cosmic::Application for CosmicAppList {
Message::GpuRequest(gpus) => { Message::GpuRequest(gpus) => {
self.gpus = gpus; self.gpus = gpus;
} }
Message::DockItemHover(id) => {
self.hovered_dock_item = id;
}
Message::OpenActive => { Message::OpenActive => {
let create_new = self.overflow_active_popup.is_none(); let create_new = self.overflow_active_popup.is_none();
let mut cmds = vec![self.close_popups()]; let mut cmds = vec![self.close_popups()];
@ -1763,7 +1818,9 @@ impl cosmic::Application for CosmicAppList {
.filter(|(info, _)| self.is_on_current_monitor_and_workspace(info)) .filter(|(info, _)| self.is_on_current_monitor_and_workspace(info))
.any(|y| focused_item.contains(&y.0.foreign_toplevel)); .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
.applet_tooltip::<Message>( .applet_tooltip::<Message>(
dock_item.as_icon( dock_item.as_icon(
@ -1776,6 +1833,7 @@ impl cosmic::Application for CosmicAppList {
dot_radius, dot_radius,
self.core.main_window_id().unwrap(), self.core.main_window_id().unwrap(),
Some(&|info| self.is_on_current_monitor_and_workspace(info)), Some(&|info| self.is_on_current_monitor_and_workspace(info)),
icon_scale,
), ),
dock_item dock_item
.desktop_info .desktop_info
@ -1785,7 +1843,10 @@ impl cosmic::Application for CosmicAppList {
self.popup.is_some(), self.popup.is_some(),
Message::Surface, Message::Surface,
None, None,
) );
cosmic::widget::mouse_area(tooltip)
.on_enter(Message::DockItemHover(Some(DockItemId::from(dock_id))))
.on_exit(Message::DockItemHover(None))
.into() .into()
}) })
.collect(); .collect();
@ -1836,6 +1897,10 @@ impl cosmic::Application for CosmicAppList {
dot_radius, dot_radius,
self.core.main_window_id().unwrap(), self.core.main_window_id().unwrap(),
Some(&|info| self.is_on_current_monitor_and_workspace(info)), 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() { } else if self.is_listening_for_dnd && self.pinned_list.is_empty() {
@ -1875,8 +1940,10 @@ impl cosmic::Application for CosmicAppList {
.iter() .iter()
.filter(|(info, _)| self.is_on_current_monitor_and_workspace(info)) .filter(|(info, _)| self.is_on_current_monitor_and_workspace(info))
.any(|y| focused_item.contains(&y.0.foreign_toplevel)); .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
.applet_tooltip( .applet_tooltip(
dock_item.as_icon( dock_item.as_icon(
@ -1889,6 +1956,7 @@ impl cosmic::Application for CosmicAppList {
dot_radius, dot_radius,
self.core.main_window_id().unwrap(), self.core.main_window_id().unwrap(),
Some(&|info| self.is_on_current_monitor_and_workspace(info)), Some(&|info| self.is_on_current_monitor_and_workspace(info)),
icon_scale,
), ),
dock_item dock_item
.desktop_info .desktop_info
@ -1898,7 +1966,10 @@ impl cosmic::Application for CosmicAppList {
self.popup.is_some(), self.popup.is_some(),
Message::Surface, Message::Surface,
None, None,
) );
cosmic::widget::mouse_area(tooltip)
.on_enter(Message::DockItemHover(Some(DockItemId::from(dock_id))))
.on_exit(Message::DockItemHover(None))
.into() .into()
}) })
.collect(); .collect();
@ -2313,6 +2384,10 @@ impl cosmic::Application for CosmicAppList {
dot_radius, dot_radius,
id, id,
Some(&|info| self.is_on_current_monitor_and_workspace(info)), 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 dock_item
.desktop_info .desktop_info
@ -2421,6 +2496,10 @@ impl cosmic::Application for CosmicAppList {
dot_radius, dot_radius,
id, id,
Some(&|info| self.is_on_current_monitor_and_workspace(info)), 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 dock_item
.desktop_info .desktop_info