use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::CursorError;
use crate::alias;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ThemeResolution {
pub theme_name: String,
pub source: PathBuf,
pub chain: Vec<String>,
}
#[derive(Debug)]
struct Record {
name: String,
dirs: Vec<PathBuf>,
inherits: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct CacheKey {
cursor_name: String,
preferred_theme: String,
}
#[derive(Debug)]
pub struct Theme {
themes: Vec<Record>,
by_name: HashMap<String, usize>,
search_paths: Vec<PathBuf>,
cache: RefCell<HashMap<CacheKey, Result<ThemeResolution, CursorError>>>,
}
impl Theme {
pub fn discover() -> Result<Self, CursorError> {
Self::discover_with_paths(&default_search_paths())
}
pub fn discover_with_paths(search_paths: &[PathBuf]) -> Result<Self, CursorError> {
let mut themes: Vec<Record> = Vec::new();
let mut by_name: HashMap<String, usize> = HashMap::new();
for root in search_paths {
let Ok(entries) = std::fs::read_dir(root) else {
continue;
};
let mut dirs: Vec<PathBuf> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect();
dirs.sort();
for theme_dir in dirs {
let has_cursors = theme_dir.join("cursors").exists();
let index_theme = theme_dir.join("index.theme");
let cursor_theme = theme_dir.join("cursor.theme");
if !has_cursors && !index_theme.exists() && !cursor_theme.exists() {
continue;
}
let Some(name) = theme_dir.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.is_empty() {
continue;
}
let index = *by_name.entry(name.to_owned()).or_insert_with(|| {
themes.push(Record {
name: name.to_owned(),
dirs: Vec::new(),
inherits: Vec::new(),
});
themes.len() - 1
});
themes[index].dirs.push(theme_dir.clone());
if themes[index].inherits.is_empty() {
let mut inherits = parse_inherits(&cursor_theme);
if inherits.is_empty() {
inherits = parse_inherits(&index_theme);
}
themes[index].inherits = inherits;
}
}
}
if themes.is_empty() {
return Err(CursorError::NoThemes);
}
Ok(Self {
themes,
by_name,
search_paths: search_paths.to_vec(),
cache: RefCell::new(HashMap::new()),
})
}
#[must_use]
pub fn search_paths(&self) -> &[PathBuf] {
&self.search_paths
}
#[cfg(test)]
pub(crate) fn cache_len(&self) -> usize {
self.cache.borrow().len()
}
#[must_use]
pub fn theme_names(&self) -> Vec<&str> {
self.themes.iter().map(|r| r.name.as_str()).collect()
}
pub fn resolve(
&self,
cursor_name: &str,
preferred_theme: &str,
) -> Result<ThemeResolution, CursorError> {
if cursor_name.is_empty()
|| cursor_name == "."
|| cursor_name == ".."
|| cursor_name.contains('/')
|| cursor_name.contains('\\')
{
return Err(CursorError::InvalidName);
}
let key = CacheKey {
cursor_name: cursor_name.to_owned(),
preferred_theme: preferred_theme.to_owned(),
};
if let Some(hit) = self.cache.borrow().get(&key) {
return hit.clone();
}
let outcome = self.walk(cursor_name, preferred_theme);
self.cache.borrow_mut().insert(key, outcome.clone());
outcome
}
fn walk(
&self,
cursor_name: &str,
preferred_theme: &str,
) -> Result<ThemeResolution, CursorError> {
let mut start = preferred_theme.to_owned();
if start.is_empty() {
start = env_string("XCURSOR_THEME");
}
if start.is_empty() {
"default".clone_into(&mut start);
}
let mut chain: Vec<String> = Vec::new();
let mut visited: HashSet<String> = HashSet::new();
if let Some(found) = self.bfs(&start, cursor_name, &mut chain, &mut visited) {
return Ok(found);
}
if start != "default"
&& !visited.contains("default")
&& let Some(found) = self.bfs("default", cursor_name, &mut chain, &mut visited)
{
return Ok(found);
}
for record in &self.themes {
if chain.iter().any(|seen| seen == &record.name) {
continue;
}
let found = record
.dirs
.iter()
.find_map(|dir| find_in_cursors_dir(&dir.join("cursors"), cursor_name));
chain.push(record.name.clone());
if let Some(source) = found {
return Ok(ThemeResolution {
theme_name: record.name.clone(),
source,
chain,
});
}
}
Err(CursorError::NotFound)
}
fn bfs(
&self,
root: &str,
cursor_name: &str,
chain: &mut Vec<String>,
visited: &mut HashSet<String>,
) -> Option<ThemeResolution> {
let mut queue: std::collections::VecDeque<String> =
std::collections::VecDeque::from([root.to_owned()]);
while let Some(current) = queue.pop_front() {
if !visited.insert(current.clone()) {
continue;
}
chain.push(current.clone());
let Some(&index) = self.by_name.get(¤t) else {
continue;
};
let record = &self.themes[index];
for parent in &record.inherits {
queue.push_back(parent.clone());
}
for dir in &record.dirs {
if let Some(source) = find_in_cursors_dir(&dir.join("cursors"), cursor_name) {
return Some(ThemeResolution {
theme_name: current,
source,
chain: std::mem::take(chain),
});
}
}
}
None
}
}
fn find_in_cursors_dir(cursors_dir: &Path, cursor_name: &str) -> Option<PathBuf> {
let mut found = None;
alias::for_each(cursor_name, |candidate| {
if found.is_some() {
return;
}
let path = cursors_dir.join(candidate);
if path.exists() {
found = Some(path);
}
});
found
}
fn env_string(name: &str) -> String {
std::env::var(name).unwrap_or_default()
}
fn split_path_list(value: &str) -> Vec<PathBuf> {
value
.split(':')
.filter(|part| !part.is_empty())
.map(PathBuf::from)
.collect()
}
#[must_use]
pub fn default_search_paths() -> Vec<PathBuf> {
let explicit = env_string("XCURSOR_PATH");
if !explicit.is_empty() {
return split_path_list(&explicit);
}
let mut out = Vec::new();
let home = env_string("HOME");
if !home.is_empty() {
out.push(PathBuf::from(&home).join(".icons"));
}
let data_home = env_string("XDG_DATA_HOME");
if data_home.is_empty() {
if !home.is_empty() {
out.push(
PathBuf::from(&home)
.join(".local")
.join("share")
.join("icons"),
);
}
} else {
for path in split_path_list(&data_home) {
out.push(path.join("icons"));
}
}
let data_dirs = env_string("XDG_DATA_DIRS");
let dirs = if data_dirs.is_empty() {
split_path_list("/usr/local/share:/usr/share")
} else {
split_path_list(&data_dirs)
};
for path in dirs {
out.push(path.join("icons"));
}
out.push(PathBuf::from("/usr/share/pixmaps"));
out
}
fn parse_inherits(path: &Path) -> Vec<String> {
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
let mut in_icon_theme = false;
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with('[') {
if in_icon_theme {
break;
}
in_icon_theme = line == "[Icon Theme]";
continue;
}
if !in_icon_theme {
continue;
}
let Some(rest) = line.strip_prefix("Inherits") else {
continue;
};
let Some(rest) = rest.trim_start().strip_prefix('=') else {
continue;
};
return rest
.split([',', ';'])
.map(str::trim)
.filter(|parent| !parent.is_empty())
.map(str::to_owned)
.collect();
}
Vec::new()
}