// Copyright 2023 System76 // SPDX-License-Identifier: GPL-3.0-only use crate::{ fl, icon_catalog, launcher_edit::{self, LauncherEditRequest}, wayland_subscription::{ OutputUpdate, ToplevelRequest, ToplevelUpdate, WaylandImage, WaylandRequest, WaylandUpdate, wayland_subscription, }, }; use cctk::{ sctk::{output::OutputInfo, reexports::calloop::channel::Sender}, toplevel_info::ToplevelInfo, wayland_client::protocol::{ wl_data_device_manager::DndAction, wl_output::WlOutput, wl_seat::WlSeat, }, wayland_protocols::ext::{ foreign_toplevel_list::v1::client::ext_foreign_toplevel_handle_v1::ExtForeignToplevelHandleV1, workspace::v1::client::ext_workspace_handle_v1::ExtWorkspaceHandleV1, }, }; use cosmic::{ Apply, Element, Task, app, applet::{ Context, Size, cosmic_panel_config::{PanelAnchor, PanelSize}, }, cosmic_config::{Config, CosmicConfigEntry}, desktop::IconSourceExt, iced::runtime::{core::event, dnd::peek_dnd}, iced::{ self, Alignment, Background, Border, Length, Limits, Padding, Subscription, advanced::text::{Ellipsize, EllipsizeHeightLimit}, clipboard::mime::{AllowedMimeTypes, AsMimeTypes}, event::listen_with, platform_specific::shell::commands::popup::{destroy_popup, get_popup}, widget::{ Column, Row, column, mouse_area, row, rule::vertical as vertical_rule, space::horizontal as horizontal_space, space::vertical as vertical_space, stack, }, window, }, surface, theme::{self, Button, Container}, widget::{ DndDestination, Image, button, container, divider, dnd_source, grid, icon::{self, from_name}, image::Handle, rectangle_tracker::{RectangleTracker, RectangleUpdate, rectangle_tracker_subscription}, scrollable, svg, text, text_input, }, }; use cosmic::{ desktop::fde::{self, DesktopEntry, get_languages_from_env, unicase::Ascii}, widget::DndSource, }; use cosmic_app_list_config::{APP_ID, AppListConfig}; use cosmic_protocols::toplevel_info::v1::client::zcosmic_toplevel_handle_v1::State; use futures::future::pending; use rustc_hash::FxHashMap; use std::{borrow::Cow, path::PathBuf, rc::Rc, str::FromStr, time::Duration}; use switcheroo_control::Gpu; use tokio::time::sleep; use url::Url; static MIME_TYPE: &str = "text/uri-list"; const MAX_VISIBLE_ICON_CHOICES: usize = 120; const ICON_CATALOG_COLUMNS: usize = 6; pub fn run() -> cosmic::iced::Result { cosmic::applet::run::(()) } #[derive(Debug, Clone)] struct AppletIconData { icon_size: u16, icon_spacing: f32, dot_radius: f32, bar_size: f32, padding: Padding, } static DND_FAVORITES: u64 = u64::MAX; impl AppletIconData { fn new(applet: &Context) -> Self { let icon_size = applet.suggested_size(false).0; let (major_padding, cross_padding) = applet.suggested_padding(false); let (h_padding, v_padding) = if applet.is_horizontal() { (major_padding as f32, cross_padding as f32) } else { (cross_padding as f32, major_padding as f32) }; let icon_spacing = applet.spacing as f32; let (dot_radius, bar_size) = match applet.size { Size::Hardcoded(_) => (2.0, 8.0), Size::PanelSize(ref s) => { let size = s.get_applet_icon_size_with_padding(false); // Define size thresholds, to handle custom sizes let small_size_threshold = PanelSize::S.get_applet_icon_size_with_padding(false); let medium_size_threshold = PanelSize::M.get_applet_icon_size_with_padding(false); if size <= small_size_threshold { (1.0, 8.0) } else if size <= medium_size_threshold { (2.0, 8.0) } else { (2.0, 12.0) } } }; let padding = match applet.anchor { PanelAnchor::Top => [ v_padding - (dot_radius * 2. + 1.), h_padding, v_padding, h_padding, ], PanelAnchor::Bottom => [ v_padding, h_padding, v_padding - (dot_radius * 2. + 1.), h_padding, ], PanelAnchor::Left => [ v_padding, h_padding, v_padding, h_padding - (dot_radius * 2. + 1.), ], PanelAnchor::Right => [ v_padding, h_padding - (dot_radius * 2. + 1.), v_padding, h_padding, ], }; AppletIconData { icon_size, icon_spacing, dot_radius, bar_size, padding: padding.into(), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DockItemId { Item(u32), ActiveOverflow, FavoritesOverflow, } impl From for DockItemId { fn from(id: u32) -> Self { DockItemId::Item(id) } } impl From for DockItemId { fn from(id: usize) -> Self { DockItemId::Item(id as u32) } } #[derive(Debug, Clone)] struct DockItem { // ID used internally in the applet. Each dock item // have an unique id id: u32, toplevels: Vec<(ToplevelInfo, Option)>, // Information found in the .desktop file desktop_info: DesktopEntry, // We must use this because the id in `DesktopEntry` is an estimation. // Thus, if we unpin an item, we want to be sure to use the real id original_app_id: String, } impl DockItem { fn as_icon( &self, applet: &Context, rectangle_tracker: Option<&RectangleTracker>, interaction_enabled: bool, dnd_source_enabled: bool, gpus: Option<&[Gpu]>, is_focused: bool, 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, desktop_info, id, .. } = self; let filtered_toplevels: Vec<_> = if let Some(filter_fn) = filter { toplevels .iter() .filter(|(info, _)| filter_fn(info)) .collect() } else { toplevels.iter().collect() }; 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(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 { match applet.anchor { PanelAnchor::Left | PanelAnchor::Right => { vertical_space().height(app_icon.bar_size) } PanelAnchor::Top | PanelAnchor::Bottom => { horizontal_space().width(app_icon.bar_size) } } } .apply(container) .padding(effective_radius); if toplevel_count == 0 { container } else { 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 { on_bg }; container::Style { background: Some(Background::Color(fill)), border: Border { radius: dot_border_radius.into(), ..Default::default() }, ..Default::default() } })) } }; let icon_wrapper: Element<_> = match applet.anchor { PanelAnchor::Left => row([ indicator.into(), horizontal_space().width(Length::Fixed(1.0)).into(), cosmic_icon.clone().into(), ]) .align_y(Alignment::Center) .into(), PanelAnchor::Right => row([ cosmic_icon.clone().into(), horizontal_space().width(Length::Fixed(1.0)).into(), indicator.into(), ]) .align_y(Alignment::Center) .into(), PanelAnchor::Top => column([ indicator.into(), vertical_space().height(Length::Fixed(1.0)).into(), cosmic_icon.clone().into(), ]) .align_x(Alignment::Center) .into(), PanelAnchor::Bottom => column([ cosmic_icon.clone().into(), vertical_space().height(Length::Fixed(1.0)).into(), indicator.into(), ]) .align_x(Alignment::Center) .into(), }; let icon_button = button::custom(icon_wrapper) .padding(app_icon.padding) .selected(is_focused) .class(app_list_icon_style(is_focused)); let icon_button: Element<_> = if interaction_enabled { mouse_area( icon_button .on_press_maybe(if toplevel_count == 0 { launch_on_preferred_gpu(desktop_info, gpus) } else if toplevel_count == 1 { filtered_toplevels .first() .map(|t| Message::Toggle(t.0.foreign_toplevel.clone())) } else { Some(Message::ToplevelListPopup(*id, window_id)) }) .width(Length::Shrink) .height(Length::Shrink), ) .on_right_release(Message::Popup(*id, window_id)) .on_middle_release({ launch_on_preferred_gpu(desktop_info, gpus) .unwrap_or(Message::Popup(*id, window_id)) }) .into() } else { icon_button.into() }; let path = desktop_info.path.clone(); let icon_button = if dnd_source_enabled && interaction_enabled { DndSource::with_id(icon_button, cosmic::widget::Id::new("asdfasdfadfs")) .window(window_id) .drag_icon(move |_| { ( cosmic_icon.clone().into(), iced::core::widget::tree::State::None, iced::Vector::ZERO, ) }) .drag_threshold(16.) .drag_content(move || DndPathBuf(path.clone())) .on_start(Some(Message::StartDrag(*id))) .on_cancel(Some(Message::DragFinished)) .on_finish(Some(Message::DragFinished)) } else { dnd_source(icon_button) }; if let Some(tracker) = rectangle_tracker { tracker.container((*id).into(), icon_button).into() } else { icon_button.into() } } } #[derive(Debug, Clone, Default)] struct DndOffer { dock_item: Option, preview_index: usize, } #[derive(Debug, Clone)] pub struct Popup { parent: window::Id, id: window::Id, dock_item: DockItem, popup_type: PopupType, } #[derive(Debug, Clone)] struct LauncherEditState { original_app_id: String, source_path: PathBuf, original_name: String, original_exec: String, name: String, exec: String, icon: String, terminal: bool, saving: bool, error: Option, icon_catalog: IconCatalogState, } #[derive(Debug, Clone)] struct IconCatalogState { theme: String, query: String, entries: Vec, loading: bool, truncated: bool, } #[derive(Clone, Default)] struct CosmicAppList { core: cosmic::app::Core, popup: Option, launcher_edit: Option, subscription_ctr: u32, item_ctr: u32, desktop_entries: Vec, active_list: Vec, pinned_list: Vec, dnd_source: Option<(window::Id, DockItem, DndAction, Option)>, config: AppListConfig, wayland_sender: Option>, seat: Option, rectangle_tracker: Option>, rectangles: FxHashMap, dnd_offer: Option, is_listening_for_dnd: bool, gpus: Option>, active_workspaces: Vec, output_list: FxHashMap, locales: Vec, hovered_toplevel: Option, /// 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, } #[derive(Debug, Clone, PartialEq)] pub enum PopupType { RightClickMenu, ToplevelList, LauncherEditor, } #[derive(Debug, Clone)] #[allow(dead_code)] enum Message { Wayland(WaylandUpdate), PinApp(u32), UnpinApp(u32), EditLauncher(u32), LauncherNameChanged(String), LauncherExecChanged(String), LauncherIconChanged(String), LauncherIconSearchChanged(String), LauncherIconSelected(String), ReloadLauncherIconCatalog, LauncherIconCatalogLoaded(icon_catalog::IconCatalog), SaveLauncherEdit, CancelLauncherEdit, LauncherEditSaved(Result), /// 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), ToplevelHoverChanged(ExtForeignToplevelHandleV1, bool), GpuRequest(Option>), CloseRequested(window::Id), ClosePopup, Activate(ExtForeignToplevelHandleV1), Toggle(ExtForeignToplevelHandleV1), Exec(String, Option, bool), CloseToplevel(ExtForeignToplevelHandleV1), Quit(String), NewSeat(WlSeat), RemovedSeat, Rectangle(RectangleUpdate), StartDrag(u32), DragFinished, DndEnter(f64, f64), DndLeave, DndMotion(f64, f64), DndDropFinished, DndData(Option), StartListeningForDnd, StopListeningForDnd, IncrementSubscriptionCtr, ConfigUpdated(AppListConfig), OpenFavorites, OpenActive, Surface(surface::Action), } fn index_in_list( mut list_len: usize, item_size: f32, divider_size: f32, existing_preview: Option, pos_in_list: f32, ) -> usize { if existing_preview.is_some() { list_len += 1; } let index = if (list_len == 0) || (pos_in_list < item_size / 2.0) { 0 } else { let mut i = 1; let mut pos = item_size / 2.0; while i < list_len { let next_pos = pos + item_size + divider_size; if pos < pos_in_list && pos_in_list < next_pos { break; } pos = next_pos; i += 1; } i }; if let Some(existing_preview) = existing_preview { if index >= existing_preview { index.saturating_sub(1) } else { index } } else { index } } async fn try_get_gpus() -> Option> { let connection = zbus::Connection::system().await.ok()?; let proxy = switcheroo_control::SwitcherooControlProxy::new(&connection) .await .ok()?; if !proxy.has_dual_gpu().await.ok()? { return None; } let gpus = proxy.get_gpus().await.ok()?; if gpus.is_empty() { return None; } Some(gpus) } const TOPLEVEL_BUTTON_WIDTH: f32 = 192.0; const TOPLEVEL_BUTTON_HEIGHT: f32 = 156.0; fn toplevel_button<'a>( img: Option, title: String, handle: ExtForeignToplevelHandleV1, is_focused: bool, is_hovered: bool, ) -> Element<'a, Message> { let border = 1.0; let preview = column![ container(if let Some(img) = img { Element::from(Image::new(Handle::from_rgba( img.width, img.height, img.img.clone(), ))) } else { Image::new(Handle::from_rgba(1, 1, [0u8, 0u8, 0u8, 255u8].as_slice())).into() }) .class(Container::custom(move |theme| container::Style { border: Border { color: theme.cosmic().bg_divider().into(), width: border, radius: 1.0.into(), }, ..Default::default() })) .padding(border as u16) .apply(container) .center(Length::Fill), text::body(title) .ellipsize(Ellipsize::End(EllipsizeHeightLimit::Lines(1))) .width(Length::Fill) .center() ] .spacing(4) .padding([4, 4, 0, 4]); let close_button_overlay = if is_hovered { row![ horizontal_space(), button::custom(icon::from_name("window-close-symbolic").size(16)) .class(Button::Destructive) .on_press(Message::CloseToplevel(handle.clone())) .padding(4) ] } else { row![] } .width(Length::Fill) .height(Length::Fill); stack![preview, close_button_overlay] .apply(button::custom) .on_press(Message::Toggle(handle.clone())) .class(window_menu_style(is_focused)) .width(Length::Fixed(TOPLEVEL_BUTTON_WIDTH)) .height(Length::Fixed(TOPLEVEL_BUTTON_HEIGHT)) .padding(4) .selected(is_focused) .apply(mouse_area) .on_enter(Message::ToplevelHoverChanged(handle.clone(), true)) .on_middle_press(Message::CloseToplevel(handle.clone())) .on_exit(Message::ToplevelHoverChanged(handle, false)) .apply(Element::from) } fn window_menu_style(selected: bool) -> cosmic::theme::Button { let radius = theme::active() .cosmic() .radius_m() .map(|x| if x < 8.0 { x } else { x - 4.0 }); Button::Custom { active: Box::new(move |focused, theme| { let a = button::Catalog::active(theme, focused, selected, &Button::AppletMenu); button::Style { background: if selected { Some(Background::Color( theme.cosmic().icon_button.selected_state_color().into(), )) } else { a.background }, border_radius: radius.into(), outline_width: 0.0, ..a } }), hovered: Box::new(move |focused, theme| { let focused = selected || focused; let text = button::Catalog::hovered(theme, focused, focused, &Button::AppletMenu); button::Style { border_radius: radius.into(), outline_width: 0.0, ..text } }), disabled: Box::new(move |theme| { let text = button::Catalog::disabled(theme, &Button::AppletMenu); button::Style { border_radius: radius.into(), outline_width: 0.0, ..text } }), pressed: Box::new(move |focused, theme| { let focused = selected || focused; let text = button::Catalog::pressed(theme, focused, focused, &Button::AppletMenu); button::Style { border_radius: radius.into(), outline_width: 0.0, ..text } }), } } fn app_list_icon_style(selected: bool) -> cosmic::theme::Button { Button::Custom { active: Box::new(move |focused, theme| { let a = button::Catalog::active(theme, focused, selected, &Button::AppletIcon); button::Style { background: if selected { Some(Background::Color( theme.cosmic().icon_button.selected_state_color().into(), )) } else { a.background }, ..a } }), hovered: Box::new(move |focused, theme| { button::Catalog::hovered(theme, focused, selected, &Button::AppletIcon) }), disabled: Box::new(|theme| button::Catalog::disabled(theme, &Button::AppletIcon)), pressed: Box::new(move |focused, theme| { button::Catalog::pressed(theme, focused, selected, &Button::AppletIcon) }), } } #[inline] pub fn menu_control_padding() -> Padding { let spacing = theme::spacing(); [spacing.space_xxs, spacing.space_s].into() } fn launcher_icon_editor(edit: &LauncherEditState) -> Element<'_, Message> { let spacing = theme::spacing(); let selected_icon = edit.icon.trim(); let query = edit.icon_catalog.query.trim().to_ascii_lowercase(); let mut total_matches = 0usize; let mut visible_count = 0usize; let mut icon_grid = grid() .width(Length::Fill) .column_spacing(spacing.space_xxs) .row_spacing(spacing.space_xxs); for entry in edit.icon_catalog.entries.iter().filter(|entry| { query.is_empty() || entry.name.to_ascii_lowercase().contains(query.as_str()) }) { total_matches += 1; if visible_count >= MAX_VISIBLE_ICON_CHOICES { continue; } if visible_count > 0 && visible_count % ICON_CATALOG_COLUMNS == 0 { icon_grid = icon_grid.insert_row(); } let selected = selected_icon == entry.name; let icon_preview = cosmic::widget::icon( fde::IconSource::from_unknown(entry.name.as_str()).as_cosmic_icon(), ) .size(32) .width(Length::Fixed(32.0)) .height(Length::Fixed(32.0)); let label = text::caption(entry.name.as_str()) .ellipsize(Ellipsize::End(EllipsizeHeightLimit::Lines(1))) .width(Length::Fill) .center(); let tile = column![icon_preview, label] .align_x(Alignment::Center) .spacing(4) .width(Length::Fixed(70.0)); let tile_button = button::custom(tile) .class(if selected { Button::Suggested } else { Button::Image }) .selected(selected) .on_press(Message::LauncherIconSelected(entry.name.clone())) .padding(6) .width(Length::Fixed(74.0)) .height(Length::Fixed(76.0)); icon_grid = icon_grid.push(tile_button); visible_count += 1; } let current_icon = cosmic::widget::icon(fde::IconSource::from_unknown(edit.icon.as_str()).as_cosmic_icon()) .size(32) .width(Length::Fixed(36.0)) .height(Length::Fixed(36.0)); let icon_value = row![ current_icon, text_input("", edit.icon.as_str()) .label(fl!("launcher-icon")) .on_input(Message::LauncherIconChanged) .on_submit(|_| Message::SaveLauncherEdit) .width(Length::Fill) .size(14), button::icon(from_name("view-refresh-symbolic")) .on_press(Message::ReloadLauncherIconCatalog) .padding(spacing.space_xxs), ] .spacing(spacing.space_xs) .align_y(Alignment::Center); let visible_total = if edit.icon_catalog.truncated { format!( "{}/{}+ {}", visible_count, total_matches, fl!("launcher-icons") ) } else { format!( "{}/{} {}", visible_count, total_matches, fl!("launcher-icons") ) }; let catalog_header = row![ text::caption(format!( "{}: {}", fl!("launcher-icon-theme"), edit.icon_catalog.theme )), horizontal_space(), text::caption(visible_total), ] .align_y(Alignment::Center); let catalog_body: Element<_> = if edit.icon_catalog.loading { container(text::body(fl!("launcher-icon-catalog-loading"))) .center(Length::Fill) .height(Length::Fixed(220.0)) .into() } else if total_matches == 0 { container(text::body(fl!("launcher-icon-catalog-empty"))) .center(Length::Fill) .height(Length::Fixed(220.0)) .into() } else { scrollable(icon_grid) .height(Length::Fixed(240.0)) .width(Length::Fill) .into() }; column![ icon_value, catalog_header, text_input("", edit.icon_catalog.query.as_str()) .label(fl!("launcher-icon-search")) .on_input(Message::LauncherIconSearchChanged) .width(Length::Fill) .size(14), catalog_body, ] .spacing(spacing.space_s) .width(Length::Fill) .into() } fn find_desktop_entries<'a>( desktop_entries: &'a [fde::DesktopEntry], app_ids: &'a [String], ) -> impl Iterator + 'a { app_ids.iter().map(|fav| { let unicase_fav = fde::unicase::Ascii::new(fav.as_str()); fde::find_app_by_id(desktop_entries, unicase_fav).map_or_else( || fde::DesktopEntry::from_appid(fav.clone()), ToOwned::to_owned, ) }) } impl CosmicAppList { // Cache all desktop entries to use when new apps are added to the dock. fn update_desktop_entries(&mut self) { self.desktop_entries = fde::Iter::new(fde::default_paths()) .filter_map(|p| fde::DesktopEntry::from_path(p, Some(&self.locales)).ok()) .collect::>(); } /// 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; let on_active_workspace = self.active_workspaces.is_empty() || toplevel_info.workspace.is_empty() || self .active_workspaces .iter() .any(|workspace| toplevel_info.workspace.contains(workspace)); match &self.config.filter_top_levels { None => true, Some(ToplevelFilter::ActiveWorkspace) => on_active_workspace, Some(ToplevelFilter::ConfiguredOutput) => { let on_active_output = self .output_list .iter() .find(|(_, info)| info.name.as_ref() == Some(&self.core.applet.output_name)) .map_or(true, |(active_output, _)| { toplevel_info .output .iter() .any(|output| output == active_output) }); on_active_output && on_active_workspace } } } // Update pinned items using the cached desktop entries as a source. fn update_pinned_list(&mut self) { self.pinned_list = find_desktop_entries(&self.desktop_entries, &self.config.favorites) .zip(&self.config.favorites) .enumerate() .map(|(pinned_ctr, (e, original_id))| DockItem { id: pinned_ctr as u32, toplevels: Vec::new(), desktop_info: e, original_app_id: original_id.clone(), }) .collect(); } fn sync_pinned_list_from_config(&mut self) { for item in self.pinned_list.drain(..) { if !item.toplevels.is_empty() { self.active_list.push(item); } } self.pinned_list = find_desktop_entries(&self.desktop_entries, &self.config.favorites) .zip(&self.config.favorites) .map(|(de, original_id)| { if let Some(p) = self .active_list .iter() .position(|dock_item| dock_item.desktop_info.id() == de.id()) { let mut d = self.active_list.remove(p); d.desktop_info = de.clone(); d.original_app_id.clone_from(original_id); d } else { self.item_ctr += 1; DockItem { id: self.item_ctr, toplevels: Vec::new(), desktop_info: de.clone(), original_app_id: original_id.clone(), } } }) .collect(); } /// Close any open popups. fn close_popups(&mut self) -> Task> { let mut commands = Vec::new(); if let Some(popup) = self.popup.take() { if popup.popup_type == PopupType::LauncherEditor { self.launcher_edit = None; } commands.push(destroy_popup(popup.id)); } if let Some(popup) = self.overflow_active_popup.take() { commands.push(destroy_popup(popup)); } if let Some(popup) = self.overflow_favorites_popup.take() { commands.push(destroy_popup(popup)); } Task::batch(commands) } /// Returns the length of the group in the favorite list after which items are displayed in a popup. /// Shrink the favorite list until it only has active windows, or until it fits in the length provided. fn panel_overflow_lengths(&self) -> (Option, Option) { let mut favorite_index; let mut active_index = None; let Some(mut max_major_axis_len) = self.core.applet.suggested_bounds.as_ref().map(|c| { // if we have a configure for width and height, we're in a overflow popup match self.core.applet.anchor { PanelAnchor::Top | PanelAnchor::Bottom => c.width as u32, PanelAnchor::Left | PanelAnchor::Right => c.height as u32, } }) else { return (None, active_index); }; // tracing::error!("{} {}", max_major_axis_len, self.pinned_list.len()); // subtract the divider width max_major_axis_len -= 1; let applet_icon = AppletIconData::new(&self.core.applet); let button_total_size = self.core.applet.suggested_size(true).0 + self.core.applet.suggested_padding(true).0 * 2 + applet_icon.icon_spacing as u16; let favorite_active_cnt = self .pinned_list .iter() .filter(|t| !t.toplevels.is_empty()) .count(); // initial calculation of favorite_index let btn_count = max_major_axis_len / button_total_size as u32; if btn_count >= self.pinned_list.len() as u32 + self.active_list.len() as u32 { return (None, active_index); } else { favorite_index = (btn_count as usize).min(favorite_active_cnt).max(2); } // calculation of active_index based on favorite_index if there is still not enough space let active_index_max = (btn_count as i32) - (self.pinned_list.len() as i32).saturating_sub(favorite_index as i32); if active_index_max >= self.active_list.len() as i32 { active_index = Some(self.active_list.len()); } else { active_index = Some((active_index_max.max(2) as usize).min(self.active_list.len())); } // final calculation of favorite_index if there is still not enough space if let Some(active_index) = active_index { let favorite_index_max = (btn_count as i32) - active_index as i32; favorite_index = favorite_index_max.max(2) as usize; } else { favorite_index = (btn_count as usize).min(self.pinned_list.len()); } // tracing::error!("{} {} {:?}", btn_count, favorite_index, active_index); (Some(favorite_index), active_index) } fn currently_active_toplevel(&self) -> Vec { if self.active_workspaces.is_empty() { return Vec::new(); } let current_output = &self.core.applet.output_name; let mut focused_toplevels: Vec = Vec::new(); let active_workspaces = &self.active_workspaces; for toplevel_list in self.active_list.iter().chain(self.pinned_list.iter()) { for (t_info, _) in &toplevel_list.toplevels { if t_info.state.contains(&State::Activated) && active_workspaces .iter() .any(|workspace| t_info.workspace.contains(workspace)) && t_info.output.iter().any(|x| { self.output_list.get(x).is_some_and(|val| { val.name.as_ref().is_some_and(|n| n == current_output) }) }) { focused_toplevels.push(t_info.foreign_toplevel.clone()); } } } focused_toplevels } fn find_desktop_entry_for_toplevel( &mut self, info: &ToplevelInfo, unicase_appid: Ascii<&str>, ) -> DesktopEntry { if let Some(appid) = fde::find_app_by_id(&self.desktop_entries, unicase_appid) { appid.clone() } else { // Update desktop entries in case it was not found. self.update_desktop_entries(); if let Some(appid) = fde::find_app_by_id(&self.desktop_entries, unicase_appid) { appid.clone() } else { tracing::error!(id = info.app_id, "could not find desktop entry for app"); let mut fallback_entry = fde::DesktopEntry::from_appid(info.app_id.clone()); // proton opens games as steam_app_X, where X is either // the steam appid or "default". games with a steam appid // can have a desktop entry generated elsewhere; this // specifically handles non-steam games opened // under proton // in addition, try to match WINE entries who have its // appid = the full name of the executable (incl. .exe) let is_proton_game = info.app_id == "steam_app_default"; if is_proton_game || info.app_id.ends_with(".exe") { for entry in &self.desktop_entries { let localised_name = entry.name(&self.locales).unwrap_or_default(); if localised_name == info.title { // if this is a proton game, we only want // to look for game entries if is_proton_game && !entry.categories().unwrap_or_default().contains(&"Game") { continue; } fallback_entry = entry.clone(); break; } } } fallback_entry } } } // Check if a specific toplevel is focused fn is_focused(&self, handle: &ExtForeignToplevelHandleV1) -> bool { self.currently_active_toplevel().contains(handle) } // Check if a specific toplevel button is currently hovered fn is_hovered(&self, handle: &ExtForeignToplevelHandleV1) -> bool { self.hovered_toplevel.as_ref() == Some(handle) } } impl cosmic::Application for CosmicAppList { type Message = Message; type Executor = cosmic::SingleThreadExecutor; type Flags = (); const APP_ID: &'static str = APP_ID; fn init(core: cosmic::app::Core, _flags: Self::Flags) -> (Self, app::Task) { let config = Config::new(APP_ID, AppListConfig::VERSION) .ok() .and_then(|c| AppListConfig::get_entry(&c).ok()) .unwrap_or_default(); let mut app_list = Self { core, config, locales: get_languages_from_env(), ..Default::default() }; app_list.update_desktop_entries(); app_list.update_pinned_list(); app_list.item_ctr = app_list.pinned_list.len() as u32; ( app_list, Task::perform(try_get_gpus(), |gpus| { cosmic::Action::App(Message::GpuRequest(gpus)) }), ) } fn core(&self) -> &cosmic::app::Core { &self.core } fn core_mut(&mut self) -> &mut cosmic::app::Core { &mut self.core } fn update(&mut self, message: Self::Message) -> app::Task { match message { Message::Popup(id, parent_window_id) => { if let Some(Popup { parent, id: popup_id, .. }) = self.popup.take() { if parent == parent_window_id { return destroy_popup(popup_id); } else { self.overflow_active_popup = None; self.overflow_favorites_popup = None; return Task::batch([destroy_popup(popup_id), destroy_popup(parent)]); } } if let Some(toplevel_group) = self .active_list .iter() .chain(self.pinned_list.iter()) .find(|t| t.id == id) { let Some(rectangle) = self.rectangles.get(&toplevel_group.id.into()) else { tracing::error!("No rectangle found for toplevel group"); return Task::none(); }; let new_id = window::Id::unique(); self.popup = Some(Popup { parent: parent_window_id, id: new_id, dock_item: toplevel_group.clone(), popup_type: PopupType::RightClickMenu, }); let mut popup_settings = self.core.applet.get_popup_settings( parent_window_id, new_id, None, None, None, ); let iced::Rectangle { x, y, width, height, } = *rectangle; popup_settings.positioner.anchor_rect = iced::Rectangle:: { x: x as i32, y: y as i32, width: width as i32, height: height as i32, }; let gpu_update = Task::perform(try_get_gpus(), |gpus| { cosmic::Action::App(Message::GpuRequest(gpus)) }); return Task::batch([gpu_update, get_popup(popup_settings)]); } } Message::ToplevelListPopup(id, parent_window_id) => { if let Some(Popup { parent, id: popup_id, .. }) = self.popup.take() { if parent == parent_window_id { return destroy_popup(popup_id); } else { self.overflow_active_popup = None; self.overflow_favorites_popup = None; return Task::batch([destroy_popup(popup_id), destroy_popup(parent)]); } } if let Some(toplevel_group) = self .active_list .iter() .chain(self.pinned_list.iter()) .find(|t| t.id == id) { for (info, _) in &toplevel_group.toplevels { if let Some(tx) = self.wayland_sender.as_ref() { let _ = tx.send(WaylandRequest::Screencopy(info.foreign_toplevel.clone())); } } let Some(rectangle) = self.rectangles.get(&toplevel_group.id.into()) else { return Task::none(); }; let new_id = window::Id::unique(); self.popup = Some(Popup { parent: parent_window_id, id: new_id, dock_item: toplevel_group.clone(), popup_type: PopupType::ToplevelList, }); let mut popup_settings = self.core.applet.get_popup_settings( parent_window_id, new_id, None, None, None, ); let iced::Rectangle { x, y, width, height, } = *rectangle; popup_settings.positioner.anchor_rect = iced::Rectangle:: { x: x as i32, y: y as i32, width: width as i32, height: height as i32, }; let max_windows = 7.0; let window_spacing = 8.0; popup_settings.positioner.size_limits = match self.core.applet.anchor { PanelAnchor::Right | PanelAnchor::Left => Limits::NONE .min_width(100.0) .min_height(30.0) .max_width(window_spacing * 2.0 + TOPLEVEL_BUTTON_WIDTH) .max_height( TOPLEVEL_BUTTON_HEIGHT * max_windows + window_spacing * (max_windows + 1.0), ), PanelAnchor::Bottom | PanelAnchor::Top => Limits::NONE .min_width(30.0) .min_height(100.0) .max_width( TOPLEVEL_BUTTON_WIDTH * max_windows + window_spacing * (max_windows + 1.0), ) .max_height(window_spacing * 2.0 + TOPLEVEL_BUTTON_HEIGHT), }; return get_popup(popup_settings); } } Message::ToplevelHoverChanged(handle, entering) => { match (entering, &self.hovered_toplevel) { (true, _) => self.hovered_toplevel = Some(handle), // prevents race condition (false, Some(h)) if h == &handle => self.hovered_toplevel = None, _ => {} } } Message::PinApp(id) => { if let Some(i) = self.active_list.iter().position(|t| t.id == id) { let entry = self.active_list.remove(i); self.config.add_pinned( entry.original_app_id.clone(), &Config::new(APP_ID, AppListConfig::VERSION).unwrap(), ); self.pinned_list.push(entry); } if let Some(Popup { id: popup_id, .. }) = self.popup.take() { return destroy_popup(popup_id); } } Message::UnpinApp(id) => { if let Some(i) = self.pinned_list.iter().position(|t| t.id == id) { let entry = self.pinned_list.remove(i); self.config.remove_pinned( &entry.original_app_id, &Config::new(APP_ID, AppListConfig::VERSION).unwrap(), ); self.rectangles.remove(&entry.id.into()); if !entry.toplevels.is_empty() { self.active_list.push(entry); } } if let Some(Popup { id: popup_id, .. }) = self.popup.take() { return destroy_popup(popup_id); } } Message::EditLauncher(id) => { let Some(dock_item) = self.pinned_list.iter().find(|t| t.id == id).cloned() else { return Task::none(); }; let Some(exec) = dock_item.desktop_info.exec() else { return Task::none(); }; let Some(existing_popup) = self.popup.as_mut() else { return Task::none(); }; let original_name = dock_item .desktop_info .desktop_entry("Name") .map(ToString::to_string) .or_else(|| { dock_item .desktop_info .name(&self.locales) .map(Cow::into_owned) }) .unwrap_or_else(|| dock_item.original_app_id.clone()); let original_exec = exec.to_string(); let original_icon = dock_item .desktop_info .icon() .unwrap_or_default() .to_string(); let icon_theme = cosmic::icon_theme::default(); self.launcher_edit = Some(LauncherEditState { original_app_id: dock_item.original_app_id.clone(), source_path: dock_item.desktop_info.path.clone(), original_name: original_name.clone(), original_exec: original_exec.clone(), name: original_name, exec: original_exec, icon: original_icon.clone(), terminal: dock_item.desktop_info.terminal(), saving: false, error: None, icon_catalog: IconCatalogState { theme: icon_theme.clone(), query: String::new(), entries: Vec::new(), loading: true, truncated: false, }, }); existing_popup.dock_item = dock_item; existing_popup.popup_type = PopupType::LauncherEditor; return Task::perform( icon_catalog::load_icon_catalog(icon_theme, original_icon), |catalog| cosmic::Action::App(Message::LauncherIconCatalogLoaded(catalog)), ); } Message::LauncherNameChanged(name) => { if let Some(edit) = self.launcher_edit.as_mut() && !edit.saving { edit.name = name; edit.error = None; } } Message::LauncherExecChanged(exec) => { if let Some(edit) = self.launcher_edit.as_mut() && !edit.saving { edit.exec = exec; edit.error = None; } } Message::LauncherIconChanged(icon) => { if let Some(edit) = self.launcher_edit.as_mut() && !edit.saving { edit.icon = icon; edit.error = None; } } Message::LauncherIconSearchChanged(query) => { if let Some(edit) = self.launcher_edit.as_mut() { edit.icon_catalog.query = query; } } Message::LauncherIconSelected(icon) => { if let Some(edit) = self.launcher_edit.as_mut() && !edit.saving { edit.icon = icon; edit.error = None; } } Message::ReloadLauncherIconCatalog => { if let Some(edit) = self.launcher_edit.as_mut() { edit.icon_catalog.theme = cosmic::icon_theme::default(); edit.icon_catalog.entries.clear(); edit.icon_catalog.loading = true; edit.icon_catalog.truncated = false; return Task::perform( icon_catalog::load_icon_catalog( edit.icon_catalog.theme.clone(), edit.icon.clone(), ), |catalog| cosmic::Action::App(Message::LauncherIconCatalogLoaded(catalog)), ); } } Message::LauncherIconCatalogLoaded(catalog) => { if let Some(edit) = self.launcher_edit.as_mut() && edit.icon_catalog.theme == catalog.theme { edit.icon_catalog.entries = catalog.entries; edit.icon_catalog.loading = false; edit.icon_catalog.truncated = catalog.truncated; } } Message::SaveLauncherEdit => { let Some(edit) = self.launcher_edit.as_mut() else { return Task::none(); }; if edit.saving { return Task::none(); } if let Err(error) = launcher_edit::validate_launcher_fields(&edit.name, &edit.exec, &edit.icon) { edit.error = Some(error); return Task::none(); } let request = LauncherEditRequest { current_app_id: edit.original_app_id.clone(), source_path: edit.source_path.clone(), name: edit.name.clone(), exec: edit.exec.clone(), icon: edit.icon.clone(), terminal: edit.terminal, replace_localized_name: edit.name.trim() != edit.original_name.trim(), disable_dbus_activation: edit.exec.trim() != edit.original_exec.trim(), }; edit.saving = true; edit.error = None; return Task::perform(launcher_edit::save_launcher_edit(request), |result| { cosmic::Action::App(Message::LauncherEditSaved(result)) }); } Message::CancelLauncherEdit => { return self.close_popups(); } Message::LauncherEditSaved(result) => match result { Ok(result) => { tracing::info!( app_id = result.new_app_id, path = ?result.path, "saved editable launcher" ); let mut favorites = self.config.favorites.clone(); let mut favorites_changed = false; for favorite in &mut favorites { if *favorite == result.old_app_id && *favorite != result.new_app_id { *favorite = result.new_app_id.clone(); favorites_changed = true; } } if favorites_changed { self.config.update_pinned( favorites.clone(), &Config::new(APP_ID, AppListConfig::VERSION).unwrap(), ); self.config.favorites = favorites; } self.update_desktop_entries(); self.sync_pinned_list_from_config(); self.launcher_edit = None; return self.close_popups(); } Err(error) => { if let Some(edit) = self.launcher_edit.as_mut() { edit.saving = false; edit.error = Some(error); } } }, Message::Activate(handle) => { if let Some(tx) = self.wayland_sender.as_ref() { let _ = tx.send(WaylandRequest::Toplevel(ToplevelRequest::Activate(handle))); } if let Some(p) = self.popup.take() { return destroy_popup(p.id); } } Message::Toggle(handle) => { if let Some(tx) = self.wayland_sender.as_ref() { let _ = tx.send(WaylandRequest::Toplevel(if self.is_focused(&handle) { ToplevelRequest::Minimize(handle) } else { ToplevelRequest::Activate(handle) })); } if let Some(p) = self.popup.take() { return destroy_popup(p.id); } } Message::CloseToplevel(handle) => { if let Some(tx) = self.wayland_sender.as_ref() { let _ = tx.send(WaylandRequest::Toplevel(ToplevelRequest::Quit(handle))); } } Message::Quit(id) => { if let Some(toplevel_group) = self .active_list .iter() .chain(self.pinned_list.iter()) .find(|t| t.desktop_info.id() == id) { for (info, _) in &toplevel_group.toplevels { if let Some(tx) = self.wayland_sender.as_ref() { let _ = tx.send(WaylandRequest::Toplevel(ToplevelRequest::Quit( info.foreign_toplevel.clone(), ))); } } } if let Some(Popup { id: popup_id, .. }) = self.popup.take() { return destroy_popup(popup_id); } } Message::StartDrag(id) => { if let Some((_, toplevel_group, pos)) = self .active_list .iter() .find_map(|t| { if t.id == id { Some((false, t.clone(), None)) } else { None } }) .or_else(|| { self.pinned_list .iter() .position(|t| t.id == id) .map(|pos| (true, self.pinned_list[pos].clone(), Some(pos))) }) { let icon_id = window::Id::unique(); self.dnd_source = Some((icon_id, toplevel_group.clone(), DndAction::empty(), pos)); } } Message::DragFinished => { if let Some((_, mut toplevel_group, _, _pinned_pos)) = self.dnd_source.take() { if self.dnd_offer.take().is_some() { if let Some((_, toplevel_group, _, pinned_pos)) = self.dnd_source.as_ref() { let mut pos = 0; self.pinned_list.retain_mut(|pinned| { let matched_id = pinned.desktop_info.id() == toplevel_group.desktop_info.id(); let pinned_match = pinned_pos.is_some_and(|pinned_pos| pinned_pos == pos); let ret = !matched_id || pinned_match; pos += 1; ret }); } } if !self .pinned_list .iter() .chain(self.active_list.iter()) .any(|t| t.desktop_info.id() == toplevel_group.desktop_info.id()) && !toplevel_group.toplevels.is_empty() { self.item_ctr += 1; toplevel_group.id = self.item_ctr; self.active_list.push(toplevel_group); } } } Message::DndEnter(x, y) => { let item_size = self.core.applet.suggested_size(false).0 + 2 * self.core.applet.suggested_padding(false).0; let pos_in_list = match self.core.applet.anchor { PanelAnchor::Top | PanelAnchor::Bottom => x as f32, PanelAnchor::Left | PanelAnchor::Right => y as f32, }; let num_pinned = self.pinned_list.len(); let index = index_in_list(num_pinned, item_size as f32, 4.0, None, pos_in_list); self.dnd_offer = Some(DndOffer { preview_index: index, ..DndOffer::default() }); if let Some(dnd_source) = self.dnd_source.as_ref() { self.dnd_offer.as_mut().unwrap().dock_item = Some(dnd_source.1.clone()); } else { // TODO dnd return peek_dnd::() .map(Message::DndData) .map(cosmic::Action::App); } } Message::DndMotion(x, y) => { let item_size = self.core.applet.suggested_size(false).0 + 2 * self.core.applet.suggested_padding(false).0; let pos_in_list = match self.core.applet.anchor { PanelAnchor::Top | PanelAnchor::Bottom => x as f32, PanelAnchor::Left | PanelAnchor::Right => y as f32, }; let num_pinned = self.pinned_list.len(); let index = index_in_list( num_pinned, item_size as f32, 4.0, self.dnd_offer.as_ref().map(|o| o.preview_index), pos_in_list, ); if let Some(o) = self.dnd_offer.as_mut() { o.preview_index = index; } } Message::DndLeave => { if let Some((_, toplevel_group, _, pinned_pos)) = self.dnd_source.as_ref() { let mut pos = 0; self.pinned_list.retain_mut(|pinned| { let matched_id = pinned.desktop_info.id() == toplevel_group.desktop_info.id(); let pinned_match = pinned_pos.is_some_and(|pinned_pos| pinned_pos == pos); let ret = !matched_id || pinned_match; pos += 1; ret }); } self.dnd_offer = None; } Message::DndData(file_path) => { let Some(file_path) = file_path else { tracing::error!("Couldn't peek at hovered path."); return Task::none(); }; if let Some(DndOffer { dock_item, .. }) = self.dnd_offer.as_mut() { if let Ok(de) = fde::DesktopEntry::from_path(file_path.0, Some(&self.locales)) { self.item_ctr += 1; *dock_item = Some(DockItem { id: self.item_ctr, toplevels: Vec::new(), original_app_id: de.id().to_string(), desktop_info: de, }); } } } Message::DndDropFinished => { // we actually should have the data already, if not, we probably shouldn't do // anything anyway if let Some((mut dock_item, index)) = self .dnd_offer .take() .and_then(|o| o.dock_item.map(|i| (i, o.preview_index))) { self.item_ctr += 1; if let Some((pos, is_pinned)) = self .active_list .iter() .position(|de| de.original_app_id == dock_item.original_app_id) .map(|pos| (pos, false)) .or_else(|| { self.pinned_list .iter() .position(|de| de.original_app_id == dock_item.original_app_id) .map(|pos| (pos, true)) }) { let t = if is_pinned { let t = self.pinned_list.remove(pos); self.config.remove_pinned( &t.original_app_id, &Config::new(APP_ID, AppListConfig::VERSION).unwrap(), ); t } else { self.active_list.remove(pos) }; dock_item.toplevels = t.toplevels; } dock_item.id = self.item_ctr; if dock_item.desktop_info.exec().is_some() { self.pinned_list .insert(index.min(self.pinned_list.len()), dock_item); self.config.update_pinned( self.pinned_list .iter() .map(|dock_item| dock_item.original_app_id.clone()) .collect(), &Config::new(APP_ID, AppListConfig::VERSION).unwrap(), ); } } } Message::Wayland(event) => { match event { WaylandUpdate::Init(tx) => { self.wayland_sender.replace(tx); } WaylandUpdate::Image(handle, img) => { 'img_update: for x in self .active_list .iter_mut() .chain(self.pinned_list.iter_mut()) { if let Some((_, handle_img)) = x .toplevels .iter_mut() .find(|(info, _)| info.foreign_toplevel == handle) { *handle_img = Some(img); break 'img_update; } } } WaylandUpdate::Finished => { for t in &mut self.pinned_list { t.toplevels.clear(); } self.active_list.clear(); let subscription_ctr = self.subscription_ctr; let rand_d = fastrand::u64(0..100); return iced::Task::perform( async move { if let Some(millis) = 2u64 .checked_pow(subscription_ctr) .and_then(|d| d.checked_add(rand_d)) { sleep(Duration::from_millis(millis)).await; } else { pending::<()>().await; } }, |()| Message::IncrementSubscriptionCtr, ) .map(cosmic::action::app); } WaylandUpdate::Toplevel(event) => match event { ToplevelUpdate::Add(mut info) => { let unicase_appid = fde::unicase::Ascii::new(&*info.app_id); let new_desktop_info = self.find_desktop_entry_for_toplevel(&info, unicase_appid); if let Some(t) = self .active_list .iter_mut() .chain(self.pinned_list.iter_mut()) .find(|DockItem { desktop_info, .. }| { desktop_info.id() == new_desktop_info.id() }) { t.toplevels.push((info, None)); } else { if info.app_id.is_empty() { info.app_id = format!("Unknown Application {}", self.item_ctr); } self.item_ctr += 1; self.active_list.push(DockItem { id: self.item_ctr, original_app_id: info.app_id.clone(), toplevels: vec![(info, None)], desktop_info: new_desktop_info, }); } } ToplevelUpdate::Remove(handle) => { for t in self .active_list .iter_mut() .chain(self.pinned_list.iter_mut()) { t.toplevels .retain(|(info, _)| info.foreign_toplevel != handle); } self.active_list.retain(|t| !t.toplevels.is_empty()); if let Some(popup) = &mut self.popup && popup.popup_type == PopupType::ToplevelList { popup .dock_item .toplevels .retain(|(info, _)| info.foreign_toplevel != handle); if popup.dock_item.toplevels.is_empty() { let id = popup.id; self.popup = None; return destroy_popup(id); } } } ToplevelUpdate::Update(info) => { // TODO probably want to make sure it is removed if info.app_id.is_empty() { return Task::none(); } let mut updated_appid = false; 'toplevel_loop: for toplevel_list in self .active_list .iter_mut() .chain(self.pinned_list.iter_mut()) { for (t_info, _) in &mut toplevel_list.toplevels { if info.foreign_toplevel == t_info.foreign_toplevel { if info.app_id != t_info.app_id { updated_appid = true; } *t_info = info.clone(); break 'toplevel_loop; } } } if updated_appid { // remove the current toplevel from its dock item for t in self .active_list .iter_mut() .chain(self.pinned_list.iter_mut()) { t.toplevels .retain(|(t_info, _)| t_info.app_id != info.app_id); } self.active_list.retain(|t| !t.toplevels.is_empty()); // find a new one for it let new_desktop_entry = self.find_desktop_entry_for_toplevel( &info, Ascii::new(&info.app_id), ); if let Some(t) = self .active_list .iter_mut() .chain(self.pinned_list.iter_mut()) .find(|DockItem { desktop_info, .. }| { desktop_info.id() == new_desktop_entry.id() }) { t.toplevels.push((info, None)); } else { self.item_ctr += 1; self.active_list.push(DockItem { id: self.item_ctr, original_app_id: info.app_id.clone(), toplevels: vec![(info, None)], desktop_info: new_desktop_entry, }); } } } }, WaylandUpdate::Workspace(workspaces) => self.active_workspaces = workspaces, WaylandUpdate::Output(event) => match event { OutputUpdate::Add(output, info) => { self.output_list.insert(output, info); } OutputUpdate::Update(output, info) => { self.output_list.insert(output, info); } OutputUpdate::Remove(output) => { self.output_list.remove(&output); } }, WaylandUpdate::ActivationToken { token, app_id, exec, gpu_idx, terminal, } => { let mut envs = Vec::new(); if let Some(token) = token { envs.push(("XDG_ACTIVATION_TOKEN".to_string(), token.clone())); envs.push(("DESKTOP_STARTUP_ID".to_string(), token)); } if let (Some(gpus), Some(idx)) = (self.gpus.as_ref(), gpu_idx) { envs.extend( gpus[idx] .environment .iter() .map(|(k, v)| (k.clone(), v.clone())), ); } tokio::spawn(async move { cosmic::desktop::spawn_desktop_exec( exec, envs, app_id.as_deref(), terminal, ) .await; }); } } } Message::NewSeat(s) => { self.seat.replace(s); } Message::RemovedSeat => { self.seat.take(); } Message::Exec(exec, gpu_idx, terminal) => { if let Some(tx) = self.wayland_sender.as_ref() { let _ = tx.send(WaylandRequest::TokenRequest { app_id: Self::APP_ID.to_string(), exec, gpu_idx, terminal, }); } } Message::Rectangle(u) => match u { RectangleUpdate::Rectangle(r) => { self.rectangles.insert(r.0, r.1); } RectangleUpdate::Init(tracker) => { self.rectangle_tracker.replace(tracker); } }, Message::ClosePopup => { if let Some(p) = self.popup.take() { if p.popup_type == PopupType::LauncherEditor { self.launcher_edit = None; } return destroy_popup(p.id); } } Message::StartListeningForDnd => { self.is_listening_for_dnd = true; } Message::StopListeningForDnd => { self.is_listening_for_dnd = false; } Message::IncrementSubscriptionCtr => { self.subscription_ctr += 1; } Message::ConfigUpdated(config) => { self.config = config; self.update_desktop_entries(); self.sync_pinned_list_from_config(); } Message::CloseRequested(id) => { if let Some(popup) = &self.popup && popup.id == id { if popup.popup_type == PopupType::LauncherEditor { self.launcher_edit = None; } self.popup = None; } if self.overflow_active_popup.is_some_and(|p| p == id) { self.overflow_active_popup = None; } if self.overflow_favorites_popup.is_some_and(|p| p == id) { self.overflow_favorites_popup = None; } } 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()]; // create a popup with the active list if create_new { let new_id = window::Id::unique(); self.overflow_active_popup = Some(new_id); let rectangle = self.rectangles.get(&DockItemId::ActiveOverflow); let mut popup_settings = self.core.applet.get_popup_settings( self.core.main_window_id().unwrap(), new_id, None, None, None, ); if let Some(iced::Rectangle { x, y, width, height, }) = rectangle { popup_settings.positioner.anchor_rect = iced::Rectangle:: { x: *x as i32, y: *y as i32, width: *width as i32, height: *height as i32, }; } let applet_suggested_size = self.core.applet.suggested_size(false).0 + 2 * self.core.applet.suggested_padding(false).0; let (_favorite_popup_cutoff, active_popup_cutoff) = self.panel_overflow_lengths(); let popup_applet_count = self.active_list.len().saturating_sub( (active_popup_cutoff.unwrap_or_default()).saturating_sub(1), ) as f32; let popup_applet_size = applet_suggested_size as f32 * popup_applet_count + 4.0 * (popup_applet_count - 1.); let (max_width, max_height) = match self.core.applet.anchor { PanelAnchor::Top | PanelAnchor::Bottom => { (popup_applet_size, applet_suggested_size as f32) } PanelAnchor::Left | PanelAnchor::Right => { (applet_suggested_size as f32, popup_applet_size) } }; popup_settings.positioner.size_limits = Limits::NONE .max_width(max_width) .min_width(1.) .max_height(max_height) .min_height(1.); cmds.push(get_popup(popup_settings)); } return Task::batch(cmds); } Message::OpenFavorites => { let create_new = self.overflow_favorites_popup.is_none(); let mut cmds = vec![self.close_popups()]; // create a popup with the favorites list if create_new { let new_id = window::Id::unique(); self.overflow_favorites_popup = Some(new_id); let rectangle = self.rectangles.get(&DockItemId::FavoritesOverflow); let mut popup_settings = self.core.applet.get_popup_settings( self.core.main_window_id().unwrap(), new_id, None, None, None, ); if let Some(iced::Rectangle { x, y, width, height, }) = rectangle { popup_settings.positioner.anchor_rect = iced::Rectangle:: { x: *x as i32, y: *y as i32, width: *width as i32, height: *height as i32, }; } let applet_suggested_size = self.core.applet.suggested_size(false).0 + 2 * self.core.applet.suggested_padding(false).0; let (favorite_popup_cutoff, _active_popup_cutoff) = self.panel_overflow_lengths(); let popup_applet_count = self.pinned_list.len().saturating_sub( favorite_popup_cutoff.unwrap_or_default().saturating_sub(1), ) as f32; let popup_applet_size = applet_suggested_size as f32 * popup_applet_count + 4.0 * (popup_applet_count - 1.); let (max_width, max_height) = match self.core.applet.anchor { PanelAnchor::Top | PanelAnchor::Bottom => { (popup_applet_size, applet_suggested_size as f32) } PanelAnchor::Left | PanelAnchor::Right => { (applet_suggested_size as f32, popup_applet_size) } }; popup_settings.positioner.size_limits = Limits::NONE .max_width(max_width) .min_width(1.) .max_height(max_height) .min_height(1.); cmds.push(get_popup(popup_settings)); } return Task::batch(cmds); } Message::Pressed(id) => { if self.popup.is_some() && self.core.main_window_id() == Some(id) { return self.close_popups(); } } Message::Surface(a) => { return cosmic::task::message(cosmic::Action::Cosmic( cosmic::app::Action::Surface(a), )); } } Task::none() } fn view(&self) -> Element<'_, Message> { let focused_item = self.currently_active_toplevel(); let theme = self.core.system_theme(); let dot_radius = theme.cosmic().radius_xs(); let app_icon = AppletIconData::new(&self.core.applet); let is_horizontal = match self.core.applet.anchor { PanelAnchor::Top | PanelAnchor::Bottom => true, PanelAnchor::Left | PanelAnchor::Right => false, }; let divider_padding = match self.core.applet.size { Size::Hardcoded(_) => 4, Size::PanelSize(ref s) => { let size = s.get_applet_icon_size_with_padding(false); let small_size_threshold = PanelSize::S.get_applet_icon_size_with_padding(false); if size <= small_size_threshold { 4 } else { 8 } } }; let (favorite_popup_cutoff, active_popup_cutoff) = self.panel_overflow_lengths(); let mut favorite_to_remove = if let Some(cutoff) = favorite_popup_cutoff { if cutoff < self.pinned_list.len() { self.pinned_list.len() - cutoff + 1 } else { 0 } } else { 0 }; let favorites: Vec<_> = self .pinned_list .iter() .rev() .filter(|f| { if favorite_to_remove > 0 && f.toplevels.is_empty() { favorite_to_remove -= 1; false } else { true } }) .collect(); let mut favorites: Vec<_> = favorites[favorite_to_remove..] .iter() .rev() .map(|dock_item| { let filtered_is_focused = dock_item .toplevels .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)); let tooltip = self.core .applet .applet_tooltip::( dock_item.as_icon( &self.core.applet, self.rectangle_tracker.as_ref(), self.popup.is_none(), self.config.enable_drag_source, self.gpus.as_deref(), filtered_is_focused, dot_radius, self.core.main_window_id().unwrap(), Some(&|info| self.is_on_current_monitor_and_workspace(info)), icon_scale, ), dock_item .desktop_info .full_name(&self.locales) .unwrap_or_default() .into_owned(), 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(); if favorite_popup_cutoff.is_some() { // button to show more favorites let icon = match self.core.applet.anchor { PanelAnchor::Bottom => "go-up-symbolic", PanelAnchor::Left => "go-next-symbolic", PanelAnchor::Right => "go-previous-symbolic", PanelAnchor::Top => "go-down-symbolic", }; let btn = self .core .applet .icon_button(icon) .on_press(Message::OpenFavorites); let btn: Element<_> = if let Some(rectangle_tracker) = self.rectangle_tracker.as_ref() { rectangle_tracker .container(DockItemId::FavoritesOverflow, btn) .into() } else { btn.into() }; favorites.push(btn); } if let Some((item, index)) = self .dnd_offer .as_ref() .and_then(|o| o.dock_item.as_ref().map(|item| (item, o.preview_index))) { let filtered_is_focused = item .toplevels .iter() .filter(|(info, _)| self.is_on_current_monitor_and_workspace(info)) .any(|y| focused_item.contains(&y.0.foreign_toplevel)); favorites.insert( index.min(favorites.len()), item.as_icon( &self.core.applet, None, false, self.config.enable_drag_source, self.gpus.as_deref(), filtered_is_focused, 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() { // show star indicating pinned_list is drag target favorites.push( container( icon::from_name("starred-symbolic.symbolic") .size(self.core.applet.suggested_size(false).0), ) .padding(self.core.applet.suggested_padding(false).1) // TODO .into(), ); } let filtered_active_list: Vec<_> = self .active_list .iter() .filter(|dock_item| { dock_item.toplevels.iter().any(|(toplevel_info, _)| { self.is_on_current_monitor_and_workspace(toplevel_info) }) }) .collect(); let mut active: Vec<_> = filtered_active_list[..active_popup_cutoff.map_or(filtered_active_list.len(), |n| { if n < filtered_active_list.len() { n.saturating_sub(1) } else { n } })] .iter() .map(|dock_item| { let filtered_is_focused = dock_item .toplevels .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)); let tooltip = self.core .applet .applet_tooltip( dock_item.as_icon( &self.core.applet, self.rectangle_tracker.as_ref(), self.popup.is_none(), self.config.enable_drag_source, self.gpus.as_deref(), filtered_is_focused, dot_radius, self.core.main_window_id().unwrap(), Some(&|info| self.is_on_current_monitor_and_workspace(info)), icon_scale, ), dock_item .desktop_info .full_name(&self.locales) .unwrap_or_default() .into_owned(), 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(); if active_popup_cutoff.is_some_and(|n| n < filtered_active_list.len()) { // button to show more active let icon = match self.core.applet.anchor { PanelAnchor::Bottom => "go-up-symbolic", PanelAnchor::Left => "go-next-symbolic", PanelAnchor::Right => "go-previous-symbolic", PanelAnchor::Top => "go-down-symbolic", }; let btn = self .core .applet .icon_button(icon) .on_press(Message::OpenActive); let btn: Element<_> = if let Some(rectangle_tracker) = self.rectangle_tracker.as_ref() { rectangle_tracker .container(DockItemId::ActiveOverflow, btn) .into() } else { btn.into() }; active.push(btn); } let window_size = self.core.applet.suggested_bounds.as_ref(); let max_num = if self.core.applet.is_horizontal() { let suggested_width = self.core.applet.suggested_size(false).0 + self.core.applet.suggested_padding(false).0 * 2; window_size .map(|w| w.width) .map_or(u32::MAX, |b| (b / suggested_width as f32) as u32) as usize } else { let suggested_height = self.core.applet.suggested_size(false).1 + self.core.applet.suggested_padding(false).0 * 2; window_size .map(|w| w.height) .map_or(u32::MAX, |b| (b / suggested_height as f32) as u32) as usize } .max(4); if max_num < favorites.len() + active.len() { let active_leftover = max_num.saturating_sub(favorites.len()); favorites.truncate(max_num - active_leftover); active.truncate(active_leftover); } let (w, h, favorites, active, divider) = if is_horizontal { ( Length::Shrink, Length::Shrink, DndDestination::for_data::( row(favorites).spacing(app_icon.icon_spacing), |_, _| Message::DndDropFinished, ) .drag_id(DND_FAVORITES), row(active).spacing(app_icon.icon_spacing).into(), container(vertical_rule(1)) .height(Length::Fill) .padding([divider_padding, 0]) .into(), ) } else { ( Length::Shrink, Length::Shrink, DndDestination::for_data( column(favorites).spacing(app_icon.icon_spacing), |_data: Option, _| Message::DndDropFinished, ) .drag_id(DND_FAVORITES), column(active).spacing(app_icon.icon_spacing).into(), container(divider::horizontal::default()) .width(Length::Fill) .padding([0, divider_padding]) .into(), ) }; let favorites = favorites .on_enter(|x, y, _| Message::DndEnter(x, y)) .on_motion(Message::DndMotion) .on_leave(|| Message::DndLeave); let show_pinned = !self.pinned_list.is_empty() || self.dnd_offer.is_some() || self.is_listening_for_dnd; let content_list: Vec> = if show_pinned && !self.active_list.is_empty() { vec![favorites.into(), divider, active] } else if show_pinned { vec![favorites.into()] } else if !self.active_list.is_empty() { vec![active] } else { vec![ icon::from_name("com.system76.CosmicAppList") .size(self.core.applet.suggested_size(false).0) .into(), ] }; let mut content = match &self.core.applet.anchor { PanelAnchor::Left | PanelAnchor::Right => container( Column::with_children(content_list) .spacing(4.0) .align_x(Alignment::Center) .height(h) .width(w), ), PanelAnchor::Top | PanelAnchor::Bottom => container( Row::with_children(content_list) .spacing(4.0) .align_y(Alignment::Center) .height(h) .width(w), ), }; if self.active_list.is_empty() && self.pinned_list.is_empty() { let suggested_size = self.core.applet.suggested_size(false); content = content.width(suggested_size.0).height(suggested_size.1); } let mut limits = Limits::NONE.min_width(1.).min_height(1.); if let Some(b) = self.core.applet.suggested_bounds { if b.width as i32 > 0 { limits = limits.max_width(b.width); } if b.height as i32 > 0 { limits = limits.max_height(b.height); } } self.core .applet .autosize_window(content) .limits(limits) .into() } fn view_window(&self, id: window::Id) -> Element<'_, Message> { let theme = self.core.system_theme(); if let Some((_, item, _, _)) = self.dnd_source.as_ref().filter(|s| s.0 == id) { cosmic::widget::icon( fde::IconSource::from_unknown(item.desktop_info.icon().unwrap_or_default()) .as_cosmic_icon(), ) .size(self.core.applet.suggested_size(false).0) .into() } else if let Some(Popup { dock_item: DockItem { id, .. }, popup_type, .. }) = self.popup.as_ref().filter(|p| id == p.id) { let (dock_item, is_pinned) = match self.pinned_list.iter().find(|i| i.id == *id) { Some(e) => (e, true), None => match self.active_list.iter().find(|i| i.id == *id) { Some(e) => (e, false), None => return text::body("").into(), }, }; // Filter toplevels to only show windows on current monitor and workspace let filtered_toplevels: Vec<_> = dock_item .toplevels .iter() .filter(|(toplevel_info, _)| { self.is_on_current_monitor_and_workspace(toplevel_info) }) .collect(); let toplevels = &filtered_toplevels; let desktop_info = &dock_item.desktop_info; match popup_type { PopupType::RightClickMenu => { fn menu_button<'a, Message: Clone + 'a>( content: impl Into>, ) -> cosmic::widget::Button<'a, Message> { button::custom(content) .height(20 + 2 * theme::spacing().space_xxs) .class(Button::MenuItem) .padding(menu_control_padding()) .width(Length::Fill) } let mut content = column![].align_x(Alignment::Center); if let Some(exec) = desktop_info.exec() { if !toplevels.is_empty() { content = content.push(menu_button(text::body(fl!("new-window"))).on_press( Message::Exec(exec.to_string(), None, desktop_info.terminal()), )); } else if let Some(gpus) = self.gpus.as_ref() { let default_idx = preferred_gpu_idx(desktop_info, gpus.iter()); for (i, gpu) in gpus.iter().enumerate() { content = content.push( menu_button(text::body(format!( "{} {}", fl!("run-on", gpu = gpu.name.clone()), if i == default_idx { fl!("run-on-default") } else { String::new() } ))) .on_press(Message::Exec( exec.to_string(), Some(i), desktop_info.terminal(), )), ); } } else { content = content.push(menu_button(text::body(fl!("run"))).on_press( Message::Exec(exec.to_string(), None, desktop_info.terminal()), )); } for action in desktop_info.actions().into_iter().flatten() { if action == "new-window" { continue; } let Some(exec) = desktop_info.action_entry(action, "Exec") else { continue; }; let Some(name) = desktop_info.action_entry_localized(action, "Name", &self.locales) else { continue; }; content = content.push(menu_button(text::body(name)).on_press( Message::Exec(exec.into(), None, desktop_info.terminal()), )); } content = content.push(divider::horizontal::light()); } if !toplevels.is_empty() { let mut list_col = column![]; for (info, _) in toplevels { list_col = list_col.push( menu_button( text::body(&info.title) .ellipsize(Ellipsize::End(EllipsizeHeightLimit::Lines(1))), ) .on_press(Message::Activate(info.foreign_toplevel.clone())), ); } content = content.push(list_col); content = content.push(divider::horizontal::light()); } let svg_accent = Rc::new(|theme: &cosmic::Theme| { let color = theme.cosmic().accent_color().into(); svg::Style { color: Some(color) } }); content = content.push( menu_button( if is_pinned { row![ icon::icon(from_name("checkbox-checked-symbolic").into()) .size(16) .class(cosmic::theme::Svg::Custom(svg_accent.clone())), text::body(fl!("pin")) ] } else { row![text::body(fl!("pin"))] } .spacing(8), ) .on_press(if is_pinned { Message::UnpinApp(*id) } else { Message::PinApp(*id) }), ); if is_pinned && desktop_info.exec().is_some() { content = content.push( menu_button( row![ icon::icon(from_name("edit-symbolic").into()).size(16), text::body(fl!("edit-launcher")) ] .spacing(8), ) .on_press(Message::EditLauncher(*id)), ); } if !toplevels.is_empty() { content = content.push(divider::horizontal::light()); content = match toplevels.len() { 1 => content.push( menu_button(text::body(fl!("quit"))) .on_press(Message::Quit(desktop_info.id().to_string())), ), _ => content.push( menu_button(text::body(fl!("quit-all"))) .on_press(Message::Quit(desktop_info.id().to_string())), ), }; } self.core .applet .popup_container( container(content) .padding(1) //TODO: move style to libcosmic .class(theme::Container::custom(|theme| { let cosmic = theme.cosmic(); let component = &cosmic.background.component; container::Style { icon_color: Some(component.on.into()), text_color: Some(component.on.into()), background: Some(Background::Color(component.base.into())), border: Border { radius: cosmic.radius_s().into(), width: 1.0, color: component.divider.into(), }, ..Default::default() } })) .height(Length::Shrink) .width(Length::Fill), ) .limits( Limits::NONE .min_width(1.) .min_height(1.) .max_width(300.) .max_height(1000.), ) .into() } PopupType::LauncherEditor => { let Some(edit) = self.launcher_edit.as_ref() else { return text::body("").into(); }; let spacing = theme::spacing(); let can_save = !edit.saving && launcher_edit::validate_launcher_fields( &edit.name, &edit.exec, &edit.icon, ) .is_ok(); let mut form = column![ text::title4(fl!("edit-launcher")), text_input("", edit.name.as_str()) .label(fl!("launcher-name")) .on_input(Message::LauncherNameChanged) .on_submit(|_| Message::SaveLauncherEdit) .width(Length::Fill) .size(14), text_input("", edit.exec.as_str()) .label(fl!("launcher-command")) .on_input(Message::LauncherExecChanged) .on_submit(|_| Message::SaveLauncherEdit) .width(Length::Fill) .size(14), launcher_icon_editor(edit), ] .spacing(spacing.space_s) .width(Length::Fill); if let Some(error) = edit.error.as_ref() { form = form.push(text::caption(error.as_str()).class( cosmic::theme::Text::Color(theme.cosmic().destructive_color().into()), )); } let cancel = button::custom(text::body(fl!("cancel")).center().width(Length::Fill)) .on_press_maybe(if edit.saving { None } else { Some(Message::CancelLauncherEdit) }) .padding([spacing.space_xxs, spacing.space_s]) .width(142); let save = button::custom(text::body(fl!("save")).center().width(Length::Fill)) .class(Button::Suggested) .on_press_maybe(if can_save { Some(Message::SaveLauncherEdit) } else { None }) .padding([spacing.space_xxs, spacing.space_s]) .width(142); let actions = row![horizontal_space(), cancel, save] .spacing(spacing.space_xxs) .align_y(Alignment::Center); let content = column![form, actions] .spacing(spacing.space_m) .padding(spacing.space_m) .width(Length::Fill); self.core .applet .popup_container(container(content).width(Length::Fill)) .limits( Limits::NONE .min_width(480.) .min_height(1.) .max_width(520.) .max_height(1000.), ) .into() } PopupType::ToplevelList => match self.core.applet.anchor { PanelAnchor::Left | PanelAnchor::Right => { let mut content = column![].padding(8).align_x(Alignment::Center).spacing(8); for (info, img) in toplevels { content = content.push(toplevel_button( img.clone(), info.title.clone(), info.foreign_toplevel.clone(), self.is_focused(&info.foreign_toplevel), self.is_hovered(&info.foreign_toplevel), )); } self.core .applet .popup_container(content) .limits(Limits::NONE.min_width(1.).min_height(1.).max_height(1000.)) .into() } PanelAnchor::Bottom | PanelAnchor::Top => { let mut content = row![].padding(8).align_y(Alignment::Center).spacing(8); for (info, img) in toplevels { content = content.push(toplevel_button( img.clone(), info.title.clone(), info.foreign_toplevel.clone(), self.is_focused(&info.foreign_toplevel), self.is_hovered(&info.foreign_toplevel), )); } self.core .applet .popup_container(content) .limits(Limits::NONE.min_width(1.).min_height(1.).max_height(1000.)) .into() } }, } } else if self .overflow_active_popup .as_ref() .is_some_and(|overflow_id| overflow_id == &id) { let (_favorite_popup_cutoff, active_popup_cutoff) = self.panel_overflow_lengths(); let focused_item = self.currently_active_toplevel(); let dot_radius = theme.cosmic().radius_xs(); let filtered_active_list: Vec<_> = self .active_list .iter() .filter(|dock_item| { dock_item.toplevels.iter().any(|(toplevel_info, _)| { self.is_on_current_monitor_and_workspace(toplevel_info) }) }) .collect(); let active: Vec<_> = filtered_active_list .iter() .rev() .take(active_popup_cutoff.map_or(filtered_active_list.len(), |n| { if n < filtered_active_list.len() { filtered_active_list.len() - n + 1 } else { 0 } })) .map(|dock_item| { let filtered_is_focused = dock_item .toplevels .iter() .filter(|(info, _)| self.is_on_current_monitor_and_workspace(info)) .any(|y| focused_item.contains(&y.0.foreign_toplevel)); self.core .applet .applet_tooltip( dock_item.as_icon( &self.core.applet, self.rectangle_tracker.as_ref(), self.popup.is_none(), self.config.enable_drag_source, self.gpus.as_deref(), filtered_is_focused, 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 .full_name(&self.locales) .unwrap_or_default() .into_owned(), self.popup.is_some(), Message::Surface, Some(id), ) .into() }) .collect(); let content = match &self.core.applet.anchor { PanelAnchor::Left | PanelAnchor::Right => container( Column::with_children(active) .spacing(4.0) .align_x(Alignment::Center) .width(Length::Shrink) .height(Length::Shrink), ), PanelAnchor::Top | PanelAnchor::Bottom => container( Row::with_children(active) .spacing(4.0) .align_y(Alignment::Center) .width(Length::Shrink) .height(Length::Shrink), ), }; // send clear popup on press content if there is an active popup let content: Element<_> = if self.popup.is_some() { mouse_area(content) .on_release(Message::ClosePopup) .on_right_release(Message::ClosePopup) .into() } else { content.into() }; self.core .applet .popup_container(content) .limits( Limits::NONE .min_width(1.) .min_height(1.) .max_width(1920.) .max_height(1000.), ) .into() } else if self .overflow_favorites_popup .as_ref() .is_some_and(|popup_id| popup_id == &id) { let (favorite_popup_cutoff, _active_popup_cutoff) = self.panel_overflow_lengths(); let focused_item = self.currently_active_toplevel(); let dot_radius = theme.cosmic().radius_xs(); // show the overflow popup for favorites list let mut favorite_to_remove = if let Some(cutoff) = favorite_popup_cutoff { if cutoff < self.pinned_list.len() { self.pinned_list.len() - cutoff + 1 } else { 0 } } else { 0 }; let mut favorites_extra = Vec::with_capacity(favorite_to_remove); let mut favorites: Vec<_> = self .pinned_list .iter() .rev() .filter(|f| { if favorite_to_remove > 0 && f.toplevels.is_empty() { favorite_to_remove -= 1; true } else { favorites_extra.push(*f); false } }) .collect(); favorites.extend(favorites_extra[..favorite_to_remove].iter().copied()); let favorites: Vec<_> = favorites .iter() .rev() .map(|dock_item| { let filtered_is_focused = dock_item .toplevels .iter() .filter(|(info, _)| self.is_on_current_monitor_and_workspace(info)) .any(|y| focused_item.contains(&y.0.foreign_toplevel)); self.core .applet .applet_tooltip( dock_item.as_icon( &self.core.applet, self.rectangle_tracker.as_ref(), self.popup.is_none(), self.config.enable_drag_source, self.gpus.as_deref(), filtered_is_focused, 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 .full_name(&self.locales) .unwrap_or_default() .to_string(), self.popup.is_some(), Message::Surface, Some(id), ) .into() }) .collect(); let content = match &self.core.applet.anchor { PanelAnchor::Left | PanelAnchor::Right => container( Column::with_children(favorites) .spacing(4.0) .align_x(Alignment::Center) .width(Length::Shrink) .height(Length::Shrink), ), PanelAnchor::Top | PanelAnchor::Bottom => container( Row::with_children(favorites) .spacing(4.0) .align_y(Alignment::Center) .width(Length::Shrink) .height(Length::Shrink), ), }; let content: Element<_> = if self.popup.is_some() { mouse_area(content) .on_right_release(Message::ClosePopup) .on_press(Message::ClosePopup) .into() } else { content.into() }; self.core .applet .popup_container(content) .limits( Limits::NONE .min_width(1.) .min_height(1.) .max_width(1920.) .max_height(1000.), ) .into() } else { let suggested = self.core.applet.suggested_size(false); iced::widget::row!() .width(Length::Fixed(suggested.0 as f32)) .height(Length::Fixed(suggested.1 as f32)) .into() } } 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( event::wayland::Event::Seat(e, seat), )) => match e { event::wayland::SeatEvent::Enter => Some(Message::NewSeat(seat)), event::wayland::SeatEvent::Leave => Some(Message::RemovedSeat), }, cosmic::iced::core::Event::Mouse( cosmic::iced::core::mouse::Event::ButtonPressed(_), ) => Some(Message::Pressed(id)), _ => None, }), rectangle_tracker_subscription(0).map(|update| Message::Rectangle(update.1)), self.core.watch_config(APP_ID).map(|u| { for why in u.errors { tracing::error!(why = why.to_string(), "Error watching config"); } Message::ConfigUpdated(u.config) }), ]) } fn style(&self) -> Option { Some(cosmic::applet::style()) } fn on_close_requested(&self, id: window::Id) -> Option { Some(Message::CloseRequested(id)) } } fn launch_on_preferred_gpu(desktop_info: &DesktopEntry, gpus: Option<&[Gpu]>) -> Option { let exec = desktop_info.exec()?; let gpu_idx = gpus.map(|gpus| preferred_gpu_idx(desktop_info, gpus.iter())); Some(Message::Exec( exec.to_string(), gpu_idx, desktop_info.terminal(), )) } fn preferred_gpu_idx<'a, I>(desktop_info: &DesktopEntry, mut gpus: I) -> usize where I: Iterator, { gpus.position(|gpu| gpu.default ^ desktop_info.prefers_non_default_gpu()) .unwrap_or(0) } #[derive(Debug, Default, Clone)] pub struct DndPathBuf(PathBuf); impl AllowedMimeTypes for DndPathBuf { fn allowed() -> std::borrow::Cow<'static, [String]> { std::borrow::Cow::Owned(vec![MIME_TYPE.to_string()]) } } impl TryFrom<(Vec, String)> for DndPathBuf { type Error = anyhow::Error; fn try_from((data, mime_type): (Vec, String)) -> Result { if mime_type == MIME_TYPE { if let Some(p) = String::from_utf8(data) .ok() .and_then(|s| Url::from_str(&s).ok()) .and_then(|u| u.to_file_path().ok()) { Ok(DndPathBuf(p)) } else { anyhow::bail!("Failed to parse.") } } else { anyhow::bail!("Invalid mime type.") } } } impl AsMimeTypes for DndPathBuf { fn available(&self) -> std::borrow::Cow<'static, [String]> { std::borrow::Cow::Owned(vec![MIME_TYPE.to_string()]) } fn as_bytes(&self, _mime_type: &str) -> Option> { Some(Cow::Owned(self.0.to_str()?.as_bytes().to_vec())) } }