|
#!/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 |