feat: add themed launcher icon catalog
This commit is contained in:
parent
8d56d0b500
commit
04acc50e4d
5 changed files with 665 additions and 14 deletions
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue