Compare commits

..

No commits in common. "739e266210d471c09af61d529d8f41dccd815b90" and "062ec71b1aa886587bca1e196725fd6dd90cf385" have entirely different histories.

10 changed files with 571 additions and 874 deletions

View file

@ -10,16 +10,17 @@ readme = "README.md"
keywords = ["icons", "gui", "freedesktop"] keywords = ["icons", "gui", "freedesktop"]
[dependencies] [dependencies]
bstr = "1.12.1" dirs = "6.0"
btoi = "0.5.0"
memchr = "2.7.6"
memmap2 = "0.9"
thiserror = "2.0" thiserror = "2.0"
tracing = { version = "0.1.41", default-features = false }
xdg = "3.0" xdg = "3.0"
tracing = "0.1.41"
ini_core = "0.2.0"
memmap2 = "0.9"
[dev-dependencies] [dev-dependencies]
speculoos = "0.13.0" speculoos = "0.13.0"
linicon = "2.3.0"
gtk4 = "0.10"
criterion = "0.7" criterion = "0.7"
[features] [features]

View file

@ -1,8 +1,9 @@
use cosmic_freedesktop_icons::lookup;
use criterion::{ use criterion::{
AxisScale, BenchmarkId, Criterion, PlotConfiguration, criterion_group, criterion_main, AxisScale, BenchmarkId, Criterion, PlotConfiguration, black_box, criterion_group,
criterion_main,
}; };
use std::hint::black_box; use freedesktop_icons::lookup;
use gtk4::{IconLookupFlags, IconTheme, TextDirection};
pub fn bench_lookups(c: &mut Criterion) { pub fn bench_lookups(c: &mut Criterion) {
let mut group = c.benchmark_group("ComparisonsLookups"); let mut group = c.benchmark_group("ComparisonsLookups");
@ -12,11 +13,19 @@ pub fn bench_lookups(c: &mut Criterion) {
let args = [ let args = [
"user-home", // (Best case) An icon that can be found in the current theme "user-home", // (Best case) An icon that can be found in the current theme
"firefox", // An icon that can be found in the hicolor default theme "firefox", // An icon that can be found in the hicolor default theme
"com.valvesoftware.Steam", // An icon that resides in /usr/share/pixmaps "archlinux-logo", // An icon that resides in /usr/share/pixmaps
"not-found", // (Worst case) An icon that does not exist "not-found", // (Worst case) An icon that does not exist
]; ];
for arg in args { for arg in args {
group.bench_with_input(BenchmarkId::new("freedesktop-icons", arg), arg, |b, arg| {
b.iter(|| {
lookup(black_box(arg))
.with_theme(black_box("Adwaita"))
.find()
});
});
group.bench_with_input( group.bench_with_input(
BenchmarkId::new("freedesktop-icons-cache", arg), BenchmarkId::new("freedesktop-icons-cache", arg),
arg, arg,
@ -31,6 +40,33 @@ pub fn bench_lookups(c: &mut Criterion) {
}); });
}, },
); );
group.bench_with_input(BenchmarkId::new("linicon", arg), arg, |b, arg| {
b.iter(|| {
linicon::lookup_icon(black_box(arg))
.from_theme(black_box("Adwaita"))
.with_scale(black_box(1))
.with_size(black_box(24))
.next()
});
});
group.bench_with_input(BenchmarkId::new("gtk", arg), arg, |b, arg| {
gtk4::init().unwrap();
let theme = IconTheme::new();
b.iter(|| {
theme
.lookup_icon(
black_box(arg),
black_box(&[]),
black_box(24),
black_box(1),
black_box(TextDirection::None),
black_box(IconLookupFlags::empty()),
)
.icon_name()
});
});
} }
group.finish(); group.finish();

105
benches/tests.rs Normal file
View file

@ -0,0 +1,105 @@
use freedesktop_icons::lookup;
use gtk4::{IconLookupFlags, IconTheme, TextDirection};
use speculoos::prelude::*;
use std::path::PathBuf;
#[test]
fn gtk_lookup() {
gtk4::init().unwrap();
let theme = IconTheme::new();
let x = theme.lookup_icon(
"firefox",
&[],
24,
1,
TextDirection::None,
IconLookupFlags::empty(),
);
assert!(x.icon_name().is_some())
}
// Linicon sometimes fails with theme that have unknown parents
// This test only ensure we are running the correct function in the benchmarks
// And results are identical.
#[test]
fn linicon() {
// Current theme
let lin_user_home = linicon::lookup_icon("user-home")
.from_theme("Adwaita")
.with_size(24)
.with_scale(1)
.next();
let user_home = lookup("user-home")
.with_theme("Adwaita")
.with_size(24)
.with_scale(1)
.find();
asserting!("Linicon return some icon")
.that(&lin_user_home.unwrap())
.is_ok()
.map(|icon| &icon.path)
.is_equal_to(PathBuf::from(
"/usr/share/icons/Adwaita/24x24/places/user-home.png",
));
asserting!("Our implementation should return the same result as linicon")
.that(&user_home)
.is_some()
.is_equal_to(PathBuf::from(
"/usr/share/icons/Adwaita/24x24/places/user-home.png",
));
// Fallback to hicolor
let lin_firefox = linicon::lookup_icon("firefox")
.from_theme("Adwaita")
.with_size(24)
.with_scale(1)
.next();
let firefox = lookup("firefox")
.with_theme("Adwaita")
.with_size(24)
.with_scale(1)
.find();
asserting!("Linicon return some icon")
.that(&lin_firefox.unwrap())
.is_ok()
.map(|icon| &icon.path)
.is_equal_to(PathBuf::from(
"/usr/share/icons/hicolor/22x22/apps/firefox.png",
));
asserting!("Our implementation should return the same result as linicon")
.that(&firefox)
.is_some()
.is_equal_to(PathBuf::from(
"/usr/share/icons/hicolor/22x22/apps/firefox.png",
));
// pixmaps
let lin_archlinux = linicon::lookup_icon("archlinux-logo")
.from_theme("Adwaita")
.with_size(24)
.with_scale(1)
.next();
let archlinux = lookup("archlinux-logo")
.with_theme("Adwaita")
.with_size(24)
.with_scale(1)
.find();
asserting!("Linicon fails to fallback to pixmaps")
.that(&lin_archlinux)
.is_none();
asserting!("But we succeed")
.that(&archlinux)
.is_some()
.is_equal_to(PathBuf::from("/usr/share/pixmaps/archlinux-logo.png"));
}

View file

@ -1,17 +1,15 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{LazyLock, RwLock}; use std::sync::LazyLock;
use std::sync::Mutex;
use std::time::Instant; use std::time::Instant;
pub(crate) static CACHE: LazyLock<Cache> = LazyLock::new(Cache::default); pub(crate) static CACHE: LazyLock<Cache> = LazyLock::new(Cache::default);
type Theme = Box<str>; type IconMap = BTreeMap<(String, u16, u16), CacheEntry>;
type Icon = Box<str>; type ThemeMap = BTreeMap<String, IconMap>;
type SizedMap = BTreeMap<(u16, u16), CacheEntry>;
type IconMap = BTreeMap<Icon, SizedMap>;
type ThemeMap = BTreeMap<Theme, IconMap>;
#[derive(Default)] #[derive(Default)]
pub(crate) struct Cache(RwLock<ThemeMap>); pub(crate) struct Cache(Mutex<ThemeMap>);
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum CacheEntry { pub enum CacheEntry {
@ -25,7 +23,7 @@ pub enum CacheEntry {
impl Cache { impl Cache {
pub fn clear(&self) { pub fn clear(&self) {
self.0.write().unwrap().clear(); self.0.lock().unwrap().clear();
} }
pub fn insert<P: AsRef<Path>>( pub fn insert<P: AsRef<Path>>(
@ -36,35 +34,39 @@ impl Cache {
icon_name: &str, icon_name: &str,
icon_path: &Option<P>, icon_path: &Option<P>,
) { ) {
let mut inner = self.0.write().unwrap(); let mut theme_map = self.0.lock().unwrap();
let entry = icon_path let entry = icon_path
.as_ref() .as_ref()
.map(|path| CacheEntry::Found(path.as_ref().to_path_buf())) .map(|path| CacheEntry::Found(path.as_ref().to_path_buf()))
.unwrap_or(CacheEntry::NotFound(Instant::now())); .unwrap_or(CacheEntry::NotFound(Instant::now()));
inner match theme_map.get_mut(theme) {
.entry(theme.into()) Some(icon_map) => {
.or_insert_with(IconMap::default) icon_map.insert((icon_name.to_string(), size, scale), entry);
.entry(icon_name.into()) }
.or_insert_with(BTreeMap::default) None => {
.insert((size, scale), entry); let mut icon_map = BTreeMap::new();
icon_map.insert((icon_name.to_string(), size, scale), entry);
theme_map.insert(theme.to_string(), icon_map);
}
}
} }
pub fn get(&self, theme: &str, size: u16, scale: u16, icon_name: &str) -> CacheEntry { pub fn get(&self, theme: &str, size: u16, scale: u16, icon_name: &str) -> CacheEntry {
let inner = self.0.read().unwrap(); let theme_map = self.0.lock().unwrap();
inner theme_map
.get(theme) .get(theme)
.and_then(|icon_map| icon_map.get(icon_name)) .map(|icon_map| icon_map.get(&(icon_name.to_string(), size, scale)))
.and_then(|icon_map| icon_map.get(&(size, scale)).cloned()) .and_then(|path| path.cloned())
.unwrap_or(CacheEntry::Unknown) .unwrap_or(CacheEntry::Unknown)
} }
pub fn reset_none(&self) { pub fn reset_none(&self) {
let mut inner = self.0.write().unwrap(); let mut theme_map = self.0.lock().unwrap();
for (_theme_name, theme) in inner.iter_mut() {
for (_, cached_icons) in theme.iter_mut() { for (_theme_name, theme) in theme_map.iter_mut() {
for (_, cached_icon) in cached_icons.iter_mut() { for (_icon_data, cached_icon) in theme.iter_mut() {
if matches!(cached_icon, CacheEntry::NotFound(_)) { if matches!(cached_icon, CacheEntry::NotFound(_)) {
*cached_icon = CacheEntry::Unknown; *cached_icon = CacheEntry::Unknown;
} }
@ -72,4 +74,3 @@ impl Cache {
} }
} }
} }
}

View file

@ -51,14 +51,11 @@
//! .find(); //! .find();
//! # } //! # }
//! ``` //! ```
use memmap2::Mmap;
use theme::BASE_PATHS; use theme::BASE_PATHS;
use crate::cache::{CACHE, CacheEntry}; use crate::cache::{CACHE, CacheEntry};
use crate::theme::{THEMES, Theme, try_build_icon_path}; use crate::theme::{THEMES, try_build_icon_path};
use std::hash::{Hash, Hasher};
use std::io::BufRead; use std::io::BufRead;
use std::ops::ControlFlow;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Instant; use std::time::Instant;
@ -85,10 +82,8 @@ pub fn list_themes() -> Vec<String> {
.flatten() .flatten()
.map(|path| &path.index) .map(|path| &path.index)
.filter_map(|index| { .filter_map(|index| {
let file = std::fs::File::open(index) let file = std::fs::File::open(index).ok()?;
.and_then(|file| unsafe { Mmap::map(&file) }) let mut reader = std::io::BufReader::new(file);
.ok()?;
let mut reader = std::io::Cursor::new(file.as_ref());
let mut line = String::new(); let mut line = String::new();
while let Ok(read) = reader.read_line(&mut line) { while let Ok(read) = reader.read_line(&mut line) {
@ -110,6 +105,56 @@ pub fn list_themes() -> Vec<String> {
themes themes
} }
/// Return the default GTK theme if set.
///
/// ## Example
/// ```rust, no_run
/// use cosmic_freedesktop_icons::default_theme_gtk;
///
/// let theme = default_theme_gtk();
///
/// assert_eq!(Some("Adwaita"), theme.as_deref());
/// ```
pub fn default_theme_gtk() -> Option<String> {
// Calling gsettings is the simplest way to retrieve the default icon theme without adding
// GTK as a dependency. There seems to be several ways to set the default GTK theme
// including a file in XDG_CONFIG_HOME as well as an env var. Gsettings is the most
// straightforward method.
let gsettings = std::process::Command::new("gsettings")
.args(["get", "org.gnome.desktop.interface", "icon-theme"])
.output()
.ok()?;
// Only return the theme if it's in the cache.
if gsettings.status.success() {
let name = String::from_utf8(gsettings.stdout).ok()?;
let name = name.trim().trim_matches('\'');
THEMES.get(name).and_then(|themes| {
themes.first().and_then(|path| {
let file = std::fs::File::open(&path.index).ok()?;
let mut reader = std::io::BufReader::new(file);
let mut line = String::new();
while let Ok(read) = reader.read_line(&mut line) {
if read == 0 {
break;
}
if let Some(name) = line.strip_prefix("Name=") {
return Some(name.trim().to_owned());
}
line.clear();
}
None
})
})
} else {
None
}
}
/// The lookup builder struct, holding all the lookup query parameters. /// The lookup builder struct, holding all the lookup query parameters.
pub struct LookupBuilder<'a> { pub struct LookupBuilder<'a> {
name: &'a str, name: &'a str,
@ -118,7 +163,6 @@ pub struct LookupBuilder<'a> {
scale: u16, scale: u16,
size: u16, size: u16,
theme: &'a str, theme: &'a str,
extra_paths: &'a [PathBuf],
} }
/// Build an icon lookup for the given icon name. /// Build an icon lookup for the given icon name.
@ -225,14 +269,6 @@ impl<'a> LookupBuilder<'a> {
self self
} }
/// Search additional directories for the icon as flat paths (no theme hierarchy).
/// These paths are searched before the theme chain.
#[inline]
pub fn with_extra_paths<'b: 'a>(mut self, paths: &'b [PathBuf]) -> Self {
self.extra_paths = paths;
self
}
/// Execute the current lookup /// Execute the current lookup
/// if no icon is found in the current theme fallback to /// if no icon is found in the current theme fallback to
/// `/usr/share/icons/hicolor` theme and then to `/usr/share/pixmaps`. /// `/usr/share/icons/hicolor` theme and then to `/usr/share/pixmaps`.
@ -254,7 +290,6 @@ impl<'a> LookupBuilder<'a> {
scale: 1, scale: 1,
size: 24, size: 24,
theme: "hicolor", theme: "hicolor",
extra_paths: &[],
} }
} }
@ -266,91 +301,80 @@ impl<'a> LookupBuilder<'a> {
if self.cache { if self.cache {
match self.cache_lookup(self.theme) { match self.cache_lookup(self.theme) {
CacheEntry::Found(icon) => return Some(icon), CacheEntry::Found(icon) => return Some(icon),
CacheEntry::NotFound(last_check) if last_check.elapsed().as_secs() < 5 => { CacheEntry::NotFound(last_check)
if last_check.duration_since(Instant::now()).as_secs() < 5 =>
{
return None; return None;
} }
_ => (), _ => (),
} }
} }
if !self.extra_paths.is_empty() {
let extensions = if self.force_svg {
[".svg", ".png", ".xpm"]
} else {
[".png", ".svg", ".xpm"]
};
let mut name_buf = String::new();
let result = extensions
.into_iter()
.try_for_each(|ext| {
self.extra_paths.iter().try_for_each(|dir| {
let mut path = dir.clone();
if try_build_icon_path(&mut path, &mut name_buf, self.name, ext) {
return ControlFlow::Break(path);
}
name_buf.clear();
ControlFlow::Continue(())
})
})
.break_value();
if result.is_some() {
return result;
}
}
// Records theme paths that have already been searched.
let searched_themes = &mut Vec::new();
// Record themes whose inherits have been searched.
let search_inherits = &mut Vec::new();
// Then lookup in the given theme // Then lookup in the given theme
THEMES THEMES
.get(self.theme.as_bytes()) .get(self.theme)
.or_else(|| THEMES.get("hicolor".as_bytes())) .or_else(|| THEMES.get("hicolor"))
.and_then(|icon_themes| { .and_then(|icon_themes| {
let icon = icon_themes let icon = icon_themes
.iter() .iter()
// Search the active icon themes .find_map(|theme| {
.find_map(|theme| self.search_theme(searched_themes, theme)) theme.try_get_icon(self.name, self.size, self.scale, self.force_svg)
// Search the inherits of those icon themes.
.or_else(|| {
icon_themes.iter().find_map(|t| {
self.search_theme_inherits(search_inherits, searched_themes, t)
}) })
})
// Search the cosmic icon theme
.or_else(|| self.search_inherited_theme(searched_themes, "Cosmic".as_bytes()))
// Search the hicolor icon theme if it was not previously searched
.or_else(|| self.search_inherited_theme(searched_themes, "hicolor".as_bytes()))
// GNOME applications may rely on the gnome theme
.or_else(|| self.search_inherited_theme(searched_themes, "gnome".as_bytes()))
// Ubuntu applications may require Yaru
.or_else(|| self.search_inherited_theme(searched_themes, "Yaru".as_bytes()))
.or_else(|| { .or_else(|| {
let extensions = if self.force_svg { // Fallback to the parent themes recursively
[".svg", ".png", ".xpm"] let mut parents = icon_themes
} else { .iter()
[".png", ".svg", ".xpm"] .flat_map(|t| {
let Ok(file) = theme::read_ini_theme(&t.index) else {
return Vec::new();
}; };
let mut name_buf = String::new(); let Ok(file) = std::str::from_utf8(file.as_ref()) else {
return Vec::new();
};
extensions t.inherits(file)
.into_iter() .into_iter()
.try_for_each(|ext| { .map(String::from)
BASE_PATHS.iter().try_for_each(|theme_base_dir| { .collect::<Vec<String>>()
let mut path = theme_base_dir.clone(); })
if try_build_icon_path(&mut path, &mut name_buf, self.name, ext) .collect::<Vec<_>>();
parents.dedup();
parents.into_iter().find_map(|parent| {
THEMES.get(&parent).and_then(|parent| {
parent.iter().find_map(|t| {
t.try_get_icon(self.name, self.size, self.scale, self.force_svg)
})
})
})
})
.or_else(|| {
THEMES.get("hicolor").and_then(|icon_themes| {
icon_themes.iter().find_map(|theme| {
theme.try_get_icon(self.name, self.size, self.scale, self.force_svg)
})
})
})
.or_else(|| {
for theme_base_dir in BASE_PATHS.iter() {
if let Some(icon) =
try_build_icon_path(self.name, theme_base_dir, self.force_svg)
{ {
return ControlFlow::Break(path); return Some(icon);
} }
name_buf.clear(); }
ControlFlow::Continue(()) None
}) })
.or_else(|| {
try_build_icon_path(self.name, "/usr/share/pixmaps", self.force_svg)
}) })
.break_value() .or_else(|| {
let p = PathBuf::from(&self.name);
if let (Some(name), Some(parent)) = (p.file_stem(), p.parent()) {
try_build_icon_path(&name.to_string_lossy(), parent, self.force_svg)
} else {
None
}
}); });
if self.cache { if self.cache {
@ -381,190 +405,30 @@ impl<'a> LookupBuilder<'a> {
CACHE.insert(theme, self.size, self.scale, self.name, &icon); CACHE.insert(theme, self.size, self.scale, self.name, &icon);
icon icon
} }
/// Search a theme by its path for a matching icon if not already searched.
fn search_theme(&self, searched_themes: &mut Vec<u64>, theme: &Theme) -> Option<PathBuf> {
// Store hash of the theme.
let theme_hash = {
let mut hasher = std::hash::DefaultHasher::new();
theme.path.0.hash(&mut hasher);
hasher.finish()
};
if let Err(pos) = searched_themes.binary_search(&theme_hash) {
searched_themes.insert(pos, theme_hash);
return theme.try_get_icon(self.name, self.size, self.scale, self.force_svg);
}
None
}
// Search the inherits of a theme if not already searched.
fn search_theme_inherits(
&self,
search_inherits: &mut Vec<u64>,
searched_themes: &mut Vec<u64>,
theme: &Theme,
) -> Option<PathBuf> {
// Store hash of the theme.
let theme_hash = {
let mut hasher = std::hash::DefaultHasher::new();
theme.path.0.hash(&mut hasher);
hasher.finish()
};
if let Err(pos) = search_inherits.binary_search(&theme_hash) {
search_inherits.insert(pos, theme_hash);
let Ok(file) = theme::read_ini_theme(&theme.index) else {
return None;
};
// Search all inherited themes that we haven't already searched
return theme
.inherits(file.as_ref())
.into_iter()
.find_map(|parent| self.search_inherited_theme(searched_themes, parent));
}
None
}
/// Search the inherits of a theme by its name if not already searched.
fn search_inherited_theme(
&self,
searched_themes: &mut Vec<u64>,
theme: &[u8],
) -> Option<PathBuf> {
THEMES
.get(theme)?
.iter()
.find_map(|t| self.search_theme(searched_themes, t))
}
} }
// WARNING: these test are highly dependent on your installed icon-themes. // WARNING: these test are highly dependent on your installed icon-themes.
// If you want to run them, make sure you have 'Papirus' and 'Arc' icon-themes installed. // If you want to run them, make sure you have 'Papirus' and 'Arc' icon-themes installed.
#[cfg(test)] #[cfg(test)]
#[cfg(feature = "local_tests")]
mod test { mod test {
use crate::{CACHE, CacheEntry, lookup}; use crate::{CACHE, CacheEntry, lookup};
use speculoos::prelude::*; use speculoos::prelude::*;
use std::path::PathBuf; use std::path::PathBuf;
#[test] #[test]
fn hicolor_firefox_24_png() { fn simple_lookup() {
let firefox = lookup("firefox").find(); let firefox = lookup("firefox").find();
asserting!("Firefox contains only a 16x16 and 32x32 icon, so 16x16 should be returned")
.that(&firefox)
.is_some()
.is_equal_to(PathBuf::from(
"/usr/share/icons/hicolor/16x16/apps/firefox.png",
));
}
#[test]
fn hicolor_firefox_48_png() {
let firefox = lookup("firefox").with_size(48).find();
asserting!("Firefox has a 48x48 icon, so that should be returned")
.that(&firefox)
.is_some()
.is_equal_to(PathBuf::from(
"/usr/share/icons/hicolor/48x48/apps/firefox.png",
));
}
#[test]
fn hicolor_firefox_svg_fallback_to_png() {
let firefox = lookup("firefox").force_svg().find();
asserting!("Lookup with no parameters should return an existing icon") asserting!("Lookup with no parameters should return an existing icon")
.that(&firefox) .that(&firefox)
.is_some() .is_some()
.is_equal_to(PathBuf::from( .is_equal_to(PathBuf::from(
"/usr/share/icons/hicolor/16x16/apps/firefox.png", "/usr/share/icons/hicolor/22x22/apps/firefox.png",
)); ));
} }
#[test] #[test]
fn cosmic_weather_storm_symbolic() {
let firefox = lookup("weather-storm-symbolic").find();
asserting!("Is the cosmic icon theme installed?")
.that(&firefox)
.is_some()
.is_equal_to(PathBuf::from(
"/usr/share/icons/Cosmic/scalable/status/weather-storm-symbolic.svg",
));
}
#[test]
fn cosmic_weather_storm_symbolic_force_svg() {
let firefox = lookup("weather-storm-symbolic").force_svg().find();
asserting!("Is the cosmic icon theme installed?")
.that(&firefox)
.is_some()
.is_equal_to(PathBuf::from(
"/usr/share/icons/Cosmic/scalable/status/weather-storm-symbolic.svg",
));
}
#[test]
fn flatpak_slack() {
let home = std::env::home_dir().unwrap();
assert_eq!(
lookup("com.slack.Slack").find(),
Some(home.join(
".local/share/flatpak/exports/share/icons/hicolor/scalable/apps/com.slack.Slack.svg"
)),
"Is the Slack flatpak installed locally?"
);
}
#[test]
fn vscode_pixmap() {
assert_eq!(
lookup("vscode").find(),
Some(PathBuf::from("/usr/share/pixmaps/vscode.png")),
"Is VS Code installed locally on the host?"
);
}
#[test]
fn libreoffice_startcenter() {
assert_eq!(
lookup("libreoffice-startcenter").find(),
Some(PathBuf::from(
"/usr/share/icons/hicolor/24x24/apps/libreoffice-startcenter.png"
)),
"Is libreoffice installed locally on the host?"
);
}
#[test]
fn gnome_advanced_network() {
assert_eq!(
lookup("preferences-system-network").find(),
Some(PathBuf::from(
"/usr/share/icons/gnome/24x24/categories/preferences-system-network.png"
)),
"Is the gnome icon theme installed?"
);
}
#[test]
fn ubuntu_additional_drivers() {
assert_eq!(
lookup("jockey").find(),
Some(PathBuf::from("/usr/share/icons/Yaru/24x24/apps/jockey.png")),
"Is the gnome icon theme installed?"
);
}
#[test]
#[cfg(feature = "local_tests")]
fn theme_lookup() { fn theme_lookup() {
let firefox = lookup("firefox").with_theme("Papirus").find(); let firefox = lookup("firefox").with_theme("Papirus").find();
@ -577,7 +441,6 @@ mod test {
} }
#[test] #[test]
#[cfg(feature = "local_tests")]
fn should_fallback_to_parent_theme() { fn should_fallback_to_parent_theme() {
let icon = lookup("video-single-display-symbolic") let icon = lookup("video-single-display-symbolic")
.with_theme("Arc") .with_theme("Arc")
@ -592,7 +455,6 @@ mod test {
} }
#[test] #[test]
#[cfg(feature = "local_tests")]
fn should_fallback_to_pixmaps_utlimately() { fn should_fallback_to_pixmaps_utlimately() {
let archlinux_logo = lookup("archlinux-logo") let archlinux_logo = lookup("archlinux-logo")
.with_size(16) .with_size(16)
@ -606,6 +468,26 @@ mod test {
.is_equal_to(PathBuf::from("/usr/share/pixmaps/archlinux-logo.png")); .is_equal_to(PathBuf::from("/usr/share/pixmaps/archlinux-logo.png"));
} }
#[test]
fn compare_to_linincon_with_theme() {
let lin_wireshark = linicon::lookup_icon("wireshark")
.next()
.unwrap()
.unwrap()
.path;
let wireshark = lookup("wireshark")
.with_size(16)
.with_scale(1)
.with_theme("Papirus")
.find();
asserting!("Given the same input parameter, lookup should output be the same as linincon")
.that(&wireshark)
.is_some()
.is_equal_to(lin_wireshark);
}
#[test] #[test]
fn should_not_attempt_to_lookup_a_not_found_cached_icon() { fn should_not_attempt_to_lookup_a_not_found_cached_icon() {
let not_found = lookup("not-found").with_cache().find(); let not_found = lookup("not-found").with_cache().find();
@ -614,9 +496,8 @@ mod test {
let expected_cache_result = CACHE.get("hicolor", 24, 1, "not-found"); let expected_cache_result = CACHE.get("hicolor", 24, 1, "not-found");
assert!( asserting!("When lookup fails a first time, subsequent attempts should fail from cache")
matches!(expected_cache_result, CacheEntry::NotFound(..)), .that(&expected_cache_result)
"When lookup fails a first time, subsequent attempts should fail from cache" .is_equal_to(CacheEntry::NotFound);
);
} }
} }

View file

@ -10,31 +10,47 @@ pub struct Directory<'a> {
} }
impl Directory<'_> { impl Directory<'_> {
pub fn directory_size_distance(&self, size: i16, scale: i16) -> i16 { pub fn match_size(&self, size: u16, scale: u16) -> bool {
match self.type_ { let scale = scale as i16;
DirectoryType::Fixed => self.size * self.scale - size * scale, let size = size as i16;
DirectoryType::Scalable => { if self.scale != scale {
let scaled_requested_size = size * scale; false
} else {
match self.type_ {
DirectoryType::Fixed => self.size == size,
DirectoryType::Scalable => self.minsize <= size && size <= self.maxsize,
DirectoryType::Threshold => {
self.size - self.threshold <= size && size <= self.size + self.threshold
}
}
}
}
pub fn directory_size_distance(&self, size: u16, scale: u16) -> i16 {
let scaled_size = self.size * self.scale;
let min_scaled_size = self.minsize * self.scale; let min_scaled_size = self.minsize * self.scale;
let max_scaled_size = self.maxsize * self.scale;
let scale = scale as i16;
let size = size as i16;
let scaled_requested_size = size * scale;
match self.type_ {
DirectoryType::Fixed => scaled_size - scaled_requested_size,
DirectoryType::Scalable => {
if scaled_requested_size < min_scaled_size { if scaled_requested_size < min_scaled_size {
min_scaled_size - scaled_requested_size min_scaled_size - scaled_requested_size
} else { } else if scaled_requested_size < max_scaled_size {
let max_scaled_size = self.maxsize * self.scale;
if scaled_requested_size < max_scaled_size {
scaled_requested_size - max_scaled_size scaled_requested_size - max_scaled_size
} else { } else {
0 0
} }
} }
}
DirectoryType::Threshold => { DirectoryType::Threshold => {
let scaled_requested_size = size * scale;
if scaled_requested_size < (self.size - self.threshold) * scale { if scaled_requested_size < (self.size - self.threshold) * scale {
self.minsize * self.scale - scaled_requested_size min_scaled_size - scaled_requested_size
} else if scaled_requested_size > (self.size + self.threshold) * scale { } else if scaled_requested_size > (self.size + self.threshold) * scale {
scaled_requested_size - self.maxsize * self.scale scaled_requested_size - max_scaled_size
} else { } else {
0 0
} }
@ -56,11 +72,11 @@ impl Default for DirectoryType {
} }
} }
impl From<&[u8]> for DirectoryType { impl From<&str> for DirectoryType {
fn from(value: &[u8]) -> Self { fn from(value: &str) -> Self {
match value[0] { match value {
b'F' => DirectoryType::Fixed, "Fixed" => DirectoryType::Fixed,
b'S' => DirectoryType::Scalable, "Scalable" => DirectoryType::Scalable,
_ => DirectoryType::Threshold, _ => DirectoryType::Threshold,
} }
} }

11
src/theme/error.rs Normal file
View file

@ -0,0 +1,11 @@
use std::io;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Error, Debug)]
pub(crate) enum ThemeError {
#[error("No 'index.theme' file for {0}")]
ThemeIndexNotFound(PathBuf),
#[error("IoError: {0}")]
IoError(#[from] io::Error),
}

View file

@ -1,19 +1,19 @@
use crate::theme::directories::DirectoryType; use crate::theme::error::ThemeError;
use crate::theme::paths::ThemePath; use crate::theme::paths::ThemePath;
use memmap2::Mmap; use memmap2::Mmap;
pub(crate) use paths::BASE_PATHS; pub(crate) use paths::BASE_PATHS;
use std::cmp::Ordering;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::ops::ControlFlow;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::LazyLock; use std::sync::LazyLock;
mod directories; mod directories;
pub mod error;
mod parse; mod parse;
mod paths; mod paths;
pub static THEMES: LazyLock<BTreeMap<Vec<u8>, Vec<Theme>>> = LazyLock::new(get_all_themes); type Result<T> = std::result::Result<T, ThemeError>;
pub static THEMES: LazyLock<BTreeMap<String, Vec<Theme>>> = LazyLock::new(get_all_themes);
#[inline] #[inline]
pub fn read_ini_theme(path: &Path) -> std::io::Result<Mmap> { pub fn read_ini_theme(path: &Path) -> std::io::Result<Mmap> {
@ -33,99 +33,76 @@ impl Theme {
name: &str, name: &str,
size: u16, size: u16,
scale: u16, scale: u16,
prefer_svg: bool, force_svg: bool,
) -> Option<PathBuf> { ) -> Option<PathBuf> {
let file = read_ini_theme(&self.index).ok()?; let file = read_ini_theme(&self.index).ok()?;
self.try_get_icon_closest_size(file.as_ref(), name, size, scale, prefer_svg) let file = std::str::from_utf8(file.as_ref()).ok()?;
self.try_get_icon_exact_size(file, name, size, scale, force_svg)
.or_else(|| self.try_get_icon_closest_size(file, name, size, scale, force_svg))
}
#[inline]
fn try_get_icon_exact_size(
&self,
file: &str,
name: &str,
size: u16,
scale: u16,
force_svg: bool,
) -> Option<PathBuf> {
self.match_size(file, size, scale)
.find_map(|path| try_build_icon_path(name, path, force_svg))
}
#[inline]
fn match_size<'a>(
&'a self,
file: &'a str,
size: u16,
scale: u16,
) -> impl Iterator<Item = PathBuf> + 'a {
let dirs = self.get_all_directories(file);
dirs.filter(move |directory| directory.match_size(size, scale))
.map(|dir| dir.name)
.map(|dir| self.path().join(dir))
} }
#[inline] #[inline]
fn try_get_icon_closest_size( fn try_get_icon_closest_size(
&self, &self,
file: &[u8], file: &str,
name: &str, name: &str,
size: u16, size: u16,
scale: u16, scale: u16,
prefer_svg: bool, force_svg: bool,
) -> Option<PathBuf> { ) -> Option<PathBuf> {
self.try_fold_icon_path( self.closest_match_size(file, size, scale)
self.closest_match_size(file, size, scale, prefer_svg),
name,
prefer_svg,
)
}
fn try_fold_icon_path<'a>(
&self,
dir_names: Vec<(&'a str, i16, bool)>,
name: &str,
prefer_svg: bool,
) -> Option<PathBuf> {
let extensions = if prefer_svg {
[".svg", ".png", ".xpm"]
} else {
[".png", ".svg", ".xpm"]
};
extensions.into_iter().find_map(|ext| {
dir_names
.iter() .iter()
.try_fold( .find_map(|path| try_build_icon_path(name, path, force_svg))
(self.path().clone(), String::new()), }
move |(mut path, mut name_buf), (dir_name, _, _)| {
path.push(dir_name); fn closest_match_size(&self, file: &str, size: u16, scale: u16) -> Vec<PathBuf> {
if try_build_icon_path(&mut path, &mut name_buf, name, ext) { let dirs = self.get_all_directories(file);
ControlFlow::Break(path)
let mut dirs: Vec<_> = dirs
.filter_map(|directory| {
let distance = directory.directory_size_distance(size, scale);
if distance < i16::MAX {
Some((directory, distance.abs()))
} else { } else {
name_buf.clear(); None
let components = dir_name
.as_bytes()
.iter()
.fold(2, |n, c| n + (*c == b'/') as u32)
as usize;
for _ in 0..components {
path.pop();
} }
ControlFlow::Continue((path, name_buf))
}
},
)
.break_value()
}) })
} .collect();
fn closest_match_size<'a>( dirs.sort_by(|(_, a), (_, b)| a.cmp(b));
&'a self,
file: &'a [u8],
size: u16,
scale: u16,
prefer_svg: bool,
) -> Vec<(&'a str, i16, bool)> {
let mut unsorted = self.get_all_directories(file).fold(
Vec::<(&'a str, i16, bool)>::new(),
|mut unsorted, directory| {
let is_scalable = matches!(directory.type_, DirectoryType::Scalable);
let distance = directory.directory_size_distance(size as i16, scale as i16);
unsorted.push((directory.name, distance.abs(), is_scalable));
unsorted
},
);
unsorted.sort_by(|a, b| { dirs.iter()
let ordering = if prefer_svg { .map(|(dir, _)| dir)
b.2.cmp(&a.2) .map(|dir| dir.name)
} else { .map(|dir| self.path().join(dir))
a.2.cmp(&b.2) .collect()
};
match ordering {
Ordering::Equal => a.1.cmp(&b.1),
_ => ordering,
}
});
unsorted
} }
fn path(&self) -> &PathBuf { fn path(&self) -> &PathBuf {
@ -133,28 +110,45 @@ impl Theme {
} }
} }
pub(super) fn try_build_icon_path<'a>( pub(super) fn try_build_icon_path<P: AsRef<Path>>(
path: &'a mut PathBuf,
name_buf: &'a mut String,
name: &str, name: &str,
extension: &'static str, path: P,
) -> bool { force_svg: bool,
name_buf.push_str(name); ) -> Option<PathBuf> {
path.push(name); if force_svg {
try_build_ext(path, name_buf, name, extension) try_build_svg(name, path.as_ref())
.or_else(|| try_build_png(name, path.as_ref()))
.or_else(|| try_build_xmp(name, path.as_ref()))
} else {
try_build_png(name, path.as_ref())
.or_else(|| try_build_svg(name, path.as_ref()))
.or_else(|| try_build_xmp(name, path.as_ref()))
}
} }
#[inline] fn try_build_svg<P: AsRef<Path>>(name: &str, path: P) -> Option<PathBuf> {
fn try_build_ext(path: &mut PathBuf, name_buf: &mut String, name: &str, ext: &'static str) -> bool { let path = path.as_ref();
name_buf.truncate(name.len()); let svg = path.join(format!("{name}.svg"));
name_buf.push_str(ext);
path.set_file_name(&name_buf); if svg.exists() { Some(svg) } else { None }
path.exists() }
fn try_build_png<P: AsRef<Path>>(name: &str, path: P) -> Option<PathBuf> {
let path = path.as_ref();
let png = path.join(format!("{name}.png"));
if png.exists() { Some(png) } else { None }
}
fn try_build_xmp<P: AsRef<Path>>(name: &str, path: P) -> Option<PathBuf> {
let path = path.as_ref();
let xmp = path.join(format!("{name}.xmp"));
if xmp.exists() { Some(xmp) } else { None }
} }
// Iter through the base paths and get all theme directories // Iter through the base paths and get all theme directories
pub(super) fn get_all_themes() -> BTreeMap<Vec<u8>, Vec<Theme>> { pub(super) fn get_all_themes() -> BTreeMap<String, Vec<Theme>> {
let mut icon_themes = BTreeMap::<Vec<u8>, Vec<_>>::new(); let mut icon_themes = BTreeMap::<_, Vec<_>>::new();
let mut found_indices = BTreeMap::new(); let mut found_indices = BTreeMap::new();
let mut to_revisit = Vec::new(); let mut to_revisit = Vec::new();
@ -174,10 +168,8 @@ pub(super) fn get_all_themes() -> BTreeMap<Vec<u8>, Vec<Theme>> {
if fallback_index.is_none() { if fallback_index.is_none() {
found_indices.insert(name.clone(), theme.index.clone()); found_indices.insert(name.clone(), theme.index.clone());
} }
icon_themes let name = name.to_string_lossy().to_string();
.entry(name.as_bytes().to_owned()) icon_themes.entry(name).or_default().push(theme);
.or_default()
.push(theme);
} else if entry.path().is_dir() { } else if entry.path().is_dir() {
to_revisit.push(entry); to_revisit.push(entry);
} }
@ -188,10 +180,8 @@ pub(super) fn get_all_themes() -> BTreeMap<Vec<u8>, Vec<Theme>> {
let name = entry.file_name(); let name = entry.file_name();
let fallback_index = found_indices.get(&name); let fallback_index = found_indices.get(&name);
if let Some(theme) = Theme::from_path(entry.path(), fallback_index) { if let Some(theme) = Theme::from_path(entry.path(), fallback_index) {
icon_themes let name = name.to_string_lossy().to_string();
.entry(name.as_bytes().to_owned()) icon_themes.entry(name).or_default().push(theme);
.or_default()
.push(theme);
} }
} }
@ -200,26 +190,24 @@ pub(super) fn get_all_themes() -> BTreeMap<Vec<u8>, Vec<Theme>> {
impl Theme { impl Theme {
pub(crate) fn from_path<P: AsRef<Path>>(path: P, index: Option<&PathBuf>) -> Option<Self> { pub(crate) fn from_path<P: AsRef<Path>>(path: P, index: Option<&PathBuf>) -> Option<Self> {
let mut path = path.as_ref().to_path_buf(); let path = path.as_ref();
let is_dir = path.is_dir();
path.push("index.theme");
let local_index_exists = path.exists();
let has_index = local_index_exists || index.is_some();
if !has_index || !is_dir { let has_index = path.join("index.theme").exists() || index.is_some();
if !has_index || !path.is_dir() {
return None; return None;
} }
index let path = ThemePath(path.into());
.cloned()
.or_else(|| local_index_exists.then_some(path.clone())) match (index, path.index()) {
.map(|index| Theme { (Some(index), _) => Some(Theme {
path: ThemePath({ path,
path.pop(); index: index.clone(),
path
}), }),
index, (None, Ok(index)) => Some(Theme { path, index }),
}) _ => None,
}
} }
} }
@ -231,61 +219,40 @@ mod test {
#[test] #[test]
fn get_one_icon() { fn get_one_icon() {
let themes = THEMES.get(&b"Adwaita"[..]).unwrap(); let themes = THEMES.get("Adwaita").unwrap();
println!( println!(
"{:?}", "{:?}",
themes.iter().find_map(|t| { themes.iter().find_map(|t| {
let file = super::read_ini_theme(&t.index).ok()?; let file = super::read_ini_theme(&t.index).ok()?;
t.try_get_icon_closest_size(file.as_ref(), "edit-delete-symbolic", 24, 1, false) let file = std::str::from_utf8(file.as_ref()).ok()?;
t.try_get_icon_exact_size(file, "edit-delete-symbolic", 24, 1, false)
}) })
); );
} }
#[test] #[test]
fn should_get_png_first() { fn should_get_png_first() {
let themes = THEMES.get(&b"hicolor"[..]).unwrap(); let themes = THEMES.get("hicolor").unwrap();
let icon = themes.iter().find_map(|t| { let icon = themes.iter().find_map(|t| {
let file = super::read_ini_theme(&t.index).ok()?; let file = super::read_ini_theme(&t.index).ok()?;
t.try_get_icon_closest_size(file.as_ref(), "blueman", 22, 1, false) let file = std::str::from_utf8(file.as_ref()).ok()?;
t.try_get_icon_exact_size(file, "blueman", 24, 1, true)
}); });
assert_that!(icon).is_some().is_equal_to(PathBuf::from( assert_that!(icon).is_some().is_equal_to(PathBuf::from(
"/usr/share/icons/hicolor/22x22/apps/blueman.png", "/usr/share/icons/hicolor/22x22/apps/blueman.png",
)); ));
} }
#[test]
fn should_get_png_first_92() {
let themes = THEMES.get(&b"hicolor"[..]).unwrap();
let icon = themes.iter().find_map(|t| {
let file = super::read_ini_theme(&t.index).ok()?;
t.try_get_icon_closest_size(file.as_ref(), "blueman", 92, 1, false)
});
assert_that!(icon).is_some().is_equal_to(PathBuf::from(
"/usr/share/icons/hicolor/96x96/apps/blueman.png",
));
}
#[test] #[test]
fn should_get_svg_first() { fn should_get_svg_first() {
let themes = THEMES.get(&b"hicolor"[..]).unwrap(); let themes = THEMES.get("hicolor").unwrap();
let icon = themes.iter().find_map(|t| { let icon = themes.iter().find_map(|t| {
let file = super::read_ini_theme(&t.index).ok()?; let file = super::read_ini_theme(&t.index).ok()?;
t.try_get_icon_closest_size(file.as_ref(), "blueman", 24, 1, true) let file = std::str::from_utf8(file.as_ref()).ok()?;
t.try_get_icon_exact_size(file, "blueman", 24, 1, false)
}); });
assert_that!(icon).is_some().is_equal_to(PathBuf::from( assert_that!(icon).is_some().is_equal_to(PathBuf::from(
"/usr/share/icons/hicolor/scalable/apps/blueman.svg", "/usr/share/icons/hicolor/22x22/apps/blueman.png",
));
}
#[test]
fn should_get_svg_first_96() {
let themes = THEMES.get(&b"hicolor"[..]).unwrap();
let icon = themes.iter().find_map(|t| {
let file = super::read_ini_theme(&t.index).ok()?;
t.try_get_icon_closest_size(file.as_ref(), "blueman", 96, 1, true)
});
assert_that!(icon).is_some().is_equal_to(PathBuf::from(
"/usr/share/icons/hicolor/scalable/apps/blueman.svg",
)); ));
} }
} }

View file

@ -1,48 +1,76 @@
use crate::theme::Theme; use crate::theme::Theme;
use crate::theme::directories::{Directory, DirectoryType}; use crate::theme::directories::{Directory, DirectoryType};
use bstr::BStr;
fn icon_theme_section(file: &str) -> impl Iterator<Item = (&str, &str)> + '_ {
ini_core::Parser::new(file)
.skip_while(|item| *item != ini_core::Item::Section("Icon Theme"))
.take_while(|item| match item {
ini_core::Item::Section(value) => *value == "Icon Theme",
_ => true,
})
.filter_map(|item| {
if let ini_core::Item::Property(key, value) = item {
Some((key, value?))
} else {
None
}
})
}
#[derive(Debug)]
enum DirectorySection<'a> {
Property(&'a str, &'a str),
EndSection,
Section(&'a str),
}
fn sections(file: &str) -> impl Iterator<Item = DirectorySection<'_>> {
ini_core::Parser::new(file).filter_map(move |item| match item {
ini_core::Item::Property(key, Some(value)) => Some(DirectorySection::Property(key, value)),
ini_core::Item::Section(section) => Some(DirectorySection::Section(section)),
ini_core::Item::SectionEnd => Some(DirectorySection::EndSection),
_ => None,
})
}
impl Theme { impl Theme {
pub(super) fn get_all_directories<'a>( pub(super) fn get_all_directories<'a>(
&'a self, &'a self,
file: &'a [u8], file: &'a str,
) -> impl Iterator<Item = Directory<'a>> + 'a { ) -> impl Iterator<Item = Directory<'a>> + 'a {
let mut iterator = sections(file); let mut iterator = sections(file);
std::iter::from_fn(move || { std::iter::from_fn(move || {
let mut is_icon_theme = false;
let mut name = ""; let mut name = "";
let mut size = None; let mut size = None;
let mut max_size = None; let mut max_size = None;
let mut min_size = None; let mut min_size = None;
let mut threshold = None; let mut threshold = None;
let mut scale = None; let mut scale = None;
// let mut context = None;
let mut dtype = DirectoryType::default(); let mut dtype = DirectoryType::default();
#[allow(clippy::while_let_on_iterator)] #[allow(clippy::while_let_on_iterator)]
while let Some(event) = iterator.next() { while let Some(event) = iterator.next() {
match event { match event {
DirectorySection::Property(key, value) => { DirectorySection::Property(key, value) => {
if is_icon_theme { if name.is_empty() || name == "Icon Theme" {
continue; continue;
} }
match key { match key {
b"Size" => size = btoi::btoi(value).ok(), "Size" => size = str::parse(value).ok(),
b"Scale" => scale = btoi::btoi(value).ok(), "Scale" => scale = str::parse(value).ok(),
b"Type" => dtype = DirectoryType::from(value), // "Context" => context = Some(value),
b"MaxSize" => max_size = btoi::btoi(value).ok(), "Type" => dtype = DirectoryType::from(value),
b"MinSize" => min_size = btoi::btoi(value).ok(), "MaxSize" => max_size = str::parse(value).ok(),
b"Threshold" => threshold = btoi::btoi(value).ok(), "MinSize" => min_size = str::parse(value).ok(),
"Threshold" => threshold = str::parse(value).ok(),
_ => (), _ => (),
} }
} }
DirectorySection::Section(new_name) => { DirectorySection::Section(new_name) => {
let Ok(new_name) = std::str::from_utf8(new_name) else {
return None;
};
is_icon_theme = new_name == "Icon Theme";
name = new_name; name = new_name;
size = None; size = None;
max_size = None; max_size = None;
@ -53,7 +81,7 @@ impl Theme {
} }
DirectorySection::EndSection => { DirectorySection::EndSection => {
if is_icon_theme { if name.is_empty() || name == "Icon Theme" {
continue; continue;
} }
@ -63,6 +91,7 @@ impl Theme {
name, name,
size, size,
scale: scale.unwrap_or(1), scale: scale.unwrap_or(1),
// context,
type_: dtype, type_: dtype,
maxsize: max_size.unwrap_or(size), maxsize: max_size.unwrap_or(size),
minsize: min_size.unwrap_or(size), minsize: min_size.unwrap_or(size),
@ -76,391 +105,29 @@ impl Theme {
}) })
} }
pub fn inherits<'a>(&self, file: &'a [u8]) -> impl Iterator<Item = &'a [u8]> { pub fn inherits<'a>(&self, file: &'a str) -> Vec<&'a str> {
icon_theme_section(file) icon_theme_section(file)
.find(|&(key, _)| key == b"Inherits") .find(|&(key, _)| key == "Inherits")
.into_iter() .map(|(_, parents)| {
.flat_map(|(_, parents)| { parents
BStr::new(parents) .split(',')
.split(|&char| char == b',')
// Filtering out 'hicolor' since we are going to fallback there anyway // Filtering out 'hicolor' since we are going to fallback there anyway
.filter(|parent| parent != &b"hicolor") .filter(|parent| parent != &"hicolor")
.collect()
}) })
.unwrap_or_default()
} }
} }
#[derive(Debug)]
enum DirectorySection<'a> {
Property(&'a [u8], &'a [u8]),
EndSection,
Section(&'a [u8]),
}
fn sections(file: &[u8]) -> impl Iterator<Item = DirectorySection<'_>> {
let mut finished = false;
let mut table_found = false;
let mut section: &[u8] = b"";
let mut prev = 0;
let mut line_indices = memchr::memchr_iter(b'\n', file);
std::iter::from_fn(move || {
if finished {
return None;
}
if !section.is_empty() {
let new_section = section;
section = b"";
return Some(DirectorySection::Section(new_section));
}
loop {
let line_pos = match line_indices.next() {
Some(pos) => pos,
None => {
let value = if !finished {
Some(DirectorySection::EndSection)
} else {
None
};
finished = true;
return value;
}
};
let line = BStr::new(unsafe {
// Indices from memchr are valid.
file.get_unchecked(prev..line_pos)
})
.trim_ascii();
prev = line_pos + 1;
if line.is_empty() {
continue;
}
if line[0] == b'[' {
section = &line[1..line.len() - 1];
if table_found {
return Some(DirectorySection::EndSection);
} else {
table_found = true;
return Some(DirectorySection::Section(section));
}
}
if let Some((key, value)) = memchr::memchr(b'=', line).map(|pos| unsafe {
// Position was already validated by memchr.
line.split_at_unchecked(pos)
}) {
return Some(DirectorySection::Property(key, &value[1..]));
}
}
})
}
fn icon_theme_section(file: &[u8]) -> impl Iterator<Item = (&[u8], &[u8])> + '_ {
let mut found_table = false;
let mut prev = 0;
let mut line_indices = memchr::memchr_iter(b'\n', file);
std::iter::from_fn(move || {
loop {
let line_pos = line_indices.next()?;
let line = BStr::new(unsafe {
// Indices from memchr are valid.
file.get_unchecked(prev..line_pos)
})
.trim_ascii();
prev = line_pos + 1;
if line.is_empty() {
continue;
}
if line[0] == b'[' {
if found_table {
return None;
} else {
let section = &line[1..line.len() - 1];
found_table = true;
if section != b"Icon Theme" {
return None;
}
}
}
if let Some((key, value)) = memchr::memchr(b'=', line).map(|pos| unsafe {
// Position was already validated by memchr.
line.split_at_unchecked(pos)
}) {
return Some((key, &value[1..]));
}
}
})
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
const ADWAITA_INDEX: &str = "[Icon Theme] use crate::THEMES;
Name=Adwaita\u{0020} use speculoos::prelude::*;
Comment=The Only One
Example=folder
Inherits=hicolor
# KDE Specific Stuff
DisplayDepth=32
# Directory list
Directories=16x16/actions,16x16/apps,16x16/categories,16x16/devices,16x16/emblems,16x16/emotes,16x16/legacy,16x16/mimetypes,16x16/places,16x16/status,16x16/ui,scalable/devices,scalable/mimetypes,scalable/places,scalable/status,scalable/actions,scalable/apps,scalable/categories,scalable/emblems,scalable/emotes,scalable/legacy,scalable/ui,symbolic-up-to-32/status,symbolic/actions,symbolic/apps,symbolic/categories,symbolic/devices,symbolic/emblems,symbolic/emotes,symbolic/mimetypes,symbolic/places,symbolic/status,symbolic/legacy,symbolic/ui,
[16x16/actions]
Context=Actions
Size=16
Type=Fixed
[16x16/apps]
Context=Applications
Size=16
Type=Fixed
[16x16/categories]
Context=Categories
Size=16
Type=Fixed
[16x16/devices]
Context=Devices
Size=16
Type=Fixed
[16x16/emblems]
Context=Emblems
Size=16
Type=Fixed
[16x16/emotes]
Context=Emotes
Size=16
Type=Fixed
[16x16/legacy]
Context=Legacy
Size=16
Type=Fixed
[16x16/mimetypes]
Context=MimeTypes
Size=16
Type=Fixed
[16x16/places]
Context=Places
Size=16
Type=Fixed
[16x16/status]
Context=Status
Size=16
Type=Fixed
[16x16/ui]
Context=UI
Size=16
Type=Fixed
[scalable/devices]
Context=Devices
Size=128
MinSize=8
MaxSize=512
Type=Scalable
[scalable/mimetypes]
Context=MimeTypes
Size=128
MinSize=8
MaxSize=512
Type=Scalable
[scalable/places]
Context=Places
Size=128
MinSize=8
MaxSize=512
Type=Scalable
[scalable/status]
Context=Status
Size=128
MinSize=8
MaxSize=512
Type=Scalable
[scalable/actions]
Context=Actions
Size=128
MinSize=8
MaxSize=512
Type=Scalable
[scalable/apps]
Context=Applications
Size=128
MinSize=8
MaxSize=512
Type=Scalable
[scalable/categories]
Context=Categories
Size=128
MinSize=8
MaxSize=512
Type=Scalable
[scalable/emblems]
Context=Emblems
Size=128
MinSize=8
MaxSize=512
Type=Scalable
[scalable/emotes]
Context=Emotes
Size=128
MinSize=8
MaxSize=512
Type=Scalable
[scalable/legacy]
Context=Legacy
Size=128
MinSize=8
MaxSize=512
Type=Scalable
[scalable/ui]
Context=UI
Size=128
MinSize=8
MaxSize=512
Type=Scalable
[symbolic-up-to-32/status]
Context=Status
Size=16
MinSize=16
MaxSize=32
Type=Scalable
[symbolic/actions]
Context=Actions
Size=16
MinSize=8
MaxSize=512
Type=Scalable
[symbolic/apps]
Context=Applications
Size=16
MinSize=8
MaxSize=512
Type=Scalable
[symbolic/categories]
Context=Categories
Size=16
MinSize=8
MaxSize=512
Type=Scalable
[symbolic/devices]
Context=Devices
Size=16
MinSize=8
MaxSize=512
Type=Scalable
[symbolic/emblems]
Context=Emblems
Size=16
MinSize=8
MaxSize=512
Type=Scalable
[symbolic/emotes]
Context=Emotes
Size=16
MinSize=8
MaxSize=512
Type=Scalable
[symbolic/mimetypes]
Context=MimeTypes
Size=16
MinSize=8
MaxSize=512
Type=Scalable
[symbolic/places]
Context=Places
Size=16
MinSize=8
MaxSize=512
Type=Scalable
[symbolic/status]
Context=Status
Size=16
MinSize=8
MaxSize=512
Type=Scalable
[symbolic/legacy]
Context=Legacy
Size=16
MinSize=8
MaxSize=512
Type=Scalable
[symbolic/ui]
Context=UI
Size=16
MinSize=8
MaxSize=512
Type=Scalable";
#[test]
fn icon_theme_section() {
let mut iterator = super::icon_theme_section(ADWAITA_INDEX.as_bytes());
let (key, value) = iterator.next().unwrap();
assert_eq!(key, b"Name");
assert_eq!(value, b"Adwaita");
let (key, value) = iterator.next().unwrap();
assert_eq!(key, b"Comment");
assert_eq!(value, b"The Only One");
let (key, value) = iterator.next().unwrap();
assert_eq!(key, b"Example");
assert_eq!(value, b"folder");
let (key, value) = iterator.next().unwrap();
assert_eq!(key, b"Inherits");
assert_eq!(value, b"hicolor");
let (key, value) = iterator.next().unwrap();
assert_eq!(key, b"DisplayDepth");
assert_eq!(value, b"32");
let (key, value) = iterator.next().unwrap();
assert_eq!(key, b"Directories");
assert_eq!(value, b"16x16/actions,16x16/apps,16x16/categories,16x16/devices,16x16/emblems,16x16/emotes,16x16/legacy,16x16/mimetypes,16x16/places,16x16/status,16x16/ui,scalable/devices,scalable/mimetypes,scalable/places,scalable/status,scalable/actions,scalable/apps,scalable/categories,scalable/emblems,scalable/emotes,scalable/legacy,scalable/ui,symbolic-up-to-32/status,symbolic/actions,symbolic/apps,symbolic/categories,symbolic/devices,symbolic/emblems,symbolic/emotes,symbolic/mimetypes,symbolic/places,symbolic/status,symbolic/legacy,symbolic/ui,");
assert_eq!(iterator.next(), None);
}
#[test] #[test]
#[cfg(feature = "local_tests")] #[cfg(feature = "local_tests")]
fn should_get_theme_parents() { fn should_get_theme_parents() {
use speculoos::prelude::*; for theme in THEMES.get("Arc").unwrap() {
for theme in crate::THEMES.get("Arc").unwrap() {
let file = crate::theme::read_ini_theme(&theme.index).ok().unwrap(); let file = crate::theme::read_ini_theme(&theme.index).ok().unwrap();
let file = std::str::from_utf8(file.as_ref()).ok().unwrap(); let file = std::str::from_utf8(file.as_ref()).ok().unwrap();
let parents = theme.inherits(file); let parents = theme.inherits(file);

View file

@ -1,38 +1,50 @@
use dirs::home_dir;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::LazyLock; use std::sync::LazyLock;
use xdg::BaseDirectories; use xdg::BaseDirectories;
use crate::theme;
use crate::theme::error::ThemeError;
pub(crate) static BASE_PATHS: LazyLock<Vec<PathBuf>> = LazyLock::new(icon_theme_base_paths); pub(crate) static BASE_PATHS: LazyLock<Vec<PathBuf>> = LazyLock::new(icon_theme_base_paths);
/// Look in $HOME/.icons (for backwards compatibility), in $XDG_DATA_DIRS/icons, in $XDG_DATA_DIRS/pixmaps and in /usr/share/pixmaps (in that order). /// Look in $HOME/.icons (for backwards compatibility), in $XDG_DATA_DIRS/icons, in $XDG_DATA_DIRS/pixmaps and in /usr/share/pixmaps (in that order).
/// Paths that are not found are filtered out. /// Paths that are not found are filtered out.
fn icon_theme_base_paths() -> Vec<PathBuf> { fn icon_theme_base_paths() -> Vec<PathBuf> {
let base_dirs = BaseDirectories::new(); let base_dirs = BaseDirectories::new();
let mut data_dirs: Vec<_> = base_dirs
let data_dirs = base_dirs
.get_data_dirs() .get_data_dirs()
.into_iter() .into_iter()
.flat_map(|p| [p.join("icons"), p.join("pixmaps")]); .flat_map(|p| [p.join("icons"), p.join("pixmaps")])
.collect();
let data_home_dirs = base_dirs if let Some(data_home) = base_dirs.get_data_home() {
.get_data_home() data_dirs.push(data_home.join("icons"));
.into_iter() data_dirs.push(data_home.join("pixmaps"));
.flat_map(|data_home| [data_home.join("icons"), data_home.join("pixmaps")].into_iter()); }
let home_dir = std::env::home_dir() match home_dir().map(|home| home.join(".icons")) {
.into_iter() Some(home_icon_dir) => data_dirs.push(home_icon_dir),
.map(|home| home.join(".icons")); None => tracing::warn!("No $HOME directory found"),
}
data_dirs data_dirs.into_iter().filter(|p| p.exists()).collect()
.chain(data_home_dirs)
.chain(home_dir)
.filter(|p| p.exists())
.collect()
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct ThemePath(pub PathBuf); pub struct ThemePath(pub PathBuf);
impl ThemePath {
pub(super) fn index(&self) -> theme::Result<PathBuf> {
let index = self.0.join("index.theme");
if !index.exists() {
return Err(ThemeError::ThemeIndexNotFound(index));
}
Ok(index)
}
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::theme::paths::icon_theme_base_paths; use crate::theme::paths::icon_theme_base_paths;
@ -42,7 +54,7 @@ mod test {
#[test] #[test]
fn should_get_all_themes() { fn should_get_all_themes() {
let themes = get_all_themes(); let themes = get_all_themes();
assert_that!(themes.get(&b"hicolor"[..])).is_some(); assert_that!(themes.get("hicolor")).is_some();
} }
#[test] #[test]