Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 108 additions & 55 deletions src/cli.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf>,

/// 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.
Expand All @@ -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<ScalingAlgorithm>,

/// 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<ScalingAlgorithm>,

/// 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<f64>,

/// 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,
}
Expand Down Expand Up @@ -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<PathBuf>,
/// All installer files.
pub installer_files: Vec<PathBuf>,
/// All cursor files.
pub cursor_files: Vec<PathBuf>,
/// Installation is manual. Or not.
Expand Down Expand Up @@ -166,7 +159,7 @@ impl ParsedArgs {
pub fn from_args(args: Args) -> Result<Self> {
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 {
Expand All @@ -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",);
}
}

Expand Down Expand Up @@ -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,
Expand All @@ -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<CursorTheme> {
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::<Result<_>>()?;

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::<Result<_>>()?;

let theme = CursorTheme::new(typed_cursors, name)?;

Ok(theme)
}
4 changes: 2 additions & 2 deletions src/cursors/cursor_image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CursorImage>,
}
Expand Down Expand Up @@ -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<CursorImage> = LazyLock::new(|| CursorImage {
Expand Down
2 changes: 1 addition & 1 deletion src/cursors/generic_cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
6 changes: 3 additions & 3 deletions src/formats/ani.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -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);
Expand Down
26 changes: 15 additions & 11 deletions src/formats/crs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,27 +44,31 @@ fn section_to_type(section: &str) -> Option<CursorType> {
///
/// 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<Vec<CursorMapping>> {
let crs_string = fs::read_to_string(crs_path)?;
pub fn parse_crs_installer(crs_path: &Path) -> Result<Vec<CursorMapping>> {
// 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(&section) 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 });
}

Expand Down
Loading
Loading