From cf4427ff12902ec7321ecafd533831f7d4abcb1f Mon Sep 17 00:00:00 2001 From: hachispin Date: Sat, 25 Jul 2026 13:13:28 +0100 Subject: [PATCH 1/9] Change CLI inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit directories are now always expanded to the cursor files they contain, similar to globbing (since some shells [like cmd]) can't glob ☹ new input includes installer files (inf/crs), directories (following the aforementioned behaviour) and cursor files which are just converted by themselves (unless `--manual` is provided, wherein they're used as cursor choices) wow i can't write for shit --- src/cli.rs | 89 +++++++++++++++++++++--------------------------------- 1 file changed, 35 insertions(+), 54 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index bbb8055..ed7ee82 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,9 +1,9 @@ //! 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, warn}; use std::{fs, path::PathBuf}; @@ -15,43 +15,29 @@ use fast_image_resize::{FilterType, ResizeAlg}; #[derive(Parser)] #[command(version, about, long_about = None)] pub struct Args { - /// The paths to either cursor theme directories, cursor files, or both. + /// The paths to cursor theme installers, cursor files, directories. /// - /// 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. + /// Supported theme installer formats include INF and CRS as of now. /// - /// Themes are expected to contain some cursor files and a - /// corresponding installer file that uses the INF/CRS format. - /// - /// 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 +58,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 +119,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 +148,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 +168,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 +212,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, From 5d4728bbe93a51e592d7a6164cf988da700f90d5 Mon Sep 17 00:00:00 2001 From: hachispin Date: Sat, 25 Jul 2026 14:45:28 +0100 Subject: [PATCH 2/9] Move themes/manual.rs to cli.rs also moved around code so things aren't super broken --- src/cli.rs | 76 ++++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 38 ++++++++++++++-------- src/themes/manual.rs | 68 --------------------------------------- src/themes/mod.rs | 1 - src/themes/theme.rs | 22 +++---------- 5 files changed, 103 insertions(+), 102 deletions(-) delete mode 100644 src/themes/manual.rs diff --git a/src/cli.rs b/src/cli.rs index ed7ee82..7864554 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,14 +3,25 @@ //! 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, warn}; +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)] @@ -233,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/main.rs b/src/main.rs index c573b9f..54290c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,7 +32,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 +56,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_theme_dir(d) .with_context(|| format!("while reading dir={} as theme", d.display()))?; for &sf in &args.scale_to { @@ -67,21 +67,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..8272694 100644 --- a/src/themes/theme.rs +++ b/src/themes/theme.rs @@ -5,7 +5,6 @@ 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, warn, }; @@ -146,15 +145,15 @@ 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 /// - 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() { @@ -246,21 +245,10 @@ impl CursorTheme { /// ## Errors /// /// Mostly from parsing the INF file and filesystem operations. - pub fn from_theme_dir(theme_dir: impl AsRef, manual: bool) -> Result { + pub fn from_theme_dir(theme_dir: impl AsRef) -> 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)? - } else { + let (name, mappings) = { let installers: Vec<_> = find_extensions_icase(theme_dir, &["inf", "crs"])?.collect(); if installers.len() > 1 { From ae5a19e4726f2be1d317de9a925d3f80b46857d7 Mon Sep 17 00:00:00 2001 From: hachispin Date: Sat, 25 Jul 2026 14:53:39 +0100 Subject: [PATCH 3/9] Fix some experimental lints --- src/cursors/cursor_image.rs | 4 ++-- src/cursors/generic_cursor.rs | 2 +- src/formats/ani.rs | 6 +++--- src/formats/inf.rs | 2 +- src/formats/xcursor.rs | 4 ++-- src/main.rs | 6 ++++-- src/themes/theme.rs | 4 ++-- 7 files changed, 15 insertions(+), 13 deletions(-) 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/inf.rs b/src/formats/inf.rs index d669612..8eb509f 100644 --- a/src/formats/inf.rs +++ b/src/formats/inf.rs @@ -189,7 +189,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 :) 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/main.rs b/src/main.rs index 54290c4..f738027 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; diff --git a/src/themes/theme.rs b/src/themes/theme.rs index 8272694..53f02cc 100644 --- a/src/themes/theme.rs +++ b/src/themes/theme.rs @@ -20,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, @@ -32,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. From c8afcefc35e74f9f1e3e5acfa03ed04f4c2822cf Mon Sep 17 00:00:00 2001 From: hachispin Date: Sat, 25 Jul 2026 23:32:39 +0100 Subject: [PATCH 4/9] Theme parsing fixed for INF Lookups are now done with the INF file, so this tool no longer relies upon INF being in the same directory as its cursor files. Seems to work. Should probably write a test for this though. Code is pretty atrocious but I'll clean it up. Maybe. --- src/formats/inf.rs | 154 ++++++++++++++++++++++++++++++++++---------- src/main.rs | 2 +- src/themes/theme.rs | 58 ++++++----------- 3 files changed, 142 insertions(+), 72 deletions(-) diff --git a/src/formats/inf.rs b/src/formats/inf.rs index 8eb509f..75198b3 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,6 +145,63 @@ const fn index_to_cursor_type(index: usize) -> CursorType { } } +/// 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!("..."))?; + + // configparser makes all keys lowercase so all filenames + // in copyfiles directive sections become lowercase too ☹ + let mut mappings = HashMap::with_capacity(paths.len()); + + for field in copyfiles.split(',') { + // TODO: Implement this later. + assert_ne!(copyfiles.chars().next(), Some('@')); + + 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?"); + } + + if entry.len() == 1 { + } else { + mappings.insert(dequote(&entry[0]), dequote(&entry[1])); + } + } + } + + dbg!(&mappings); + + 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 { @@ -142,20 +218,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"); @@ -263,7 +349,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 +357,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/main.rs b/src/main.rs index f738027..f965293 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,7 +59,7 @@ fn main() -> Result<()> { let args = ParsedArgs::from_args(raw_args)?; args.installer_files.par_iter().try_for_each(|d| { - let mut theme = CursorTheme::from_theme_dir(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 { diff --git a/src/themes/theme.rs b/src/themes/theme.rs index 53f02cc..6f66c16 100644 --- a/src/themes/theme.rs +++ b/src/themes/theme.rs @@ -2,10 +2,8 @@ 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}, - warn, + cursors::generic_cursor::GenericCursor, formats::inf::parse_inf_installer, + fs_utils::find_icase, warn, }; use std::{ @@ -245,39 +243,25 @@ impl CursorTheme { /// ## Errors /// /// Mostly from parsing the INF file and filesystem operations. - pub fn from_theme_dir(theme_dir: impl AsRef) -> Result { - let theme_dir = theme_dir.as_ref(); - - let (name, mappings) = { - 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() - ) - })?, - ) - } - }; + 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; + let mappings; + + if ext.eq_ignore_ascii_case("inf") { + (name, mappings) = parse_inf_installer(installer_file)?; + } else if ext.eq_ignore_ascii_case("crs") { + panic!(); + } else { + bail!("unsupported installer file extension ext={}", ext.display()); + } let typed_cursors: Vec<_> = mappings .into_iter() From bf8ec62f707fe0d7ab385ca144e5bc46d54f6b1c Mon Sep 17 00:00:00 2001 From: hachispin Date: Thu, 30 Jul 2026 20:03:20 +0100 Subject: [PATCH 5/9] hotfix --- src/formats/inf.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/formats/inf.rs b/src/formats/inf.rs index 75198b3..aca49ca 100644 --- a/src/formats/inf.rs +++ b/src/formats/inf.rs @@ -180,6 +180,7 @@ fn resolve_paths( } if entry.len() == 1 { + mappings.insert(dequote(&entry[0]), dequote(&entry[0])); } else { mappings.insert(dequote(&entry[0]), dequote(&entry[1])); } From 130a12d9108c523b64f185786f36df41f114649b Mon Sep 17 00:00:00 2001 From: hachispin Date: Fri, 7 Aug 2026 17:50:40 +0100 Subject: [PATCH 6/9] Resolve each component case-insensitively Initial implementation only resolved the filename, which worked under the original assumptions where cursors were stored in the same location as the INF file. This has now changed. --- src/fs_utils.rs | 94 +++++++++++++++++++++++++++---------------------- 1 file changed, 51 insertions(+), 43 deletions(-) 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()) })) } From 03dce95f2112557ba13baba2f5df3aff17780420 Mon Sep 17 00:00:00 2001 From: hachispin Date: Fri, 7 Aug 2026 17:51:58 +0100 Subject: [PATCH 7/9] Sanitize '.' and match name changes --- src/themes/theme.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/themes/theme.rs b/src/themes/theme.rs index 6f66c16..cf94c60 100644 --- a/src/themes/theme.rs +++ b/src/themes/theme.rs @@ -3,7 +3,7 @@ use super::symlinks::get_symlinks; use crate::{ cursors::generic_cursor::GenericCursor, formats::inf::parse_inf_installer, - fs_utils::find_icase, warn, + fs_utils::resolve_icase, warn, }; use std::{ @@ -148,8 +148,7 @@ impl TypedCursor { /// /// ## 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 pub fn from_mapping(mapping: CursorMapping) -> Result { let CursorMapping { path, r#type } = mapping; @@ -157,7 +156,7 @@ impl TypedCursor { 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() @@ -300,13 +299,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)] { From 0cbd81becd43690f81004592b7bc10f55243c6ec Mon Sep 17 00:00:00 2001 From: hachispin Date: Fri, 7 Aug 2026 19:53:19 +0100 Subject: [PATCH 8/9] Parse CRS paths relative to its parent Matches how INF files are handled now. --- src/formats/crs.rs | 26 +++++++++++++++----------- src/themes/theme.rs | 19 +++++++++---------- 2 files changed, 24 insertions(+), 21 deletions(-) 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/themes/theme.rs b/src/themes/theme.rs index cf94c60..6335cfb 100644 --- a/src/themes/theme.rs +++ b/src/themes/theme.rs @@ -2,8 +2,10 @@ use super::symlinks::get_symlinks; use crate::{ - cursors::generic_cursor::GenericCursor, formats::inf::parse_inf_installer, - fs_utils::resolve_icase, warn, + cursors::generic_cursor::GenericCursor, + formats::{crs::parse_crs_installer, inf::parse_inf_installer}, + fs_utils::resolve_icase, + warn, }; use std::{ @@ -251,16 +253,13 @@ impl CursorTheme { ) })?; - let name; - let mappings; - - if ext.eq_ignore_ascii_case("inf") { - (name, mappings) = parse_inf_installer(installer_file)?; + let (name, mappings) = if ext.eq_ignore_ascii_case("inf") { + parse_inf_installer(installer_file)? } else if ext.eq_ignore_ascii_case("crs") { - panic!(); + (String::new(), parse_crs_installer(installer_file)?) } else { - bail!("unsupported installer file extension ext={}", ext.display()); - } + bail!("unsupported installer file extension ext={}", ext.display()) + }; let typed_cursors: Vec<_> = mappings .into_iter() From 5626651e389bed4cfdf5896d0e9bf8d3d227e91f Mon Sep 17 00:00:00 2001 From: hachispin Date: Fri, 7 Aug 2026 21:27:07 +0100 Subject: [PATCH 9/9] Remove `dbg!()` and polish --- src/formats/inf.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/formats/inf.rs b/src/formats/inf.rs index aca49ca..bf4102f 100644 --- a/src/formats/inf.rs +++ b/src/formats/inf.rs @@ -155,15 +155,18 @@ fn resolve_paths( .get("copyfiles") .cloned() .flatten() - .ok_or_else(|| anyhow!("..."))?; + .ok_or_else(|| anyhow!("no copyfiles section"))?; - // configparser makes all keys lowercase so all filenames - // in copyfiles directive sections become lowercase too ☹ + // 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. - assert_ne!(copyfiles.chars().next(), Some('@')); + if matches!(copyfiles.chars().next(), Some('@')) { + bail!("unsupported '@' syntax in copyfiles"); + } let field = field.trim(); @@ -176,7 +179,7 @@ fn resolve_paths( let entry: Vec<_> = k.split(',').map(|f| f.replace('\\', "/")).collect(); if entry.is_empty() { - bail!("empty entry?"); + bail!("empty entry in section={field}"); } if entry.len() == 1 { @@ -187,8 +190,6 @@ fn resolve_paths( } } - dbg!(&mappings); - let mut new = Vec::with_capacity(paths.len()); for p in paths { @@ -203,7 +204,9 @@ fn resolve_paths( Ok(new) } -/// Helper function for [`parse_inf_installer`]. This expands `Scheme.Reg` if needed. +/// 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();