Skip to content

Instantly share code, notes, and snippets.

@inxomnyaa
Last active April 27, 2026 08:25
Show Gist options
  • Select an option

  • Save inxomnyaa/b140dc6a3ce911745f47d863fd330220 to your computer and use it in GitHub Desktop.

Select an option

Save inxomnyaa/b140dc6a3ce911745f47d863fd330220 to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
# build_cursors.sh — Build XCursor themes from Windows .cur / .ani files
#
# Expected layout (relative to this script):
# Dark/
# *.cur *.ani
# Light/
# *.cur *.ani
#
# Any sub-directory that contains .cur or .ani files is treated as a theme variant.
# The variant folder name (e.g. "Dark") becomes part of the X11 theme name.
#
# Usage:
# ./build_cursors.sh # build only → ./themes/
# ./build_cursors.sh --install # build + install to ~/.local/share/icons/
#
# Dependencies (install in your distrobox):
# sudo dnf install icoutils xcursorgen
# python3 (stdlib only)
set -euo pipefail
# ── Config ────────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
THEMES_DIR="$SCRIPT_DIR/themes" # final output
ICONS_DIR="$HOME/.local/share/icons" # install destination
# Build dir lives in /tmp — keeps paths free of spaces that would break
# xcursorgen's config parser and bash word-splitting in loops.
BUILD_DIR="/tmp/cursor-build-$$"
trap 'rm -rf "$BUILD_DIR"' EXIT
THEME_PREFIX="Material Design Rounded"
THEME_SUFFIX="Cursors"
DO_INSTALL=false
for arg in "$@"; do [[ $arg == --install ]] && DO_INSTALL=true; done
# ── Cursor name mapping ───────────────────────────────────────────────────────
# Key = Windows filename stem, lowercase
# Value = space-separated X11/CSS cursor names
# First name → cursor file; rest → symlinks pointing to it
declare -A CURSOR_MAP=(
["pointer"]="default left_ptr arrow top_left_arrow x-cursor"
["beam"]="text xterm ibeam"
["busy"]="wait watch"
["working"]="progress left_ptr_watch half-busy"
["link"]="pointer hand hand1 hand2 pointing_hand openhand"
["help"]="help question_arrow whats_this"
["move"]="move fleur all-scroll size_all"
["unavailable"]="not-allowed crossed_circle forbidden no-drop"
["precision"]="crosshair cross diamond_cross tcross"
["alternate"]="alias context-menu copy"
["horz"]="ew-resize col-resize sb_h_double_arrow size_hor h_double_arrow w-resize e-resize"
["vert"]="ns-resize row-resize sb_v_double_arrow size_ver v_double_arrow n-resize s-resize"
["dgn1"]="nwse-resize size_fdiag fd_double_arrow nw-resize se-resize"
["dgn2"]="nesw-resize size_bdiag bd_double_arrow ne-resize sw-resize"
["handwriting"]="pencil"
["pin"]="dnd-none"
["person"]="draft"
)
# ── Helper: resolve X11 names for a Windows cursor stem ──────────────────────
resolve_x11_names() {
local stem_lower="${1,,}"
echo "${CURSOR_MAP[$stem_lower]:-$stem_lower}"
}
# ── Helper: extract PNGs from a .cur file ────────────────────────────────────
# Outputs one line per image: "size hotspot_x hotspot_y /abs/path/to.png"
# All paths are under /tmp so xcursorgen never sees a space in its .in files.
extract_cur_images() {
local cur_file="$1"
local work_dir="$2"
mkdir -p "$work_dir"
local listing
listing="$(icotool -l "$cur_file" 2>/dev/null)" || return 0
[[ -z "$listing" ]] && return 0
icotool -x "$cur_file" -o "$work_dir/" 2>/dev/null || return 0
local stem
stem="$(basename "${cur_file%.cur}")"
while IFS= read -r line; do
[[ -z "$line" ]] && continue
local idx w hx hy
idx="$(echo "$line" | grep -oP 'index=\K[0-9]+' | head -1)"
w="$( echo "$line" | grep -oP 'width=\K[0-9]+' | head -1)"
hx="$( echo "$line" | grep -oP 'hotspot-x=\K[0-9]+' | head -1)"
hy="$( echo "$line" | grep -oP 'hotspot-y=\K[0-9]+' | head -1)"
[[ -z "$idx" || -z "$w" ]] && continue
hx="${hx:-0}"; hy="${hy:-0}"
# icotool output pattern: stem_N_WxHxBB.png
local png
png="$(find "$work_dir" -maxdepth 1 -name "${stem}_${idx}_${w}x*.png" | head -1)"
[[ -f "$png" ]] && echo "$w $hx $hy $png"
done <<< "$listing"
}
# ── Build one theme variant ───────────────────────────────────────────────────
build_theme() {
local src_dir="$1" # e.g. /path/to/Dark (Copy)/Dark
local cursors_dir="$2" # e.g. ./themes/…/cursors
local work_dir="$3" # /tmp/cursor-build-PID/Dark (no spaces!)
mkdir -p "$cursors_dir" "$work_dir"
# ── Step 1: Extract .ani → per-frame .cur + timing sidecar ─────────────────
local ani_count
ani_count="$(find "$src_dir" -maxdepth 1 -name '*.ani' | wc -l)"
if [[ $ani_count -gt 0 ]]; then
echo " Extracting $ani_count .ani file(s)..."
python3 "$SCRIPT_DIR/extract_ani.py" "$src_dir" "$work_dir"
fi
declare -A from_ani=()
# ── Step 2: Animated cursors (from ANI-extracted frames) ───────────────────
while IFS= read -r -d '' ani_file; do
local stem
stem="$(basename "${ani_file%.ani}")"
local stem_lower="${stem,,}"
from_ani["$stem_lower"]=1
read -ra x11_names <<< "$(resolve_x11_names "$stem_lower")"
local primary="${x11_names[0]}"
local aliases=("${x11_names[@]:1}")
echo " [ANI] $stem → $primary (${#aliases[@]} alias(es))"
# Per-frame delays written by extract_ani.py
local timing_file="$work_dir/${stem}_timing.txt"
local -a delays=()
[[ -f "$timing_file" ]] && mapfile -t delays < "$timing_file"
local cfg="$work_dir/${primary}.in"
: > "$cfg"
local fi=0
# Use find + null terminator — safe even if paths contain spaces
while IFS= read -r -d '' frame_cur; do
local frame_work="$work_dir/frames/${stem}_f${fi}"
local delay="${delays[$fi]:-100}"
while IFS= read -r img_line; do
echo "$img_line $delay" >> "$cfg"
done < <(extract_cur_images "$frame_cur" "$frame_work")
(( fi++ )) || true
done < <(find "$work_dir" -maxdepth 1 -name "${stem}_frame_*.cur" -print0 | sort -zV)
if [[ ! -s "$cfg" ]]; then
echo " ⚠ no images found for $stem — skipping"
continue
fi
xcursorgen "$cfg" "$cursors_dir/$primary"
for alias in "${aliases[@]}"; do
ln -sf "$primary" "$cursors_dir/$alias" 2>/dev/null || true
done
done < <(find "$src_dir" -maxdepth 1 -name '*.ani' -print0 | sort -zV)
# ── Step 3: Static cursors (.cur files in source dir) ──────────────────────
while IFS= read -r -d '' cur_file; do
local stem
stem="$(basename "${cur_file%.cur}")"
local stem_lower="${stem,,}"
[[ -n "${from_ani[$stem_lower]:-}" ]] && continue # already done as animated
read -ra x11_names <<< "$(resolve_x11_names "$stem_lower")"
local primary="${x11_names[0]}"
local aliases=("${x11_names[@]:1}")
echo " [CUR] $stem → $primary (${#aliases[@]} alias(es))"
local cur_work="$work_dir/static/$stem"
local cfg="$work_dir/${primary}.in"
: > "$cfg"
while IFS= read -r img_line; do
echo "$img_line" >> "$cfg"
done < <(extract_cur_images "$cur_file" "$cur_work")
if [[ ! -s "$cfg" ]]; then
echo " ⚠ no images found for $stem — skipping"
continue
fi
xcursorgen "$cfg" "$cursors_dir/$primary"
for alias in "${aliases[@]}"; do
ln -sf "$primary" "$cursors_dir/$alias" 2>/dev/null || true
done
done < <(find "$src_dir" -maxdepth 1 -name '*.cur' -print0 | sort -zV)
}
# ── Main ──────────────────────────────────────────────────────────────────────
for cmd in icotool xcursorgen python3; do
if ! command -v "$cmd" &>/dev/null; then
echo "ERROR: '$cmd' not found."
echo " Install: sudo dnf install icoutils xcursorgen"
exit 1
fi
done
mkdir -p "$BUILD_DIR" "$THEMES_DIR"
found=false
# Discover variant folders — any direct subdirectory with .cur or .ani files
while IFS= read -r -d '' src_dir; do
n_cursors="$(find "$src_dir" -maxdepth 1 \( -name '*.cur' -o -name '*.ani' \) | wc -l)"
[[ $n_cursors -eq 0 ]] && continue
variant="$(basename "$src_dir")"
theme_name="$THEME_PREFIX $variant $THEME_SUFFIX"
theme_dir="$THEMES_DIR/$theme_name"
cursors_dir="$theme_dir/cursors"
# work_dir is under /tmp — guaranteed no spaces
work_dir="$BUILD_DIR/$variant"
echo ""
echo "━━━ Building: $theme_name ($n_cursors source file(s)) ━━━"
mkdir -p "$cursors_dir"
cat > "$theme_dir/index.theme" << EOF
[Icon Theme]
Name=$theme_name
Comment=$THEME_PREFIX $variant cursor theme (Linux port)
Inherits=default
EOF
build_theme "$src_dir" "$cursors_dir" "$work_dir"
cursor_count="$(find "$cursors_dir" -type f | wc -l)"
symlink_count="$(find "$cursors_dir" -type l | wc -l)"
echo " ✓ Done — $cursor_count cursor file(s), $symlink_count symlink(s)"
found=true
done < <(find "$SCRIPT_DIR" -mindepth 1 -maxdepth 1 -type d -print0 | sort -zV)
$found || { echo "ERROR: No theme variant folders found."; exit 1; }
# ── Install ───────────────────────────────────────────────────────────────────
if $DO_INSTALL; then
echo ""
echo "Installing themes to $ICONS_DIR ..."
mkdir -p "$ICONS_DIR"
while IFS= read -r -d '' theme_dir; do
theme_name="$(basename "$theme_dir")"
dest="$ICONS_DIR/$theme_name"
rm -rf "$dest"
cp -r "$theme_dir" "$dest"
echo " ✓ Installed: $theme_name"
done < <(find "$THEMES_DIR" -mindepth 1 -maxdepth 1 -type d -print0)
echo ""
echo "Activate with GNOME Tweaks → Cursors, or run:"
echo " gsettings set org.gnome.desktop.interface cursor-theme '$THEME_PREFIX Dark $THEME_SUFFIX'"
fi
# ── Summary ───────────────────────────────────────────────────────────────────
echo ""
echo "Output:"
find "$THEMES_DIR" -maxdepth 3 | sort

Windows to Bazzite/Fedora/GNOME Cursor Theme Converter

Converts Windows .cur / .ani cursor themes into XCursor themes for Linux (GNOME/X11/Wayland). Animated cursors are fully supported. Preferably run inside a distrobox. Tested with https://www.deviantart.com/arteffect10520/art/Material-Design-Rounded-Light-Cursors-966666167 and https://www.deviantart.com/arteffect10520/art/Material-Design-Rounded-Dark-Cursors-966666334


Distrobox Setup (recommended)

distrobox create --name cursor-tools
distrobox enter cursor-tools

Required Folder Structure

Place your Windows cursor files into named sub-folders next to the scripts. Each sub-folder becomes its own theme variant (the folder name is used as the theme name).

📁 your-cursor-theme/
├── build_cursors.sh
├── extract_ani.py
├── 📁 Dark/
│   ├── Pointer.cur
│   ├── beam.cur
│   ├── busy.ani
│   ├── working.ani
│   └── ... (all .cur / .ani files)
└── 📁 Light/
    ├── Pointer.cur
    ├── beam.cur
    ├── busy.ani
    ├── working.ani
    └── ... (all .cur / .ani files)

Any sub-folder containing .cur or .ani files is picked up automatically. You can have just Dark/, just Light/, or both — or any other variant name.


First Time Setup

sudo dnf install icoutils xcursorgen python3 python3-pip
chmod +x build_cursors.sh

Usage

Build only (output goes to ./themes/):

./build_cursors.sh

Build + install (copies themes to ~/.local/share/icons/):

./build_cursors.sh --install

Activating the Theme

After installing, apply via GNOME Tweaks → Appearance → Cursor, or run:

gsettings set org.gnome.desktop.interface cursor-theme 'Material Design Rounded Dark Cursors'

Notes

  • Build temp files go to /tmp/cursor-build-*/ and are cleaned up automatically.
  • The final themes are in ./themes/ and can be copied to /usr/share/icons/ for system-wide use.
#!/usr/bin/env python3
"""
extract_ani.py — Extract frames from Windows .ani cursor files.
Usage:
python3 extract_ani.py <source_dir> <output_dir>
For each .ani file in <source_dir>:
- Writes <output_dir>/<stem>_frame_NN.cur for every frame
- Writes <output_dir>/<stem>_timing.txt with one delay (ms) per line
"""
import struct
import sys
from pathlib import Path
def parse_ani(path: Path):
"""
Parse a Windows ANI (Animated Cursor) RIFF file.
Returns:
frames – list of bytes objects, one per frame (raw .cur data)
delays – list of ints, delay per frame in milliseconds
"""
data = path.read_bytes()
if data[:4] != b'RIFF' or data[8:12] != b'ACON':
print(f" [skip] {path.name}: not a valid ANI/ACON file", flush=True)
return [], []
display_rate = 3 # default display rate in jiffies (1 jiffy = 1/60 s)
per_frame_rates: list[int] = []
frames: list[bytes] = []
pos = 12
file_size = len(data)
while pos + 8 <= file_size:
fourcc = data[pos : pos + 4]
chunk_size = struct.unpack_from('<I', data, pos + 4)[0]
chunk_start = pos + 8
chunk_end = chunk_start + chunk_size
if fourcc == b'anih' and chunk_size >= 36:
# struct anih { DWORD cbSizeof, cFrames, cSteps, cx, cy,
# cBitCount, cPlanes, iDispRate, bfAttributes }
anih = struct.unpack_from('<9I', data, chunk_start)
display_rate = anih[7] # iDispRate in jiffies
elif fourcc == b'rate' and chunk_size >= 4:
n = chunk_size // 4
per_frame_rates = list(struct.unpack_from(f'<{n}I', data, chunk_start))
elif fourcc == b'LIST' and data[chunk_start : chunk_start + 4] == b'fram':
fpos = chunk_start + 4
while fpos + 8 <= chunk_end:
ffourcc = data[fpos : fpos + 4]
fsize = struct.unpack_from('<I', data, fpos + 4)[0]
if ffourcc == b'icon':
frames.append(data[fpos + 8 : fpos + 8 + fsize])
fpos += 8 + fsize
# RIFF chunks are word-aligned (padded to even size)
pos = chunk_end + (chunk_size & 1)
# Build per-frame delay list in milliseconds
delays = []
for i in range(len(frames)):
jiffies = per_frame_rates[i] if i < len(per_frame_rates) else display_rate
delays.append(max(1, round(jiffies * 1000 / 60)))
return frames, delays
def extract_ani(ani_path: Path, out_dir: Path) -> None:
stem = ani_path.stem
frames, delays = parse_ani(ani_path)
if not frames:
return
out_dir.mkdir(parents=True, exist_ok=True)
print(f" {ani_path.name}: {len(frames)} frame(s)", flush=True)
timing_lines = []
for i, (frame_data, delay_ms) in enumerate(zip(frames, delays)):
out_path = out_dir / f"{stem}_frame_{i:02d}.cur"
out_path.write_bytes(frame_data)
timing_lines.append(str(delay_ms))
print(f" [{i:02d}] {out_path.name} ({delay_ms} ms)", flush=True)
# Sidecar timing file — one integer (ms) per line
timing_path = out_dir / f"{stem}_timing.txt"
timing_path.write_text('\n'.join(timing_lines) + '\n')
def main():
if len(sys.argv) < 3:
print("Usage: extract_ani.py <source_dir> <output_dir>")
sys.exit(1)
src = Path(sys.argv[1])
out = Path(sys.argv[2])
ani_files = sorted(src.glob('*.ani'))
if not ani_files:
print(f"No .ani files found in {src}")
return
for ani_file in ani_files:
extract_ani(ani_file, out)
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment