diff --git a/src/cli.rs b/src/cli.rs index bbb8055..7864554 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,57 +1,54 @@ //! Module for [`clap`] code. //! -//! This contains the [`Args`] struct, which has the [`Parser`] -//! trait, and the [`ParsedArgs`] struct, which is just plain old data. +//! This contains the [`Args`] struct, which has the [`Parser`] trait, +//! and the [`ParsedArgs`] struct, which is just plain old data. -use crate::fs_utils::find_extensions_icase; +use crate::{ + fs_utils::find_extensions_icase, + themes::theme::{CursorMapping, CursorTheme, CursorType, TypedCursor}, + warn, +}; use std::{fs, path::PathBuf}; -use anyhow::{Result, bail}; +use anyhow::{Result, anyhow, bail}; use clap::{Parser, ValueEnum}; use fast_image_resize::{FilterType, ResizeAlg}; +use dialoguer::{ + Select, + console::{Term, style}, + theme::ColorfulTheme, +}; +use documented::DocumentedVariants; + /// Raw arguments from CLI. Has the [`Parser`] trait. #[derive(Parser)] #[command(version, about, long_about = None)] pub struct Args { - /// The paths to either cursor theme directories, cursor files, or both. - /// - /// Cursor file paths are converted to Xcursor (named the same as the cursor file), - /// while theme directory paths are converted fully into an X11 theme directory. + /// The paths to cursor theme installers, cursor files, directories. /// - /// Themes are expected to contain some cursor files and a - /// corresponding installer file that uses the INF/CRS format. + /// Supported theme installer formats include INF and CRS as of now. /// - /// To override this behaviour, use the "--no-theme" flag, which only - /// converts the contained cursor files and ignores any installer files. + /// Cursor file paths are converted to Xcursor (named the same as the cursor file, bar + /// extension), while directories are expanded to all the cursor files it contains + /// (non-recursively). This acts as an alternative for shells that can't glob (e.g., cmd). #[arg(required = true)] paths: Vec, - /// Uses a manual and interactive installation process. + /// Uses a manual and interactive conversion process. /// - /// This is for when no installer file is present to provide each cursor's semantic - /// role. Only a cursor "theme" (directory with cursor files) can be passed into this. + /// This is intended for when a theme installer isn't present. All provided cursor file paths will be used. /// /// Notes for usage: /// /// - You can re-select already used cursors if needed. /// - Person/Location Select on Windows have no equivalent on Linux, so ignore them. - /// - You may see missing glyphs, shown as □, �, etc. This is fine, but - /// if you do want to see them, consider downloading a nerd font. - #[arg(long, verbatim_doc_comment, conflicts_with = "no_theme")] + /// - You may see missing glyphs, shown as □, �, etc. This is fine, + /// but if you want to see them, consider downloading a nerd font. + #[arg(long, verbatim_doc_comment)] manual: bool, - /// Indicates that the directory provided is **not** a theme. - /// - /// Cursor files will be converted but no other work, such as writing symlinks, - /// theme files and mapping the cursor to the correct equivalent is done. - /// - /// This means that any installer files are ignored. This isn't - /// recommended for most use-cases as it makes conversion more manual. - #[arg(long)] - no_theme: bool, - /// Uses the provided scaling algorithm. /// /// This is overridden by "--upscale-with" and "--downscale-with", if set. @@ -72,32 +69,28 @@ pub struct Args { /// Uses the provided scaling algorithm for upscaling. /// - /// This algorithm overrides the "--scale-with" - /// algorithm when upscaling, if it's provided. + /// This algorithm overrides the "--scale-with" algorithm when upscaling, if it's provided. #[arg(long, value_name = "ALGORITHM")] upscale_with: Option, /// Uses the provided scaling algorithm for downscaling. /// - /// This algorithm overrides the "--scale-with" - /// algorithm when downscaling, if it's provided. + /// This algorithm overrides the "--scale-with" algorithm when downscaling, if it's provided. #[arg(long, value_name = "ALGORITHM")] downscale_with: Option, /// A list of scale factors to scale the original cursor(s) to. /// - /// Scale factors can be floats (decimals) e.g: 0.5, 1.5, 2.3, etc. - /// Any negative values are considered invalid scale factors. + /// Scale factors can be floats (decimals) e.g: 0.5, 1.5, 2.3, + /// etc. Any negative values are considered invalid scale factors. /// - /// All scaled variations and the original cursor - /// are included in the produced Xcursor file(s). + /// All scaled variations and the original cursor are included in the produced Xcursor file(s). #[arg(long, value_parser, num_args(1..), value_name = "F64_SCALE_FACTORS")] scale_to: Vec, /// The directory to place the parsed themes/files. /// - /// If the provided path doesn't exist yet, this - /// attempts to create it, including parents. + /// If the provided path doesn't exist yet, this attempts to create it, including parents. #[arg(short, long, default_value = "./")] out: PathBuf, } @@ -137,8 +130,8 @@ impl From<&ScalingAlgorithm> for ResizeAlg { /// Parsed CLI arguments. #[derive(Debug)] pub struct ParsedArgs { - /// All theme directories. - pub cursor_theme_dirs: Vec, + /// All installer files. + pub installer_files: Vec, /// All cursor files. pub cursor_files: Vec, /// Installation is manual. Or not. @@ -166,7 +159,7 @@ impl ParsedArgs { pub fn from_args(args: Args) -> Result { let paths = args.paths; let manual = args.manual; - let mut cursor_theme_dirs = Vec::new(); + let mut installer_files = Vec::new(); let mut cursor_files = Vec::new(); for path in paths { @@ -186,14 +179,20 @@ impl ParsedArgs { } if path.is_dir() { - cursor_theme_dirs.push(path); + cursor_files.extend(find_extensions_icase(&path, &["cur", "ani"])?); } else if path.is_file() { - cursor_files.push(path); + let Some(ext) = path.extension().and_then(|e| e.to_str()) else { + warn!("ignoring file {path_display} as it has no extension"); + continue; + }; + + match ext.to_ascii_lowercase().as_str() { + "inf" | "crs" => installer_files.push(path), + "cur" | "ani" => cursor_files.push(path), + _ => warn!("ignoring file {path_display} as it is not a cursor"), + } } else { - bail!( - "provided path={} is neither a dir or a file", - path.display() - ); + warn!("ignoring path={path_display} as it is neither a dir or a file",); } } @@ -224,15 +223,8 @@ impl ParsedArgs { let out = args.out; fs::create_dir_all(&out)?; - if args.no_theme { - for theme in cursor_theme_dirs.drain(..) { - let cursors = find_extensions_icase(&theme, &["cur", "ani"])?; - cursor_files.extend(cursors); - } - } - Ok(Self { - cursor_theme_dirs, + installer_files, cursor_files, manual, scale_to, @@ -252,3 +244,64 @@ impl ParsedArgs { } } } + +/// Asks the user a series of prompts to construct a theme manually. +/// +/// This is used for when no installer file is present. +/// +/// ## Errors +/// +/// - any path in `cursor_paths` has no filename +/// - [`Select`] prompt fails (e.g., if user is not in a terminal) +pub(super) fn prompt_for_theme(cursor_files: &[PathBuf]) -> Result { + let mut mappings = Vec::with_capacity(cursor_files.len()); + let mut cursor_paths_display: Vec<_> = cursor_files + .iter() + .map(|p| { + p.file_name() + .map(|f| format!("'{}' ", f.display())) + .ok_or_else(|| anyhow!("no file name for cursor path, p={}", p.display())) + }) + .collect::>()?; + + for r#type in CursorType::VARIANTS { + let prompt = format!( + "Select the file representing '{:?}'.\n{}", + r#type, + r#type.get_variant_docs() + ); + + let chosen_index = Select::with_theme(&ColorfulTheme::default()) + .items(&cursor_paths_display) + .with_prompt(prompt) + .default(0) + .report(false) // can get very messy as prompts are long + .interact()?; + + cursor_paths_display[chosen_index].push_str(&style("✓").green().to_string()); + + let path = cursor_files[chosen_index].clone(); + mappings.push(CursorMapping { r#type, path }); + } + + let name = loop { + eprint!("Enter a theme name: "); + let theme_name = Term::stderr().read_line()?; + + // crude, but it works + if theme_name.contains(['/', '\\']) { + eprintln!("Theme name can't contain '/' or '\\'."); + } else { + break theme_name; + } + }; + + let typed_cursors = mappings + .into_iter() + .map(TypedCursor::from_mapping) + .collect::>()?; + + let theme = CursorTheme::new(typed_cursors, name)?; + + Ok(theme) +} diff --git a/src/cursors/cursor_image.rs b/src/cursors/cursor_image.rs index af0c044..6d62a1d 100644 --- a/src/cursors/cursor_image.rs +++ b/src/cursors/cursor_image.rs @@ -190,7 +190,7 @@ impl fmt::Debug for CursorImage { /// - If there is one frame, the delay of it is zero. /// - If there are multiple frames, all delays are non-zero. #[derive(Debug)] -#[expect(clippy::len_without_is_empty)] // it's never empty +#[expect(clippy::len_without_is_empty, reason = "it's never empty")] pub struct CursorImages { inner: Vec, } @@ -265,7 +265,7 @@ pub mod tests { hotspot_x: 0, hotspot_y: 0, delay: 100, - rgba: vec![0u8; 4096], + rgba: vec![0_u8; 4096], }); pub static WHITE: LazyLock = LazyLock::new(|| CursorImage { diff --git a/src/cursors/generic_cursor.rs b/src/cursors/generic_cursor.rs index 9d74e35..7682c90 100644 --- a/src/cursors/generic_cursor.rs +++ b/src/cursors/generic_cursor.rs @@ -340,7 +340,7 @@ impl GenericCursor { /// Trivial accessor for `base` field. #[must_use] - pub fn base_images(&self) -> &CursorImages { + pub const fn base_images(&self) -> &CursorImages { &self.base } diff --git a/src/formats/ani.rs b/src/formats/ani.rs index 8f4997a..c40045a 100644 --- a/src/formats/ani.rs +++ b/src/formats/ani.rs @@ -254,7 +254,7 @@ impl AniFile { let ani_blob_len_u64 = u64::try_from(ani_blob.len())?; let mut ani = Self::default(); let mut cursor = Cursor::new(ani_blob); - let mut buf = [0u8; 4]; + let mut buf = [0_u8; 4]; cursor.read_exact(&mut buf)?; if buf != *b"RIFF" { @@ -333,8 +333,8 @@ impl AniFile { /// The "INFO" chunk isn't required. The "fram" chunk is. fn parse_list(cursor: &mut Cursor<&[u8]>, ani: &mut Self) -> Result<()> { let ani_blob_size = cursor.get_ref().len(); - let mut buf = [0u8; 4]; - let mut list_id = [0u8; 4]; + let mut buf = [0_u8; 4]; + let mut list_id = [0_u8; 4]; cursor.read_exact(&mut buf)?; // list size cursor.read_exact(&mut list_id)?; let list_size = u32::from_le_bytes(buf); diff --git a/src/formats/crs.rs b/src/formats/crs.rs index 990f99c..5131496 100644 --- a/src/formats/crs.rs +++ b/src/formats/crs.rs @@ -44,27 +44,31 @@ fn section_to_type(section: &str) -> Option { /// /// If file is failed to be read or has unexpected sections. /// Note that missing sections are not treated as an error. -pub fn parse_crs_installer(crs_path: &Path, theme_dir: &Path) -> Result> { - let crs_string = fs::read_to_string(crs_path)?; +pub fn parse_crs_installer(crs_path: &Path) -> Result> { + // I assume paths are relative to the CRS file? Wouldn't + // make sense otherwise but this format has no spec :P + let parent = crs_path + .parent() + .ok_or_else(|| anyhow!("no parent for crs_path={}", crs_path.display()))?; + let crs = Ini::new() - .read(crs_string) + .read(fs::read_to_string(crs_path)?) .map_err(|e| anyhow!("failed to read crs, error e={e}"))?; let mut mappings = Vec::with_capacity(CursorType::NUM_VARIANTS); - for section_name in crs.keys() { - let Some(r#type) = section_to_type(section_name) else { - bail!("unexpected section in crs file (please report), section={section_name}"); + for (section, value) in crs { + let Some(r#type) = section_to_type(§ion) else { + bail!("unexpected section in crs file (please report), section={section}"); }; - let relative_path = crs.get(section_name).and_then(|s| s.get("path")); - - let Some(Some(relative_path)) = relative_path else { - warn!("skipping section_name={section_name}"); + let Some(relative) = value.get("path").and_then(Option::as_ref) else { + warn!("skipping section_name={section}"); continue; }; - let path = theme_dir.join(relative_path); + let path = parent.join(relative); + mappings.push(CursorMapping { r#type, path }); } diff --git a/src/formats/inf.rs b/src/formats/inf.rs index d669612..bf4102f 100644 --- a/src/formats/inf.rs +++ b/src/formats/inf.rs @@ -20,32 +20,50 @@ use configparser::ini::Ini; // inf is an "ini-like" format /// /// ## Implementation details /// -/// In INF installer files, the `Scheme.Reg` section is usually (but not always!) like this +/// First, parse the order stored in `Scheme.Reg` to get each cursors' semantic role. Since the +/// paths stored there are the _destination_ paths, we need to get the _source_ paths (the ones +/// relative to the INF). This can be done by reading the section(s) stored in `CopyFiles`). +/// +/// Each entry in the aforementioned section will look like this: +/// +/// `destination-file-name[,[source-file-name][,[unused][,flag]]]` +/// +/// To get the source file, we match the filename of the destination paths stored in `Scheme.Reg`. +/// If the source is omitted, both the source and destination filenames are the same. +/// +/// ## INF `AddReg` +/// +/// The `AddReg` directive refers to sections that add entries to the registry. The section +/// that installs the cursor theme is usually called `Scheme.Reg` and looks something like this: /// /// ```text /// ; note that this is pseudocode, this isn't a valid inf file +/// ; each entry follows the format: +/// ; reg-root,[subkey],[value-entry-name],[flags],[value][,[value]] /// -/// ; this section always starts like this -/// HKCU,"Control Panel\Cursors\Schemes","theme_name",, -/// -/// ; the cursors are always ordered like this -/// ; sometimes they're variables, sometimes not -/// "pointer,help,work,busy,cross,text,hand,unavailable, +/// HKCU,"Control Panel\Cursors\Schemes","theme_name",, \ +/// "pointer,help,work,busy,cross,text,hand,unavailable, \ /// vert,horz,dgn1,dgn2,move,alternate,link,pin,person" +/// +/// ; ^ the cursors are always ordered like this. sometimes +/// ; they're variables (in the `Strings` section), sometimes not /// ``` -pub fn parse_inf_installer( - inf_path: &Path, - theme_dir: &Path, -) -> Result<(String, Vec)> { +pub fn parse_inf_installer(inf_path: &Path) -> Result<(String, Vec)> { let inf_string = fs::read_to_string(inf_path)?; - let inf = Ini::new() + let parent = inf_path + .parent() + .ok_or_else(|| anyhow!("no parent for inf_path={}", inf_path.display()))?; + + let inf: HashMap>> = Ini::new() .read(inf_string) .map_err(|e| anyhow!("failed to read inf, error e={e}"))?; - let addreg = inf + let defaultinstall: &HashMap> = inf .get("defaultinstall") - .ok_or_else(|| anyhow!("no defaultinstall section found"))? + .ok_or_else(|| anyhow!("no defaultinstall section found"))?; + + let addreg = defaultinstall .get("addreg") .ok_or_else(|| anyhow!("no addreg key found in defaultinstall"))? .as_ref() @@ -53,13 +71,11 @@ pub fn parse_inf_installer( // find the right registry entries (the ones we can parse) // https://github.com/quantum5/win2xcur/blob/c8a390b79456a45104fe42133b9d7eb4ce7c8638/win2xcur/parser/inf.py#L47-L50 - let scheme = addreg.split(',') + let scheme = addreg + .split(',') .filter_map(|k| inf.get(&k.to_ascii_lowercase())) .flat_map(|v| v.keys()) - .find(|k| - k.starts_with(r#"hkcu,"control panel\cursors\schemes","#) || - k.starts_with(r#"hklm,"software\microsoft\windows\currentversion\control panel\cursors\schemes","#) - ) + .find(|k| k.contains(r#""control panel\cursors\schemes","#)) .ok_or_else(|| anyhow!("couldn't find cursor mappings"))?; let subs = inf.get("strings"); @@ -67,7 +83,7 @@ pub fn parse_inf_installer( let mut reg_info = expanded_reg.split(','); reg_info.next(); // root key, e.g., hkcu, hklm - reg_info.next(); // path + reg_info.next(); // subkey let name = reg_info .next() @@ -83,21 +99,24 @@ pub fn parse_inf_installer( .map(|s| { s.rsplit_once('\\') .ok_or_else(|| anyhow!("failed to extract filename from path, s={s}")) - .map(|s| s.1) + .map(|s| s.1.to_ascii_lowercase()) }) .collect::>()?; let end = paths.len() - 1; paths[end] = paths[paths.len() - 1] .strip_suffix('"') - .ok_or_else(|| anyhow!("expected closing quotation for paths, didn't find it"))?; + .ok_or_else(|| anyhow!("expected closing quotation for paths, didn't find it"))? + .to_string(); + + let paths = resolve_paths(&inf, defaultinstall, &paths)?; let mappings: Vec<_> = paths .into_iter() .zip(0..15) .map(|(p, i)| CursorMapping { r#type: index_to_cursor_type(i), - path: theme_dir.join(p), + path: parent.join(p), }) .collect(); @@ -126,7 +145,68 @@ const fn index_to_cursor_type(index: usize) -> CursorType { } } -/// Helper function for [`parse_inf_installer`]. This expands `Scheme.Reg` if needed. +/// Resolves destination paths to source paths. +fn resolve_paths( + inf: &HashMap>>, + defaultinstall: &HashMap>, + paths: &[String], +) -> Result> { + let copyfiles = defaultinstall + .get("copyfiles") + .cloned() + .flatten() + .ok_or_else(|| anyhow!("no copyfiles section"))?; + + // paths are coerced to lowercase because they're "keys" (from configparser's perspective). + // this most likely causes some extra lookups, since the initial path most likely has + // the correct casing. could be solved with Ini::new_cs() but probably isn't worth it. + let mut mappings = HashMap::with_capacity(paths.len()); + + for field in copyfiles.split(',') { + // TODO: Implement this later. + if matches!(copyfiles.chars().next(), Some('@')) { + bail!("unsupported '@' syntax in copyfiles"); + } + + let field = field.trim(); + + let section = inf + .get(&field.to_ascii_lowercase()) + .ok_or_else(|| anyhow!("copyfiles specifies '{field}' should exist, but doesn't"))?; + + for k in section.keys() { + // destination-file-name[,[source-file-name][,[unused][,flag]]] + let entry: Vec<_> = k.split(',').map(|f| f.replace('\\', "/")).collect(); + + if entry.is_empty() { + bail!("empty entry in section={field}"); + } + + if entry.len() == 1 { + mappings.insert(dequote(&entry[0]), dequote(&entry[0])); + } else { + mappings.insert(dequote(&entry[0]), dequote(&entry[1])); + } + } + } + + let mut new = Vec::with_capacity(paths.len()); + + for p in paths { + new.push( + mappings + .get(p.as_str()) + .ok_or_else(|| anyhow!("missing mapping for {p}"))? + .clone(), + ); + } + + Ok(new) +} + +/// Helper function for [`parse_inf_installer`]. +/// +/// This expands `Scheme.Reg` if needed. fn expand_scheme(reg: &str, subs: Option<&HashMap>>) -> Result { let Some(subs) = subs else { let empty: HashMap = HashMap::new(); @@ -142,20 +222,30 @@ fn expand_scheme(reg: &str, subs: Option<&HashMap>>) -> R expand(reg, &subs).with_context(|| format!("for input reg={reg}")) } -/// Helper function for [`expand_reg`] for removing the outer pair of quotes. +/// Dequotes following INF spec. +/// +/// The INF parser not only discards the outermost pair of enclosing double quotation +/// marks for any "quoted string" in this section, but also condenses each subsequent +/// sequential pair of double quotation marks into a single double quotation marks character. +/// +/// For example, """some string""" also becomes "some string" when it is parsed. +fn dequote(input: &str) -> String { + let mut input = input.trim(); + + if input.starts_with('"') && input.ends_with('"') && input.len() >= 2 { + input = &input[1..(input.len() - 1)]; + } + + input.replace("\"\"", "\"") +} + +/// Helper function for [`expand_scheme`] to remove the outer pair of quotes. /// /// This is because [`configparser`] takes _everything_ as a string, /// for example: `key = "value"` means `config["key"] == "\"value\""`. fn dequote_value(entry: (&String, &Option)) -> Option<(String, String)> { match entry { - (k, Some(v)) => Some(( - k.clone(), - v.strip_suffix('"') - .unwrap_or_default() - .strip_prefix('"') - .unwrap_or_default() - .to_string(), - )), + (k, Some(v)) => Some((k.clone(), dequote(v))), (k, None) => { // side effect but shhh warn!("key={k} has value None"); @@ -189,7 +279,7 @@ fn expand(input: &str, subs: &HashMap) -> Result { let value = subs .get(key) .map(String::as_str) - .or_else(|| if key == "%%" { Some("%") } else { None }) + .or_else(|| (key == "%%").then_some("%")) .or_else(|| { if key.chars().all(|c| c.is_ascii_digit() || c == '%') { // let's just assume it's a DIRID and leave it :) @@ -263,7 +353,7 @@ mod tests { $( CursorMapping { r#type: crate::themes::theme::CursorType::$variant, - path: $root.join(concat!("Neuro ", $filename_suffix, ".ani")), + path: $root.join(concat!("neuro ", $filename_suffix, ".ani")), }, )+ ]} @@ -271,7 +361,7 @@ mod tests { let theme_dir = Path::new(from_root!("/testing/fixtures/neuro")); let inf_path = theme_dir.join("Install.inf"); - let (theme_name, mappings) = parse_inf_installer(&inf_path, theme_dir).unwrap(); + let (theme_name, mappings) = parse_inf_installer(&inf_path).unwrap(); assert_eq!(theme_name, "Neuro-sama Cursor"); let expected_mappings = make_mappings!( diff --git a/src/formats/xcursor.rs b/src/formats/xcursor.rs index bbfa546..c785807 100644 --- a/src/formats/xcursor.rs +++ b/src/formats/xcursor.rs @@ -109,7 +109,7 @@ struct CommentChunk { } impl CommentChunk { - fn new(string: String, subtype: CommentRole, position: u32) -> (Self, TableOfContents) { + const fn new(string: String, subtype: CommentRole, position: u32) -> (Self, TableOfContents) { let comment = Self { role: subtype, string: string.into_bytes(), @@ -230,7 +230,7 @@ fn to_pre_argb(rgba: &mut [u8]) { // less swaps needed and NE speeds (if on LE arch) pixel.swap(0, 2); - for i in 0..3usize { + for i in 0..3_usize { pixel[i] = pre_alpha_formula(pixel[i], pixel[3]); } } diff --git a/src/fs_utils.rs b/src/fs_utils.rs index e804fd8..2c8fd50 100644 --- a/src/fs_utils.rs +++ b/src/fs_utils.rs @@ -1,60 +1,62 @@ //! Utilities related to paths. -use anyhow::{Result, anyhow, bail}; -use std::path::{Path, PathBuf}; +use anyhow::{Result, bail}; + +use std::{ + borrow::ToOwned, + path::{Path, PathBuf}, +}; use crate::warn; -/// Attempts to find `file_path` by searching through it's parent dir. +/// Resolves each component in `path` case-insensitively. /// -/// This does not search recursively. Also, `file_path.file_name()` -/// not being found in its parent dir isn't considered an error. +/// Mostly used for Windows to Linux path conversions. /// /// ## Errors /// -/// - `file_path` doesn't have a parent -/// - `file_path` is not a file -/// - multiple files match `file_path` -pub fn find_icase(file_path: &Path) -> Result> { - let file_path_display = file_path.display(); - - if file_path.try_exists()? { - if file_path.metadata()?.is_file() { - return Ok(Some(file_path.to_path_buf())); - } +/// If multiple candidates are found, or for general fs issues. +pub fn resolve_icase(path: &Path) -> Result> { + let path_display = path.display(); - bail!("file_path={file_path_display} exists but is not a file"); + // windows should just always hit this, methinks + if path.try_exists()? { + return Ok(Some(path.to_path_buf())); } - let parent = file_path.parent().ok_or_else(|| { - anyhow!("no parent found for file_path={file_path_display} during case-insensitive lookup") - })?; - let parent_display = parent.display(); + let mut resolved = PathBuf::new(); - let filename = file_path - .file_name() - .ok_or_else(|| anyhow!("no filename for file_path={file_path_display}"))?; + for component in path.components() { + if resolved.join(component).try_exists()? { + resolved.push(component); + continue; + } - let found: Vec<_> = read_dir_files(parent)? - .filter(|p| { - p.file_name() - .is_some_and(|name| name.eq_ignore_ascii_case(filename)) - }) - .collect(); + let component = component.as_os_str(); + + let found: Vec<_> = read_dir(&resolved, true, true)? + .filter_map(|p| p.file_name().map(ToOwned::to_owned)) + .filter(|name| name.eq_ignore_ascii_case(component)) + .collect(); - if found.len() > 1 { - bail!( - "multiple candidates found for case-insensitive lookup \ - in parent={parent_display} for filename={file_path_display}" - ); + match found.as_slice() { + [] => return Ok(None), + [name] => resolved.push(name), + _ => bail!( + "multiple candidates found for case-insensitive \ + lookup in parent={} for path={path_display}", + resolved.display() + ), + } } - Ok(found.first().cloned()) + Ok(Some(resolved)) } /// Attempts to find files in `dir` with file extensions in `extensions`. /// -/// This is case-insensitive and not recursive. +/// This assumes that `dir` exists and does not search recursively for the given extensions. +/// The extensions must _not_ be prefixed with a dot (e.g., "png" instead of ".png"). /// /// ## Errors /// @@ -69,16 +71,22 @@ pub fn find_extensions_icase( bail!("expected dir={dir_display} to be a directory"); } - Ok(read_dir_files(dir)?.filter(|p| { + Ok(read_dir(dir, true, false)?.filter(|p| { p.extension() .is_some_and(|ext| extensions.iter().any(|ele| ext.eq_ignore_ascii_case(ele))) })) } /// Helper function for reading `dir` robustly. -/// -/// The returned iterator only yields files. -fn read_dir_files(dir: &Path) -> Result> { +fn read_dir( + dir: &Path, + allow_file: bool, + allow_dir: bool, +) -> Result> { + if !allow_file && !allow_dir { + bail!("both file and dir not allowed - most likely unintended"); + } + Ok(dir .read_dir()? .filter_map(|e| { @@ -88,11 +96,11 @@ fn read_dir_files(dir: &Path) -> Result> { .ok() }) .map(|e| e.path()) - .filter(|p| { - p.metadata() + .filter(move |p| { + p.metadata() // follows symlinks .inspect_err(|err| { warn!("failed to read metadata of path, p={}: {err}", p.display()); }) - .is_ok_and(|m| m.is_file()) + .is_ok_and(|m| allow_file && m.is_file() || allow_dir && m.is_dir()) })) } diff --git a/src/main.rs b/src/main.rs index c573b9f..f965293 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,8 +11,10 @@ clippy::semicolon_inside_block, clippy::allow_attributes )] -// when used, scope is restricted (use statement inside functions) -#![allow(clippy::enum_glob_use)] +#![allow( + clippy::enum_glob_use, + reason = "when used, scope is restricted (e.g., inside functions)" +)] pub mod cli; pub mod cursors; @@ -32,7 +34,7 @@ macro_rules! from_root { use from_root; use crate::{ - cli::{Args, ParsedArgs}, + cli::{Args, ParsedArgs, prompt_for_theme}, cursors::generic_cursor::GenericCursor, themes::theme::CursorTheme, }; @@ -56,8 +58,8 @@ fn main() -> Result<()> { let raw_args = Args::parse(); let args = ParsedArgs::from_args(raw_args)?; - args.cursor_theme_dirs.par_iter().try_for_each(|d| { - let mut theme = CursorTheme::from_theme_dir(d, args.manual) + args.installer_files.par_iter().try_for_each(|d| { + let mut theme = CursorTheme::from_installer_file(d) .with_context(|| format!("while reading dir={} as theme", d.display()))?; for &sf in &args.scale_to { @@ -67,21 +69,31 @@ fn main() -> Result<()> { theme.save_as_x11_theme(&args.out) })?; - args.cursor_files.par_iter().try_for_each(|f| { - let mut cursor = GenericCursor::from_path(f) - .with_context(|| format!("while reading f={} as cursor", f.display()))?; - - let filename = args.out.join( - f.file_stem() - .ok_or_else(|| anyhow!("no file stem for cursor_file={}", f.display()))?, - ); + if args.manual { + let mut theme = prompt_for_theme(&args.cursor_files)?; for &sf in &args.scale_to { - cursor.add_scale(sf, args.get_algorithm(sf))?; + theme.add_scale(sf, args.get_algorithm(sf))?; } - cursor.save_as_xcursor(filename) - })?; + theme.save_as_x11_theme(&args.out)?; + } else { + args.cursor_files.par_iter().try_for_each(|f| { + let mut cursor = GenericCursor::from_path(f) + .with_context(|| format!("while reading f={} as cursor", f.display()))?; + + let filename = args.out.join( + f.file_stem() + .ok_or_else(|| anyhow!("no file stem for cursor_file={}", f.display()))?, + ); + + for &sf in &args.scale_to { + cursor.add_scale(sf, args.get_algorithm(sf))?; + } + + cursor.save_as_xcursor(filename) + })?; + } Ok(()) } diff --git a/src/themes/manual.rs b/src/themes/manual.rs deleted file mode 100644 index 260bb16..0000000 --- a/src/themes/manual.rs +++ /dev/null @@ -1,68 +0,0 @@ -use super::theme::CursorType; -use crate::themes::theme::CursorMapping; - -use std::path::PathBuf; - -use anyhow::{Result, anyhow}; -use dialoguer::{ - Select, - console::{Term, style}, - theme::ColorfulTheme, -}; -use documented::DocumentedVariants; - -/// Asks the user a series of prompts to construct a theme manually. -/// -/// This is used for when no installer file is present. -/// -/// ## Errors -/// -/// - any path in `cursor_paths` has no filename -/// - [`Select`] prompt fails (e.g., if user is not in a terminal) -pub(super) fn prompt_for_mappings( - cursor_paths: &[PathBuf], -) -> Result<(String, Vec)> { - let mut mappings = Vec::with_capacity(cursor_paths.len()); - let mut cursor_paths_display: Vec<_> = cursor_paths - .iter() - .map(|p| { - p.file_name() - .map(|f| format!("'{}' ", f.display())) - .ok_or_else(|| anyhow!("no file name for cursor path, p={}", p.display())) - }) - .collect::>()?; - - for r#type in CursorType::VARIANTS { - let prompt = format!( - "Select the file representing '{:?}'.\n{}", - r#type, - r#type.get_variant_docs() - ); - - let chosen_index = Select::with_theme(&ColorfulTheme::default()) - .items(&cursor_paths_display) - .with_prompt(prompt) - .default(0) - .report(false) // can get very messy as prompts are long - .interact()?; - - cursor_paths_display[chosen_index].push_str(&style("✓").green().to_string()); - - let path = cursor_paths[chosen_index].clone(); - mappings.push(CursorMapping { r#type, path }); - } - - let name = loop { - eprint!("Enter a theme name: "); - let theme_name = Term::stderr().read_line()?; - - // crude, but it works - if theme_name.contains(['/', '\\']) { - eprintln!("Theme name can't contain '/' or '\\'."); - } else { - break theme_name; - } - }; - - Ok((name, mappings)) -} diff --git a/src/themes/mod.rs b/src/themes/mod.rs index e82d83b..2bb5398 100644 --- a/src/themes/mod.rs +++ b/src/themes/mod.rs @@ -1,5 +1,4 @@ //! Groups modules related to the parsing or creation of cursor themes. -pub mod manual; pub mod symlinks; pub mod theme; diff --git a/src/themes/theme.rs b/src/themes/theme.rs index 404bb2a..6335cfb 100644 --- a/src/themes/theme.rs +++ b/src/themes/theme.rs @@ -4,8 +4,7 @@ use super::symlinks::get_symlinks; use crate::{ cursors::generic_cursor::GenericCursor, formats::{crs::parse_crs_installer, inf::parse_inf_installer}, - fs_utils::{find_extensions_icase, find_icase}, - themes::manual::prompt_for_mappings, + fs_utils::resolve_icase, warn, }; @@ -21,7 +20,7 @@ use fast_image_resize::ResizeAlg; use rayon::iter::{IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator}; /// Cursor mappings stored in installer files. -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Eq)] pub struct CursorMapping { /// Semantic role of cursor. pub r#type: CursorType, @@ -33,7 +32,7 @@ pub struct CursorMapping { /// /// Some cursors, such as `Crosshair`, have symlinks to Xcursors /// that aren't _exactly_ the same, such as `color-picker`. -#[derive(Debug, PartialEq, Clone, DocumentedVariants)] +#[derive(Debug, PartialEq, Eq, Clone, DocumentedVariants)] pub enum CursorType { // using https://github.com/khayalhus/win2xcur-batch/blob/main/map.json // NOTE: documentation here is displayed to users in manual installs. @@ -146,21 +145,20 @@ pub struct TypedCursor { impl TypedCursor { /// Creates a cursor from `mapping`. /// - /// Note that this does a case-insensitive search if - /// the path stored in `mapping` doesn't exist. + /// Note that this does a case-insensitive search if the path stored in `mapping` doesn't + /// exist. This aspect is also why this isn't inside of a [`TryFrom`] implementation. /// /// ## Errors /// - /// - if path contained inside of `mapping` doesn't - /// exist, even after a case-insensitive check + /// - if path contained inside of `mapping` doesn't exist, even after a case-insensitive check /// - generic cursor parsing fails - fn from_mapping(mapping: CursorMapping) -> Result { + pub fn from_mapping(mapping: CursorMapping) -> Result { let CursorMapping { path, r#type } = mapping; let path = if path.exists() { path } else { - find_icase(&path)?.ok_or_else(|| { + resolve_icase(&path)?.ok_or_else(|| { anyhow!( "cursor path, path={} not found in parent (case-insensitive)", path.display() @@ -246,49 +244,21 @@ impl CursorTheme { /// ## Errors /// /// Mostly from parsing the INF file and filesystem operations. - pub fn from_theme_dir(theme_dir: impl AsRef, manual: bool) -> Result { - let theme_dir = theme_dir.as_ref(); - - let (name, mappings) = if manual { - let cursor_paths: Vec<_> = find_extensions_icase(theme_dir, &["ani", "cur"])?.collect(); - - if cursor_paths.is_empty() { - bail!( - "no cursors (files with .cur or .ani extension) found in {}", - theme_dir.display() - ) - } - - prompt_for_mappings(&cursor_paths)? + pub fn from_installer_file(installer_file: impl AsRef) -> Result { + let installer_file = installer_file.as_ref(); + let ext = installer_file.extension().ok_or_else(|| { + anyhow!( + "no extension for installer_file={}", + installer_file.display() + ) + })?; + + let (name, mappings) = if ext.eq_ignore_ascii_case("inf") { + parse_inf_installer(installer_file)? + } else if ext.eq_ignore_ascii_case("crs") { + (String::new(), parse_crs_installer(installer_file)?) } else { - let installers: Vec<_> = find_extensions_icase(theme_dir, &["inf", "crs"])?.collect(); - - if installers.len() > 1 { - bail!("found more than one installer (INF/CRS) file"); - } - - let Some(installer) = installers.first().cloned() else { - bail!("no installer (INF/CRS) file found, consider adding the --manual flag"); - }; - - if installers[0].extension().is_some_and(|ext| ext == "inf") { - parse_inf_installer(&installer, theme_dir).with_context(|| { - format!( - "while attempting to parse inf installer {}", - installer.display() - ) - })? - } else { - ( - String::new(), - parse_crs_installer(&installer, theme_dir).with_context(|| { - format!( - "while attempting to parse crs installer {}", - installer.display() - ) - })?, - ) - } + bail!("unsupported installer file extension ext={}", ext.display()) }; let typed_cursors: Vec<_> = mappings @@ -328,13 +298,14 @@ impl CursorTheme { self.name.clone() }; - let sanitized = name.replace(['/', '\\'], "_"); + let sanitized = name.replace(['/', '\\', '.'], "_"); let theme_dir = dir.join(sanitized); let cursor_dir = theme_dir.join("cursors"); fs::create_dir_all(&cursor_dir) .with_context(|| format!("failed to write cursor_dir={}", cursor_dir.display()))?; - // TODO: replace with tar.gz + // TODO: Replace with direct writing of tar.gz to deal with less Windows nonsense. + // copies are not a good alternative due to storage concerns #[cfg(windows)] {