feat: add themed launcher icon catalog
This commit is contained in:
parent
339ac4e3e4
commit
da53a9f45f
5 changed files with 665 additions and 14 deletions
|
|
@ -10,5 +10,10 @@ edit-launcher = Edit launcher
|
||||||
launcher-name = Name
|
launcher-name = Name
|
||||||
launcher-command = Command
|
launcher-command = Command
|
||||||
launcher-icon = Icon
|
launcher-icon = Icon
|
||||||
|
launcher-icon-theme = Theme
|
||||||
|
launcher-icon-search = Search icons
|
||||||
|
launcher-icon-catalog-loading = Loading icons
|
||||||
|
launcher-icon-catalog-empty = No icons
|
||||||
|
launcher-icons = icons
|
||||||
save = Save
|
save = Save
|
||||||
cancel = Cancel
|
cancel = Cancel
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,10 @@ edit-launcher = Modifier le lanceur
|
||||||
launcher-name = Nom
|
launcher-name = Nom
|
||||||
launcher-command = Commande
|
launcher-command = Commande
|
||||||
launcher-icon = Icône
|
launcher-icon = Icône
|
||||||
|
launcher-icon-theme = Thème
|
||||||
|
launcher-icon-search = Rechercher une icône
|
||||||
|
launcher-icon-catalog-loading = Chargement des icônes
|
||||||
|
launcher-icon-catalog-empty = Aucune icône
|
||||||
|
launcher-icons = icônes
|
||||||
save = Enregistrer
|
save = Enregistrer
|
||||||
cancel = Annuler
|
cancel = Annuler
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-only
|
// SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
fl,
|
fl, icon_catalog,
|
||||||
launcher_edit::{self, LauncherEditRequest},
|
launcher_edit::{self, LauncherEditRequest},
|
||||||
wayland_subscription::{
|
wayland_subscription::{
|
||||||
OutputUpdate, ToplevelRequest, ToplevelUpdate, WaylandImage, WaylandRequest, WaylandUpdate,
|
OutputUpdate, ToplevelRequest, ToplevelUpdate, WaylandImage, WaylandRequest, WaylandUpdate,
|
||||||
|
|
@ -44,11 +44,11 @@ use cosmic::{
|
||||||
surface,
|
surface,
|
||||||
theme::{self, Button, Container},
|
theme::{self, Button, Container},
|
||||||
widget::{
|
widget::{
|
||||||
DndDestination, Image, button, container, divider, dnd_source,
|
DndDestination, Image, button, container, divider, dnd_source, grid,
|
||||||
icon::{self, from_name},
|
icon::{self, from_name},
|
||||||
image::Handle,
|
image::Handle,
|
||||||
rectangle_tracker::{RectangleTracker, RectangleUpdate, rectangle_tracker_subscription},
|
rectangle_tracker::{RectangleTracker, RectangleUpdate, rectangle_tracker_subscription},
|
||||||
svg, text, text_input,
|
scrollable, svg, text, text_input,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use cosmic::{
|
use cosmic::{
|
||||||
|
|
@ -65,6 +65,8 @@ use tokio::time::sleep;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
static MIME_TYPE: &str = "text/uri-list";
|
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 {
|
pub fn run() -> cosmic::iced::Result {
|
||||||
cosmic::applet::run::<CosmicAppList>(())
|
cosmic::applet::run::<CosmicAppList>(())
|
||||||
|
|
@ -400,6 +402,16 @@ struct LauncherEditState {
|
||||||
terminal: bool,
|
terminal: bool,
|
||||||
saving: bool,
|
saving: bool,
|
||||||
error: Option<String>,
|
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)]
|
#[derive(Clone, Default)]
|
||||||
|
|
@ -461,6 +473,10 @@ enum Message {
|
||||||
LauncherNameChanged(String),
|
LauncherNameChanged(String),
|
||||||
LauncherExecChanged(String),
|
LauncherExecChanged(String),
|
||||||
LauncherIconChanged(String),
|
LauncherIconChanged(String),
|
||||||
|
LauncherIconSearchChanged(String),
|
||||||
|
LauncherIconSelected(String),
|
||||||
|
ReloadLauncherIconCatalog,
|
||||||
|
LauncherIconCatalogLoaded(icon_catalog::IconCatalog),
|
||||||
SaveLauncherEdit,
|
SaveLauncherEdit,
|
||||||
CancelLauncherEdit,
|
CancelLauncherEdit,
|
||||||
LauncherEditSaved(Result<launcher_edit::LauncherEditResult, String>),
|
LauncherEditSaved(Result<launcher_edit::LauncherEditResult, String>),
|
||||||
|
|
@ -708,6 +724,143 @@ pub fn menu_control_padding() -> Padding {
|
||||||
[spacing.space_xxs, spacing.space_s].into()
|
[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>(
|
fn find_desktop_entries<'a>(
|
||||||
desktop_entries: &'a [fde::DesktopEntry],
|
desktop_entries: &'a [fde::DesktopEntry],
|
||||||
app_ids: &'a [String],
|
app_ids: &'a [String],
|
||||||
|
|
@ -1281,6 +1434,12 @@ impl cosmic::Application for CosmicAppList {
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| dock_item.original_app_id.clone());
|
.unwrap_or_else(|| dock_item.original_app_id.clone());
|
||||||
let original_exec = exec.to_string();
|
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 {
|
self.launcher_edit = Some(LauncherEditState {
|
||||||
original_app_id: dock_item.original_app_id.clone(),
|
original_app_id: dock_item.original_app_id.clone(),
|
||||||
|
|
@ -1289,18 +1448,26 @@ impl cosmic::Application for CosmicAppList {
|
||||||
original_exec: original_exec.clone(),
|
original_exec: original_exec.clone(),
|
||||||
name: original_name,
|
name: original_name,
|
||||||
exec: original_exec,
|
exec: original_exec,
|
||||||
icon: dock_item
|
icon: original_icon.clone(),
|
||||||
.desktop_info
|
|
||||||
.icon()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string(),
|
|
||||||
terminal: dock_item.desktop_info.terminal(),
|
terminal: dock_item.desktop_info.terminal(),
|
||||||
saving: false,
|
saving: false,
|
||||||
error: None,
|
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.dock_item = dock_item;
|
||||||
existing_popup.popup_type = PopupType::LauncherEditor;
|
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) => {
|
Message::LauncherNameChanged(name) => {
|
||||||
if let Some(edit) = self.launcher_edit.as_mut()
|
if let Some(edit) = self.launcher_edit.as_mut()
|
||||||
|
|
@ -1326,6 +1493,44 @@ impl cosmic::Application for CosmicAppList {
|
||||||
edit.error = None;
|
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 => {
|
Message::SaveLauncherEdit => {
|
||||||
let Some(edit) = self.launcher_edit.as_mut() else {
|
let Some(edit) = self.launcher_edit.as_mut() else {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
|
|
@ -2633,12 +2838,7 @@ impl cosmic::Application for CosmicAppList {
|
||||||
.on_submit(|_| Message::SaveLauncherEdit)
|
.on_submit(|_| Message::SaveLauncherEdit)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.size(14),
|
.size(14),
|
||||||
text_input("", edit.icon.as_str())
|
launcher_icon_editor(edit),
|
||||||
.label(fl!("launcher-icon"))
|
|
||||||
.on_input(Message::LauncherIconChanged)
|
|
||||||
.on_submit(|_| Message::SaveLauncherEdit)
|
|
||||||
.width(Length::Fill)
|
|
||||||
.size(14),
|
|
||||||
]
|
]
|
||||||
.spacing(spacing.space_s)
|
.spacing(spacing.space_s)
|
||||||
.width(Length::Fill);
|
.width(Length::Fill);
|
||||||
|
|
|
||||||
440
cosmic-app-list/src/icon_catalog.rs
Normal file
440
cosmic-app-list/src/icon_catalog.rs
Normal file
|
|
@ -0,0 +1,440 @@
|
||||||
|
use std::{
|
||||||
|
cmp::Ordering,
|
||||||
|
collections::{HashMap, HashSet, VecDeque},
|
||||||
|
fs,
|
||||||
|
path::{Component, Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_THEMES: &[&str] = &["Cosmic", "hicolor", "gnome", "Yaru"];
|
||||||
|
const MAX_THEME_CHAIN: usize = 24;
|
||||||
|
const MAX_SCAN_DEPTH: usize = 5;
|
||||||
|
const MAX_CATALOG_ENTRIES: usize = 2_500;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct IconCatalog {
|
||||||
|
pub theme: String,
|
||||||
|
pub entries: Vec<IconCatalogEntry>,
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct IconCatalogEntry {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||||
|
struct CandidateRank {
|
||||||
|
preferred: u8,
|
||||||
|
category: u8,
|
||||||
|
symbolic: u8,
|
||||||
|
theme_depth: usize,
|
||||||
|
extension: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ord for CandidateRank {
|
||||||
|
fn cmp(&self, other: &Self) -> Ordering {
|
||||||
|
(
|
||||||
|
self.preferred,
|
||||||
|
self.category,
|
||||||
|
self.symbolic,
|
||||||
|
self.theme_depth,
|
||||||
|
self.extension,
|
||||||
|
)
|
||||||
|
.cmp(&(
|
||||||
|
other.preferred,
|
||||||
|
other.category,
|
||||||
|
other.symbolic,
|
||||||
|
other.theme_depth,
|
||||||
|
other.extension,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialOrd for CandidateRank {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||||
|
Some(self.cmp(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct Candidate {
|
||||||
|
name: String,
|
||||||
|
rank: CandidateRank,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_icon_catalog(theme: String, preferred_icon: String) -> IconCatalog {
|
||||||
|
build_icon_catalog(theme, preferred_icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_icon_catalog(theme: String, preferred_icon: String) -> IconCatalog {
|
||||||
|
let theme = if theme.trim().is_empty() {
|
||||||
|
"Cosmic".to_string()
|
||||||
|
} else {
|
||||||
|
theme
|
||||||
|
};
|
||||||
|
let preferred_name = icon_name_from_value(preferred_icon.trim());
|
||||||
|
let theme_chain = theme_chain(&theme);
|
||||||
|
let mut candidates = HashMap::new();
|
||||||
|
|
||||||
|
for (theme_depth, theme_name) in theme_chain.iter().enumerate() {
|
||||||
|
for theme_dir in theme_dirs(theme_name) {
|
||||||
|
scan_icon_tree(
|
||||||
|
&theme_dir,
|
||||||
|
theme_depth,
|
||||||
|
preferred_name.as_deref(),
|
||||||
|
&mut candidates,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pixmap_dir in pixmap_dirs() {
|
||||||
|
scan_icon_tree(
|
||||||
|
&pixmap_dir,
|
||||||
|
theme_chain.len() + 1,
|
||||||
|
preferred_name.as_deref(),
|
||||||
|
&mut candidates,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entries = candidates.into_values().collect::<Vec<_>>();
|
||||||
|
entries.sort_by(|a, b| a.rank.cmp(&b.rank).then_with(|| a.name.cmp(&b.name)));
|
||||||
|
|
||||||
|
let truncated = entries.len() > MAX_CATALOG_ENTRIES;
|
||||||
|
entries.truncate(MAX_CATALOG_ENTRIES);
|
||||||
|
|
||||||
|
IconCatalog {
|
||||||
|
theme,
|
||||||
|
entries: entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|candidate| IconCatalogEntry {
|
||||||
|
name: candidate.name,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
truncated,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme_chain(theme: &str) -> Vec<String> {
|
||||||
|
let mut queue = VecDeque::from([theme.to_string()]);
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut chain = Vec::new();
|
||||||
|
|
||||||
|
while let Some(theme_name) = queue.pop_front() {
|
||||||
|
let key = theme_name.to_ascii_lowercase();
|
||||||
|
if !seen.insert(key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
chain.push(theme_name.clone());
|
||||||
|
if chain.len() >= MAX_THEME_CHAIN {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for parent in read_theme_inherits(&theme_name) {
|
||||||
|
queue.push_back(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for fallback in FALLBACK_THEMES {
|
||||||
|
if chain.len() >= MAX_THEME_CHAIN {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let key = fallback.to_ascii_lowercase();
|
||||||
|
if seen.insert(key) {
|
||||||
|
chain.push((*fallback).to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chain
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_theme_inherits(theme: &str) -> Vec<String> {
|
||||||
|
theme_dirs(theme)
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|dir| fs::read_to_string(dir.join("index.theme")).ok())
|
||||||
|
.map(|contents| parse_inherits(&contents))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_inherits(contents: &str) -> Vec<String> {
|
||||||
|
let mut in_icon_theme = false;
|
||||||
|
|
||||||
|
for raw_line in contents.lines() {
|
||||||
|
let line = raw_line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if line.starts_with('[') && line.ends_with(']') {
|
||||||
|
in_icon_theme = line == "[Icon Theme]";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if in_icon_theme && let Some(value) = line.strip_prefix("Inherits=") {
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_icon_tree(
|
||||||
|
root: &Path,
|
||||||
|
theme_depth: usize,
|
||||||
|
preferred_name: Option<&str>,
|
||||||
|
candidates: &mut HashMap<String, Candidate>,
|
||||||
|
) {
|
||||||
|
let mut stack = vec![(root.to_path_buf(), 0usize)];
|
||||||
|
|
||||||
|
while let Some((dir, depth)) = stack.pop() {
|
||||||
|
if depth > MAX_SCAN_DEPTH {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(entries) = fs::read_dir(&dir) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.filter_map(Result::ok) {
|
||||||
|
let path = entry.path();
|
||||||
|
let Ok(file_type) = entry.file_type() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if file_type.is_dir() {
|
||||||
|
stack.push((path, depth + 1));
|
||||||
|
} else if (file_type.is_file() || file_type.is_symlink())
|
||||||
|
&& let Some(candidate) = candidate_from_path(&path, theme_depth, preferred_name)
|
||||||
|
{
|
||||||
|
insert_candidate(candidates, candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_candidate(candidates: &mut HashMap<String, Candidate>, candidate: Candidate) {
|
||||||
|
let key = candidate.name.to_ascii_lowercase();
|
||||||
|
match candidates.get_mut(&key) {
|
||||||
|
Some(existing) if candidate.rank < existing.rank => {
|
||||||
|
*existing = candidate;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
candidates.insert(key, candidate);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn candidate_from_path(
|
||||||
|
path: &Path,
|
||||||
|
theme_depth: usize,
|
||||||
|
preferred_name: Option<&str>,
|
||||||
|
) -> Option<Candidate> {
|
||||||
|
let extension = extension_rank(path)?;
|
||||||
|
let name = path.file_stem()?.to_str()?.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let symbolic = u8::from(name.ends_with("-symbolic") || path_has_component(path, "symbolic"));
|
||||||
|
let preferred = u8::from(preferred_name != Some(name));
|
||||||
|
|
||||||
|
Some(Candidate {
|
||||||
|
name: name.to_string(),
|
||||||
|
rank: CandidateRank {
|
||||||
|
preferred,
|
||||||
|
category: category_rank(path),
|
||||||
|
symbolic,
|
||||||
|
theme_depth,
|
||||||
|
extension,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extension_rank(path: &Path) -> Option<u8> {
|
||||||
|
match path.extension()?.to_str()?.to_ascii_lowercase().as_str() {
|
||||||
|
"svg" => Some(0),
|
||||||
|
"png" => Some(1),
|
||||||
|
"xpm" => Some(2),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn category_rank(path: &Path) -> u8 {
|
||||||
|
for component in path.components().filter_map(component_str) {
|
||||||
|
match component {
|
||||||
|
"apps" | "applications" => return 0,
|
||||||
|
"categories" => return 1,
|
||||||
|
"places" => return 2,
|
||||||
|
"devices" => return 3,
|
||||||
|
"mimetypes" => return 4,
|
||||||
|
"actions" => return 5,
|
||||||
|
"status" => return 6,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
7
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_has_component(path: &Path, needle: &str) -> bool {
|
||||||
|
path.components()
|
||||||
|
.filter_map(component_str)
|
||||||
|
.any(|component| component == needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component_str(component: Component<'_>) -> Option<&str> {
|
||||||
|
component.as_os_str().to_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn icon_name_from_value(value: &str) -> Option<String> {
|
||||||
|
if value.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = Path::new(value);
|
||||||
|
if value.contains('/') {
|
||||||
|
return path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
}
|
||||||
|
|
||||||
|
path.file_stem()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| Some(value.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme_dirs(theme: &str) -> Vec<PathBuf> {
|
||||||
|
icon_base_dirs()
|
||||||
|
.into_iter()
|
||||||
|
.map(|base| base.join(theme))
|
||||||
|
.filter(|path| path.is_dir())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn icon_base_dirs() -> Vec<PathBuf> {
|
||||||
|
let mut dirs = Vec::new();
|
||||||
|
|
||||||
|
if let Some(home) = std::env::home_dir() {
|
||||||
|
push_existing_unique(&mut dirs, home.join(".icons"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(data_home) = xdg_data_home() {
|
||||||
|
push_existing_unique(&mut dirs, data_home.join("icons"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for data_dir in xdg_data_dirs() {
|
||||||
|
push_existing_unique(&mut dirs, data_dir.join("icons"));
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pixmap_dirs() -> Vec<PathBuf> {
|
||||||
|
let mut dirs = Vec::new();
|
||||||
|
|
||||||
|
if let Some(data_home) = xdg_data_home() {
|
||||||
|
push_existing_unique(&mut dirs, data_home.join("pixmaps"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for data_dir in xdg_data_dirs() {
|
||||||
|
push_existing_unique(&mut dirs, data_dir.join("pixmaps"));
|
||||||
|
}
|
||||||
|
|
||||||
|
push_existing_unique(&mut dirs, PathBuf::from("/usr/share/pixmaps"));
|
||||||
|
dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xdg_data_home() -> Option<PathBuf> {
|
||||||
|
std::env::var_os("XDG_DATA_HOME")
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| std::env::home_dir().map(|home| home.join(".local/share")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xdg_data_dirs() -> Vec<PathBuf> {
|
||||||
|
std::env::var_os("XDG_DATA_DIRS")
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|value| std::env::split_paths(&value).collect())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
vec![
|
||||||
|
PathBuf::from("/usr/local/share"),
|
||||||
|
PathBuf::from("/usr/share"),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_existing_unique(dirs: &mut Vec<PathBuf>, path: PathBuf) {
|
||||||
|
if path.exists() && !dirs.iter().any(|existing| existing == &path) {
|
||||||
|
dirs.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_inherits_only_from_icon_theme_section() {
|
||||||
|
let contents = r#"
|
||||||
|
[Other]
|
||||||
|
Inherits=Wrong
|
||||||
|
|
||||||
|
[Icon Theme]
|
||||||
|
Name=Demo
|
||||||
|
Inherits=Cosmic, hicolor , Adwaita
|
||||||
|
"#;
|
||||||
|
|
||||||
|
assert_eq!(parse_inherits(contents), ["Cosmic", "hicolor", "Adwaita"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_icon_names_from_plain_names_and_paths() {
|
||||||
|
assert_eq!(icon_name_from_value("firefox").as_deref(), Some("firefox"));
|
||||||
|
assert_eq!(
|
||||||
|
icon_name_from_value("/usr/share/icons/hicolor/scalable/apps/firefox.svg").as_deref(),
|
||||||
|
Some("firefox")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_the_best_duplicate_candidate() {
|
||||||
|
let mut candidates = HashMap::new();
|
||||||
|
insert_candidate(
|
||||||
|
&mut candidates,
|
||||||
|
Candidate {
|
||||||
|
name: "demo".to_string(),
|
||||||
|
rank: CandidateRank {
|
||||||
|
preferred: 1,
|
||||||
|
category: 7,
|
||||||
|
symbolic: 1,
|
||||||
|
theme_depth: 4,
|
||||||
|
extension: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
insert_candidate(
|
||||||
|
&mut candidates,
|
||||||
|
Candidate {
|
||||||
|
name: "demo".to_string(),
|
||||||
|
rank: CandidateRank {
|
||||||
|
preferred: 0,
|
||||||
|
category: 0,
|
||||||
|
symbolic: 0,
|
||||||
|
theme_depth: 0,
|
||||||
|
extension: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(candidates["demo"].rank.preferred, 0);
|
||||||
|
assert_eq!(candidates["demo"].rank.category, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-only
|
// SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
|
mod icon_catalog;
|
||||||
mod launcher_edit;
|
mod launcher_edit;
|
||||||
mod localize;
|
mod localize;
|
||||||
mod wayland_handler;
|
mod wayland_handler;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue