feat: add themed launcher icon catalog
Some checks failed
Continuous Integration / formatting (push) Has been cancelled
Continuous Integration / linting (push) Has been cancelled

This commit is contained in:
Lionel DARNIS 2026-05-26 12:03:59 +02:00
parent 339ac4e3e4
commit da53a9f45f
5 changed files with 665 additions and 14 deletions

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: GPL-3.0-only
use crate::{
fl,
fl, icon_catalog,
launcher_edit::{self, LauncherEditRequest},
wayland_subscription::{
OutputUpdate, ToplevelRequest, ToplevelUpdate, WaylandImage, WaylandRequest, WaylandUpdate,
@ -44,11 +44,11 @@ use cosmic::{
surface,
theme::{self, Button, Container},
widget::{
DndDestination, Image, button, container, divider, dnd_source,
DndDestination, Image, button, container, divider, dnd_source, grid,
icon::{self, from_name},
image::Handle,
rectangle_tracker::{RectangleTracker, RectangleUpdate, rectangle_tracker_subscription},
svg, text, text_input,
scrollable, svg, text, text_input,
},
};
use cosmic::{
@ -65,6 +65,8 @@ 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::<CosmicAppList>(())
@ -400,6 +402,16 @@ struct LauncherEditState {
terminal: bool,
saving: bool,
error: Option<String>,
icon_catalog: IconCatalogState,
}
#[derive(Debug, Clone)]
struct IconCatalogState {
theme: String,
query: String,
entries: Vec<icon_catalog::IconCatalogEntry>,
loading: bool,
truncated: bool,
}
#[derive(Clone, Default)]
@ -461,6 +473,10 @@ enum Message {
LauncherNameChanged(String),
LauncherExecChanged(String),
LauncherIconChanged(String),
LauncherIconSearchChanged(String),
LauncherIconSelected(String),
ReloadLauncherIconCatalog,
LauncherIconCatalogLoaded(icon_catalog::IconCatalog),
SaveLauncherEdit,
CancelLauncherEdit,
LauncherEditSaved(Result<launcher_edit::LauncherEditResult, String>),
@ -708,6 +724,143 @@ pub fn menu_control_padding() -> Padding {
[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],
@ -1281,6 +1434,12 @@ impl cosmic::Application for CosmicAppList {
})
.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(),
@ -1289,18 +1448,26 @@ impl cosmic::Application for CosmicAppList {
original_exec: original_exec.clone(),
name: original_name,
exec: original_exec,
icon: dock_item
.desktop_info
.icon()
.unwrap_or_default()
.to_string(),
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()
@ -1326,6 +1493,44 @@ impl cosmic::Application for CosmicAppList {
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();
@ -2633,12 +2838,7 @@ impl cosmic::Application for CosmicAppList {
.on_submit(|_| Message::SaveLauncherEdit)
.width(Length::Fill)
.size(14),
text_input("", edit.icon.as_str())
.label(fl!("launcher-icon"))
.on_input(Message::LauncherIconChanged)
.on_submit(|_| Message::SaveLauncherEdit)
.width(Length::Fill)
.size(14),
launcher_icon_editor(edit),
]
.spacing(spacing.space_s)
.width(Length::Fill);