scripts call this by name
themeGenerator() {
- echo "Packaging: DefKorns"
- echo "Category: User Interface Mod "
- echo ""
- echo "Checking selected theme files."
- echo ""
+ translate CHECKING_THEME_FILES
theme_name="${1}"
- full_title="$(grep -F 'NAME=' "${omModSubCmds}/c0010_${theme_name}" | sed -e 's/.*=\(.*\)\[.*/\1/')"
- full_path="${theme_path}/${theme_name}"
+ failed=""
+ [ -d "${theme_path}" ] || mkdir -p "${theme_path}"
cd "${theme_path}" || exit
-
- theme_pkg="${themeURL}/${system}.${theme_name}.tar.gz"
- [ "${theme_name}" = "all" ] && theme_pkg="${downloadURL}/${sftype}-all"
-
- if wget -q --spider "${theme_pkg}" >/dev/null 2>&1; then
- echo "Downloading..."
- wget "${theme_pkg}"
- if [ "${theme_name}" = "all" ]; then
- while IFS= read -r t; do
- echo "$t"
- echo ""
- theme_pkg="${themeURL}/${system}.${t}.tar.gz"
- wget "${theme_pkg}"
- tar -xzvf "${system}.${t}.tar.gz" && remove "${system}.${t}.tar.gz"
- done <"${sftype}-all"
+ if [ ! -x "$om_scripts/downloader" ]; then
+ translate DOWNLOADER_MISSING_LINE1
+ translate DOWNLOADER_MISSING_LINE2
+ return 1
+ fi
+ trap abortDownload TERM INT HUP
+ translate DOWNLOADING
+
+ if [ "${theme_name}" = "all" ]; then
+ omDownload "${catalog_type}-all" "$themes_github_url/${catalog_type}-all" "$catalogs_classicmods_url/${catalog_type}-all"
+ rc=$?
+ if [ "$rc" = 0 ]; then
+ downloadThemes "${catalog_type}-all"
+ rc=$?
fi
- [ -f "${system}.${theme_name}.tar.gz" ] && tar -xzvf "${system}.${theme_name}.tar.gz" && rm -f ./*.tar.gz
- remove "${sftype}-all"
- remove_regional_theme
- echo ""
- size="$(du -sh "${full_path}" | cut -f1)"
- echo "NAME=${full_title}[${size}b]" >"${full_path}/info"
- echo "Installation complete."
+ remove "${catalog_type}-all"
else
- netChecker
+ downloadTheme "$theme_name"
+ rc=$?
+ [ "$rc" = 0 ] && writeThemeInfo "$theme_name"
+ fi
+
+ removeRegionalTheme
+ echo ""
+ case "$rc" in
+ 0) translate INSTALLATION_COMPLETE ;;
+ 4) translate ALL_THEMES_INSTALLED ;;
+ esac
+ if [ -n "$failed" ]; then
+ echo ""
+ translate THEMES_FAILED
+ echo "$failed"
+ return 1
+ fi
+ return "$rc"
+}
+
+urlEncodeName() {
+ printf '%s' "$1" | sed 's/%/%25/g; s/#/%23/g; s/?/%3F/g'
+}
+
+writeThemeInfo() {
+ info_title="$1"
+ info_cmd="${om_mod_sub_cmds}/c0010_$1"
+ [ -f "$info_cmd" ] || return 0
+ sed -i '/^NEW=TRUE$/d' "$info_cmd"
+ info_title="$(grep -F 'NAME=' "$info_cmd" | sed -e 's/.*=\(.*\)\[.*/\1/')"
+ echo "NAME=${info_title}[$(du -sh "$theme_path/$1" | cut -f1)b]" > "$theme_path/$1/info"
+}
+
+remoteTarball() {
+ echo "${system_name}.$(urlEncodeName "$1").tar.gz"
+}
+
+downloadTheme() {
+ remote="$(remoteTarball "$1")"
+ omDownload "${system_name}.$1.tar.gz" "$themes_github_url/$remote" "$themes_classicmods_url/$remote" || return
+ unpackTheme "$1"
+ rc=$?
+ downloadError "$rc"
+ return "$rc"
+}
+
+downloadThemes() {
+ pending="/tmp/${sftype}-pending"
+ while IFS= read -r t; do
+ { [ -d "$t" ] || [ "$t" = "$sfregion" ]; } && continue
+ remote="$(remoteTarball "$t")"
+ printf '%s\t%s\t%s\n' "${system_name}.$t.tar.gz" "$themes_github_url/$remote" "$themes_classicmods_url/$remote"
+ done < "$1" > "$pending"
+ if [ ! -s "$pending" ]; then
+ remove "$pending"
+ return 4
+ fi
+ omDownloadAll "$pending" installDownloaded 2
+ rc=$?
+ remove "$pending"
+ return "$rc"
+}
+
+installDownloaded() {
+ t="${2#"$system_name".}"
+ t="${t%.tar.gz}"
+ t_rc="$1"
+ if [ "$t_rc" = 0 ]; then
+ unpackTheme "$t" && writeThemeInfo "$t"
+ t_rc=$?
fi
+ [ "$t_rc" = 0 ] && echo "$info_title"
+ case "$t_rc" in
+ 0) ;;
+ 3) downloadError 3 && return 3 ;;
+ *) failed="$failed $t" ;;
+ esac
+}
+abortDownload() {
+ trap - TERM INT HUP
+ [ -n "$unpacking" ] && rm -rf "$unpacking" "$unpacking.png"
+ rm -f "$system_name".*.tar.gz "/tmp/${sftype}-pending" "${catalog_type}-all"
+ exit 1
}
-find_diy_asset() {
+unpackTheme() {
+ unpacking="$1"
+ omUnpack "${system_name}.$1.tar.gz" .
+ dl_rc=$?
+ [ "$dl_rc" = 0 ] || rm -rf "$1" "$1.png"
+ unpacking=""
+ return "$dl_rc"
+}
+
+#### DIY
+diyAssetSpec() {
diy_file="${1}.png"
- diy_sprite="$omModSprites/${1}"
+ diy_sprite="$om_mod_sprites/${1}"
asset_path="layout/asset/sprite/wallpaper"
asset="$diy_file"
chars="0"
colors="0"
+ preview_square="0"
+ preview_cols=""
+ preview_top="0"
+ theme_preview=""
json=""
- preview_x="920"
- preview_y="275"
- preview_w="250"
- preview_h="250"
case "$1" in
- luigi | mario)
- diy_file="${1}_run01.png"
- asset_path="layout/asset/sprite/${1}"
- asset="*.*"
- chars="1"
- preview_w="0"
- ;;
- sbm)
- diy_file="characters_sbm.png"
- asset_path="sprites"
- asset="characters_sbm.png"
- chars="1"
- preview_w="0"
- ;;
- packed)
- asset_path="sprites"
- preview_x="780"
- preview_w="0"
- preview_h="200"
- ;;
- packed_nes)
- diy_file="${sftype}.png"
- [ "$sfregion" = "jpn" ] && diy_file="hvc.png"
- asset_path="sprites"
- asset="$diy_file"
- preview_x="780"
- preview_w="0"
- preview_h="200"
- ;;
- colors)
- asset_path="scripts/env"
- diy_file="$1_$sfregion.lua"
- asset="$diy_file"
- colors="1"
- ;;
- colors_nes)
- asset_path="scripts"
- diy_file="${1}.lua"
- [ "$sfregion" = "jpn" ] && diy_file="colors_hvc.lua"
- asset="$diy_file"
- colors="1"
- ;;
- pole)
- diy_file="${1}01.png"
- asset="*.*"
- chars="1"
- preview_w="0"
- ;;
+ luigi | mario)
+ diy_file="${1}_run01.png"
+ asset_path="layout/asset/sprite/${1}"
+ asset="*.*"
+ chars="1"
+ preview_square="1"
+ preview_cols="5"
+ ;;
+ wall | wall_near | wall_far)
+ preview_square="1"
+ preview_cols="5"
+ ;;
+ sbm)
+ diy_file="characters_sbm.png"
+ asset_path="sprites"
+ asset="characters_sbm.png"
+ chars="1"
+ ;;
+ packed)
+ asset_path="sprites"
+ preview_square="1"
+ preview_cols="4"
+ preview_top="1"
+ theme_preview="extras/preview_baseui.png"
+ ;;
+ packed_nes)
+ diy_file="${sftype}.png"
+ [ "$sfregion" = "jpn" ] && diy_file="hvc.png"
+ asset_path="sprites"
+ asset="$diy_file"
+ preview_square="1"
+ preview_cols="4"
+ preview_top="1"
+ theme_preview="extras/preview_baseui.png"
+ ;;
+ colors)
+ asset_path="scripts/env"
+ diy_file="$1_$sfregion.lua"
+ asset="$diy_file"
+ colors="1"
+ ;;
+ colors_nes)
+ asset_path="scripts"
+ diy_file="${1}.lua"
+ [ "$sfregion" = "jpn" ] && diy_file="colors_hvc.lua"
+ asset="$diy_file"
+ colors="1"
+ ;;
+ pole)
+ diy_file="${1}01.png"
+ asset="*.*"
+ chars="1"
+ preview_square="1"
+ preview_cols="5"
+ ;;
esac
+}
+
+diyCropFrame() {
+ diy_crop_x=""
+ diy_sprites_json="$om_mod_scripts/om_${1}.sprites.json"
+ if [ "$colors" = "0" ] && [ -f "$diy_sprites_json" ]; then
+ diy_frame="$(grep -o '"textureFrame":\[[0-9]*,[0-9]*,[0-9]*,[0-9]*\]' "$diy_sprites_json" | grep -o '[0-9]*,[0-9]*,[0-9]*,[0-9]*')"
+ diy_crop_x="${diy_frame%%,*}"
+ diy_rest="${diy_frame#*,}"
+ diy_crop_y="${diy_rest%%,*}"
+ diy_rest="${diy_rest#*,}"
+ diy_crop_w="${diy_rest%%,*}"
+ diy_crop_h="${diy_rest#*,}"
+ fi
+}
+
+findDiyAsset() {
+ loadingSplash
- om_default_json="$omModScripts/om_${1}.json"
- paths_to_search="$omModSprites/assets/$1 $theme_path"
+ diyAssetSpec "$1"
+ diyCropFrame "$1"
+ paths_to_search="$om_mod_sprites/assets/$1 $theme_path"
find "$diy_sprite/commands" "$diy_sprite/scripts" -type f \
- -not -name 'om_*' -not -name 'c999[0-9]_*' \
- -not -name 'c0000_*' -not -name '_*' -print0 -exec rm {} \;
+ -not -name 'om_*' -not -name 'c999[0-9]_*' -not -name 'c0000_*' \
+ -not -name 'c0001_*' -not -name '_*' -exec rm {} +
+
+ existing_cmds="$(ls -1 "$diy_sprite/commands" 2>/dev/null)"
+ existing_scripts="$(ls -1 "$diy_sprite/scripts" 2>/dev/null)"
+ if [ "$preview_top" = "1" ] && [ -n "$existing_cmds" ]; then
+ grep -Lx "PREVIEW_ALIGN_TOP=TRUE" "$diy_sprite/commands"/c0001_* 2>/dev/null | while IFS= read -r old_cmd; do
+ echo "PREVIEW_ALIGN_TOP=TRUE" >> "$old_cmd"
+ done
+ fi
+ diy_crop_batch=0
+ diy_seen_hashes=""
for p in $paths_to_search; do
if [ "$(ls -A "$p")" ]; then
# shellcheck disable=SC2038
- cd "$p" && find . -mindepth 1 -maxdepth 1 -type d | xargs -n 1 basename | sort -u | while IFS= read -r asset_name; do
+ # a temp file, not a pipe: wait can't catch jobs in a pipe's subshell
+ cd "$p" && find . -mindepth 1 -maxdepth 1 -type d | xargs -n 1 basename | sort -u > /tmp/diy_asset_list
+ while IFS= read -r asset_name; do
has_sprite="0"
full_asset_path="$p/$asset_name"
- json="${omModSprites}/${1}_${asset_name}.json"
+ json="${om_mod_sprites}/${1}_${asset_name}.json"
resource_path="$full_asset_path/resources"
[ -f "$full_asset_path/$diy_file" ] && has_sprite="1"
[ -f "$resource_path/$asset_path/$diy_file" ] && has_sprite="1"
if [ $has_sprite = "1" ] && [ "$asset_name" = "${asset_name#diy_theme}" ]; then
- cmd_file="$diy_sprite/commands/c0000_$asset_name"
+ cmd_file="$diy_sprite/commands/c0001_$asset_name"
sprite_script="$diy_sprite/scripts/$asset_name"
- cmd_name="$(echo "$asset_name" | sed -e 's/_/ /g' | awk '{for(i=1;i<=NF;i++){$i=toupper(substr($i,1,1))substr($i,2)}}1')"
+ info_path="$full_asset_path"
- if [ "$p" = "$theme_path" ]; then
- cmd_name="$(sed -r 's/NAME=//g' <"$full_asset_path/info" | awk '{$NF="";sub(/[ \t]+$/,"")}1')"
- full_asset_path="$resource_path/$asset_path"
- fi
+ [ "$p" = "$theme_path" ] && full_asset_path="$resource_path/$asset_path"
preview_image="$full_asset_path/$diy_file"
- temp_preview="${omModSprites}/assets/${1}/${1}_${asset_name}.png"
+
+ if [ -f "$preview_image" ]; then
+ sprite_hash="$(md5sum "$preview_image" | cut -d' ' -f1)"
+ if printf '%s\n' "$diy_seen_hashes" | grep -qxF "$sprite_hash"; then
+ rm -f "$cmd_file" "$sprite_script.sh"
+ continue
+ fi
+ diy_seen_hashes="$diy_seen_hashes
+$sprite_hash"
+ fi
+
+ temp_preview="${om_mod_sprites}/assets/${1}/${1}_${asset_name}.png"
if [ "$colors" = "1" ]; then
preview_image="$temp_preview"
fi
[ "$chars" = "1" ] && temp_preview="$preview_image"
- if [ -n "$json" ] && [ ! -f "$temp_preview" ]; then
- decodepng "$omLoaders/loading.png" >/dev/fb0
- if [ "$colors" = "0" ]; then
- sed -r "s/changeme/${1}_${asset_name}/g;" "$om_default_json" >"$json"
- pngpack-upx u "$json" "$preview_image" "$omModSprites/assets/$1"
- remove "$json"
+ own_preview=""
+ [ -n "$theme_preview" ] && [ "$p" = "$theme_path" ] && [ -f "$info_path/$theme_preview" ] && own_preview="$info_path/$theme_preview"
+ if [ -n "$own_preview" ]; then
+ preview_image="$own_preview"
+ elif [ "$colors" = "0" ] && [ "$chars" != "1" ] && [ -n "$diy_crop_x" ]; then
+ if [ ! -f "$temp_preview" ]; then
+ sprite_crop "$preview_image" "$diy_crop_x" "$diy_crop_y" "$diy_crop_w" "$diy_crop_h" "$temp_preview" &
+ diy_crop_batch=$((diy_crop_batch + 1))
+ if [ "$diy_crop_batch" -ge 4 ]; then
+ wait
+ diy_crop_batch=0
+ fi
+ usleep 1000
fi
preview_image="$temp_preview"
- usleep 1000
fi
- if [ ! -f "$cmd_file" ]; then
+ if ! printf '%s\n' "$existing_cmds" | grep -qxF "c0001_$asset_name" || ! printf '%s\n' "$existing_scripts" | grep -qxF "$asset_name.sh" ||
+ { [ -n "$own_preview" ] && ! grep -qxF "PREVIEW_IMAGE=$own_preview" "$cmd_file"; }; then
touch "$cmd_file"
+ cmd_name="$(echo "$asset_name" | sed -e 's/_/ /g' | awk '{for(i=1;i<=NF;i++){$i=toupper(substr($i,1,1))substr($i,2)}}1')"
+ [ "$p" = "$theme_path" ] && cmd_name="$(sed -r 's/NAME=//g' < "$info_path/info" | awk '{$NF="";sub(/[ \t]+$/,"")}1')"
+
+ preview_nearest=""
+ [ "$chars" = "1" ] && preview_nearest="
+PREVIEW_NEAREST=TRUE"
+ preview_square_str=""
+ [ "$preview_square" = "1" ] && preview_square_str="
+PREVIEW_SQUARE=TRUE"
+ preview_cols_str=""
+ [ -n "$preview_cols" ] && preview_cols_str="
+PREVIEW_GRID_COLS=$preview_cols"
+ preview_top_str=""
+ [ "$preview_top" = "1" ] && preview_top_str="
+PREVIEW_ALIGN_TOP=TRUE"
+ preview_fit_str=""
+ [ "$chars" = "1" ] && preview_fit_str="
+PREVIEW_FIT_CONTAIN=TRUE
+PREVIEW_HIDE_LABEL=TRUE"
echo "COMMAND_NAME=$cmd_name
COMMAND_TYPE=INTERNAL
COMMAND_STR=sh $sprite_script.sh
-PREVIEW_IMAGE=$preview_image
-PREVIEW_IMAGE_X=$preview_x
-PREVIEW_IMAGE_Y=$preview_y
-PREVIEW_IMAGE_WIDTH=$preview_w
-PREVIEW_IMAGE_HEIGHT=$preview_h" >"$cmd_file"
+PREVIEW_IMAGE=$preview_image${preview_nearest}${preview_square_str}${preview_cols_str}${preview_top_str}${preview_fit_str}" > "$cmd_file"
+
+ copy "$om_mod_license" "$sprite_script.sh"
+ echo "diySpriteSelector \"$full_asset_path\" \"$asset_path\" \"$asset\"" >> "$sprite_script.sh"
fi
- copy "$omModLicense" "$sprite_script.sh"
- echo "diySpriteSelector \"$full_asset_path\" \"$asset_path\" \"$asset\"" >>"$sprite_script.sh"
fi
- done
+ done < /tmp/diy_asset_list
+ remove /tmp/diy_asset_list
fi
done
+ wait
}
diySpriteSelector() {
- diySelectedAsset="$1"
- diyLayout="$diyResources/$2"
- diyAsset="$diyLayout"
-
- [ ! -d "$diyAsset" ] && mkdir -p "$diyAsset"
- find "$diySelectedAsset" -name "$3" -exec cp {} "$diyLayout" \;
+ diy_selected_asset="$1"
+ diy_layout="$diy_resources/$2"
+ diy_asset="$diy_layout"
+
+ [ ! -d "$diy_asset" ] && mkdir -p "$diy_asset"
+ find "$diy_selected_asset" -name "$3" -exec cp {} "$diy_layout" \;
+ sel_category="$(basename "$(dirname "$(dirname "$0")")")"
+ mkdir -p "$diy_selection_path"
+ basename "$0" .sh > "$diy_selection_path/$sel_category"
if [ ! -f "$b_file" ]; then
- sed '1,2d' "$omThemeScript" >"$b_file"
- echo themeSelector >"$p_file"
+ sed '1,2d' "$om_theme_script" > "$b_file"
+ echo themeSelector > "$p_file"
fi
- echo ""
- echo " ____ _____ __ _____ _ _ _____ __ __ _____ ____ ____ _____ _ _____ ___ ____ "
- echo " | _ \\_ _\\ \\ / / |_ _| | | | ____| \\/ | ____| / ___| _ \\| ____| / \\|_ _/ _ \\| _ \\ "
- echo " | | | | | \\ V / | | | |_| | _| | |\\/| | _| | | | |_) | _| / _ \\ | || | | | |_) |"
- echo " | |_| | | | | | | | _ | |___| | | | |___ | |___| _ <| |___ / ___ \\| || |_| | _ < "
- echo " |____/___| |_| |_| |_| |_|_____|_| |_|_____| \\____|_| \\_\\_____/_/ \\_\\_| \\___/|_| \\_\\"
- echo " "
- echo " by: DefKorns "
- echo ""
- echo ""
- echo ""
- echo "Asset applied successfully! "
- echo ""
- echo ""
- echo "Don't forget to select 'Save custom theme' when you finish selecting your assets, or simply select"
- echo "'Preview custom theme', to have a preview before saving your theme."
- echo ""
- echo ""
}
diyThemeChecker() {
- [ "$1" = "loader" ] && decodepng "$omLoaders/loadingassets.png" >/dev/fb0
+ [ "$1" = "loader" ] && loadingSplash THEME_LOADING_ASSETS
+
+ [ ! -d "$diy_theme_path" ] && mkdir -p "$diy_theme_path"
- [ ! -d "$diyThemePath" ] && mkdir -p "$diyThemePath"
- create "$omModGenSpacer"
- if find "$diyThemePath" -type f | read -r; then
- enable_cmds "DiY" "DelDiY" "Preview"
+ if find "$diy_theme_path" -type f | read -r _; then
+ enableCmds "diy" "del_diy" "preview"
else
- disable_cmds "DiY" "DelDiY" "Preview"
+ disableCmds "diy" "del_diy" "preview"
fi
-
+
+ diyHomePreviews
+
[ "$1" = "loader" ] && usleep 1000
}
-diyThemeRemover() {
- disable_cmds "DiY" "DelDiY" "Preview"
+diyStockPreview() {
+ stock_preview=""
+ [ "$colors" = "1" ] && return
+ [ "$sftype" = "hvcj" ] && return
+ stock_file="$squashfs/usr/share/$original_theme_ui0/resources/$asset_path/$diy_file"
+ [ -f "$stock_file" ] || return
+
+ diyCropFrame "$1"
+ if [ -z "$diy_crop_x" ]; then
+ stock_preview="$stock_file"
+ return
+ fi
+ stock_crop="$om_mod_sprites/assets/$1/.stock.png"
+ [ -f "$stock_crop" ] || sprite_crop "$stock_file" "$diy_crop_x" "$diy_crop_y" "$diy_crop_w" "$diy_crop_h" "$stock_crop" || return
+ stock_preview="$stock_crop"
+}
- for item in $del_arr; do
- find "$item" -type f -not -name 'c999[0-9]_*' -not -name 'om_*' -maxdepth 1 -print0 -exec rm {} \;
- done
+diyHomePreviews() {
+ for home_cmd in "$om_mod_gen_cmds"/*; do
+ diy_category="$(sed -n 's/^COMMAND_STR=.*om_diy_asset \([a-z_]*\) .*/\1/p' "$home_cmd")"
+ [ -z "$diy_category" ] && continue
- [ -d "$diyThemePath" ] && find "$diyThemePath" -type f -not -name 'assets_here' -print0 -exec rm {} \;
+ diyAssetSpec "$diy_category"
+ picked_cmd=""
+ [ -f "$diy_selection_path/$diy_category" ] && picked_cmd="$om_mod_sprites/$diy_category/commands/c0001_$(cat "$diy_selection_path/$diy_category")"
+ if [ -n "$picked_cmd" ] && [ -f "$picked_cmd" ]; then
+ home_preview="$(sed -n 's/^PREVIEW_IMAGE=//p' "$picked_cmd")"
+ else
+ diyStockPreview "$diy_category"
+ home_preview="$stock_preview"
+ [ -z "$home_preview" ] && home_preview="$(sed -n 's/^DEFAULT_PREVIEW_IMAGE=//p' "$home_cmd")"
+ fi
+ [ -z "$home_preview" ] && home_preview="$om_mod_imgs/preview_default.png"
-}
+ home_nearest=""
+ [ "$chars" = "1" ] && home_nearest='\nPREVIEW_NEAREST=TRUE'
-modCleanUp() {
- for item in $items_to_delete; do
- remove "$item"
+ home_preview="$(printf '%s' "$home_preview" | sed 's/[\\]/\\&/g')"
+ sed -i -e '/^PREVIEW_NEAREST=/d' -e "s#^PREVIEW_IMAGE=.*#PREVIEW_IMAGE=$home_preview$home_nearest#" "$home_cmd"
done
- hasItemsToCleanUp "SystemCleanUp"
}
-themeSettings() {
- if [ "$1" = "tpf" ]; then
- themeSettingsChecker "$om_themeperfolder" "$enableThemePerFolder"
+diyThemeRemover() {
+ disableCmds "diy" "del_diy" "preview"
+ remove "$diy_selection_path"
+ diyHomePreviews
+
+ [ -d "$diy_theme_path" ] && find "$diy_theme_path" -type f -not -name 'assets_here' -exec rm {} +
+
+}
+
+#### SETTINGS
+toggleFlag() {
+ if [ -f "$1" ]; then
+ remove "$1"
else
- themeSettingsChecker "$om_themerandomizer" "$enableThemeSetting"
+ touch "$1"
fi
}
+themeSettings() {
+ case "$1" in
+ tpf) toggleFlag "$om_themeperfolder" ;;
+ mpf) toggleFlag "$om_musicperfolder" ;;
+ *) toggleFlag "$om_themerandomizer" ;;
+ esac
+}
+
+# music for every folder needs the random music on
audioSettings() {
if [ "$1" = "-amh" ]; then
- if [ ! -f "$om_advmusichack" ]; then
- audioSettingsChecker "on" "$om_advmusichack" "$enableAdvancedMusicHack"
- [ ! -f "$om_audiorandomizer" ] && audioSettingsChecker "on" "$om_audiorandomizer" "$enableAudioSetting"
- else
- remove "$om_advmusichack"
- switchOff "$enableAdvancedMusicHack"
- fi
+ toggleFlag "$om_advmusichack"
+ [ -f "$om_advmusichack" ] && touch "$om_audiorandomizer"
else
- if [ ! -f "$om_audiorandomizer" ]; then
- audioSettingsChecker "on" "$om_audiorandomizer" "$enableAudioSetting"
- else
- audioSettingsChecker "off" "$om_audiorandomizer" "$enableAudioSetting"
- audioSettingsChecker "off" "$om_advmusichack" "$enableAdvancedMusicHack"
- fi
+ toggleFlag "$om_audiorandomizer"
+ [ -f "$om_audiorandomizer" ] || remove "$om_advmusichack"
fi
}
-themeSettingsChecker() {
- if [ ! -f "$1" ]; then
- touch "$1"
- switchOn "$2"
- else
- remove "$1"
- switchOff "$2"
- fi
+hasCleanUpItems() {
+ local item
+ for item in $items_to_delete; do
+ [ -e "$item" ] && return 0
+ done
+ return 1
}
-audioSettingsChecker() {
- if [ "$1" = "off" ]; then
- remove "$2"
- switchOff "$3"
+modCleanUp() {
+ for item in $items_to_delete; do
+ remove "$item"
+ done
+ settingsRows
+}
+
+settingsRows() {
+ if hasCleanUpItems; then
+ enableCmds "system_clean_up"
+ else
+ disableCmds "system_clean_up"
+ fi
+ if [ -f "$b_file" ] && [ -f "$p_file" ]; then
+ enableCmds "uninstaller"
+ else
+ disableCmds "uninstaller"
+ fi
+ if [ -f "$enable_system_clean_up" ] || [ -f "$enable_uninstaller" ]; then
+ create "$om_mod_settings_spacer"
else
- touch "$2"
- switchOn "$3"
+ remove "$om_mod_settings_spacer"
fi
}
-hasItemsToCleanUp() {
- for item in $items_to_delete; do
- if [ -e "$item" ]; then
- has_non_system_files="true"
- break
- fi
- done
- if [ "$has_non_system_files" = "true" ]; then
- enable_cmds "$1"
- [ ! -f "$omModSettingsSpacer" ] && touch "$omModSettingsSpacer"
+musicPerFolder() {
+ [ -f "$om_musicperfolder" ] && hasMusicFiles "$music_path"
+}
+
+folderThemesRow() {
+ if [ -f "$om_themeperfolder" ] && [ -d "$has_folders" ]; then
+ enableCmds "folder_themes"
+ else
+ disableCmds "folder_themes"
+ fi
+ if hasMusicFiles "$music_path"; then
+ enableCmds "music_per_folder"
+ else
+ disableCmds "music_per_folder"
+ fi
+ if musicPerFolder && [ -d "$has_folders" ]; then
+ enableCmds "folder_music"
else
- disable_cmds "$1"
- [ ! -f "$enableUninstaller" ] && remove "$omModSettingsSpacer"
+ disableCmds "folder_music"
fi
}
-hasSelectedOptions() {
- if [ -f "$1" ]; then
- if ! grep -q "\[X\]" "$2"; then
- switchOn "$2"
- fi
- else
- switchOff "$2"
+# a folder entry living in games//CLV-S-00/ is folder inside folder
+listFolders() {
+ tab="$(printf '\t')"
+ find "$game_storage" -maxdepth 3 -type f -name 'CLV-S-00*.desktop' -exec awk '
+ /^Name=/ {
+ name = substr($0, 6)
+ if (name == "Back" || name == "Home") next
+ n = split(FILENAME, path, "/")
+ print path[n - 2] "\t" substr(path[n - 1], 9) "\t" name
+ }' {} + | sort -f -t "$tab" -k3,3 | awk -F "$tab" '
+ { kids[$1, ++count[$1]] = $2; names[$2] = $3 }
+ function walk(parent, depth, i, child, key) {
+ for (i = 1; i <= count[parent]; i++) {
+ child = kids[parent, i]
+ if (child in seen) continue
+ seen[child] = 1
+ key = names[child]
+ gsub(/ /, "_", key)
+ print tolower(key) "\t" names[child] "\t" depth
+ walk(child, depth + 1)
+ }
+ }
+ END { walk("000", 0) }'
+}
+
+themeTitle() {
+ title=""
+ [ -f "$theme_path/$1/info" ] && IFS= read -r title < "$theme_path/$1/info"
+ title="${title#NAME=}"
+ title="${title% [[]*]}"
+ [ -z "$title" ] && title="$1"
+}
+
+themePreview() {
+ preview="$thm_preview/$1.png"
+ [ ! -f "$preview" ] && preview="$theme_path/$1.png"
+ [ ! -f "$preview" ] && preview="$options_menu/images/preview_default.png"
+}
+
+globalTheme() {
+ if [ -f "$om_themerandomizer" ]; then
+ echo "@random"
+ elif [ -f "$b_file" ]; then
+ sed -n 's/^ *local selected_theme="\(.*\)"$/\1/p' "$b_file" | head -n1 | grep -v '^CHANGEME$'
fi
}
+
+folderThemesMenu() {
+ data_dir="$folder_themes_tmp"
+ rm -rf "$data_dir" && mkdir -p "$data_dir"
+ {
+ printf 'home\t%s\t0\n' "$(translate HOME_FOLDER)"
+ listFolders
+ } >"$data_dir/folders.list"
+ music_themes="$(find "$theme_path" -mindepth 2 -type f -name "*.wav" | sed "s#^$theme_path/##; s#/.*##" | sort -u)"
+ find "$theme_path" -mindepth 1 -maxdepth 1 -type d | sed 's#^.*/##' | sort | while IFS= read -r thm; do
+ has_music=0
+ echo "$music_themes" | grep -qxF "$thm" && has_music=1
+ needs_overlay=0
+ [ -f "$theme_path/$thm/needs_overlay" ] && needs_overlay=1
+ themeTitle "$thm"
+ themePreview "$thm"
+ printf '%s\t%s\t%s\t%s\t%s\n' "$thm" "$title" "$preview" "$has_music" "$needs_overlay"
+ done >"$data_dir/themes.list"
+ find "$music_path" -maxdepth 1 -type f -iname "*.wav" 2>/dev/null | sed 's#^.*/##' | sort | while IFS= read -r wav; do
+ printf '%s\t%s\n' "$wav" "$(echo "${wav%.*}" | sed 's/_/ /g')"
+ done >"$data_dir/music.list"
+ globalTheme >"$data_dir/global"
+ {
+ [ -f "$om_audiorandomizer" ] && echo random_home
+ [ -f "$om_advmusichack" ] && echo random_folders
+ } >"$data_dir/audio"
+ echo "sh $om_mod_scripts/om_settings" >"$data_dir/om_return"
+
+ title="FOLDER_THEMES"
+ [ "$1" = "music" ] && title="FOLDER_MUSIC"
+ set -- --commandPath "$data_dir/" --scriptPath "$data_dir" --title "$title" --folderThemes "$data_dir" --folderChannel "$1"
+ [ -f "$om_themeperfolder" ] && set -- "$@" --mapping "$om_folderthemes"
+ musicPerFolder && set -- "$@" --musicMapping "$om_foldermusic"
+ $options_menu/lib/theme_manager "$@" &
+}
diff --git a/mod/etc/options_menu/themes/scripts/om_wall.json b/mod/etc/options_menu/themes/scripts/om_wall.json
deleted file mode 100644
index 46f79e0..0000000
--- a/mod/etc/options_menu/themes/scripts/om_wall.json
+++ /dev/null
@@ -1 +0,0 @@
-{"texture": {"id": "1c75bca3-310d-48f1-95ad-20e7ebc294c6","width": 512,"height": 184},"sprites": {"changeme.png": {"textureFrame": [50,1,180,180],"pivot": [256,40],"sourceSize": [180,180],"sourceOffset": [0,0]}}}
\ No newline at end of file
diff --git a/mod/etc/options_menu/themes/scripts/om_wall_far.json b/mod/etc/options_menu/themes/scripts/om_wall_far.json
deleted file mode 100644
index c86bb9a..0000000
--- a/mod/etc/options_menu/themes/scripts/om_wall_far.json
+++ /dev/null
@@ -1 +0,0 @@
-{"texture":{"id":"b7e86fa6-e226-4058-a686-e71f7d01bcc6","width":512,"height":178},"sprites": {"changeme.png": {"textureFrame": [50,1,178,178],"pivot": [256,89],"sourceSize": [178,178],"sourceOffset": [0,0]}}}
\ No newline at end of file
diff --git a/mod/etc/options_menu/themes/scripts/om_wall_near.json b/mod/etc/options_menu/themes/scripts/om_wall_near.json
deleted file mode 100644
index 801c50f..0000000
--- a/mod/etc/options_menu/themes/scripts/om_wall_near.json
+++ /dev/null
@@ -1 +0,0 @@
-{"texture":{"id":"3384d6e1-fad7-4b8d-9a5e-38ea786aaf28","width":1024,"height":100},"sprites": {"changeme.png": {"textureFrame": [50,1,100,100],"pivot": [512,50],"sourceSize": [100,100],"sourceOffset": [0,0]}}}
\ No newline at end of file
diff --git a/mod/etc/options_menu/themes/settings/commands/_musicperfolder b/mod/etc/options_menu/themes/settings/commands/_musicperfolder
new file mode 100644
index 0000000..35d56e5
--- /dev/null
+++ b/mod/etc/options_menu/themes/settings/commands/_musicperfolder
@@ -0,0 +1,6 @@
+COMMAND_NAME=MUSIC_PER_FOLDER
+COMMAND_TYPE=INTERNAL
+RESTART_UI=FALSE
+COMMAND_STR=sh %script_dir%/om_toggle mpf
+STATE_STR=sh %script_dir%/om_state music_per_folder
+PREVIEW_IMAGE=/etc/options_menu/images/preview_music_per_folder.png
diff --git a/mod/etc/options_menu/themes/settings/commands/_musicperfolder_assign b/mod/etc/options_menu/themes/settings/commands/_musicperfolder_assign
new file mode 100644
index 0000000..283d5d2
--- /dev/null
+++ b/mod/etc/options_menu/themes/settings/commands/_musicperfolder_assign
@@ -0,0 +1,6 @@
+COMMAND_NAME=ASSIGN_FOLDER_MUSIC
+COMMAND_TYPE=EXTERNAL
+RESTART_UI=FALSE
+CHILD=TRUE
+COMMAND_STR=usleep 50000 && sh %script_dir%/om_folderthemes music &
+PREVIEW_IMAGE=/etc/options_menu/images/preview_assign_music.png
diff --git a/mod/etc/options_menu/themes/settings/commands/_themeperfolder_assign b/mod/etc/options_menu/themes/settings/commands/_themeperfolder_assign
new file mode 100644
index 0000000..e57bd0d
--- /dev/null
+++ b/mod/etc/options_menu/themes/settings/commands/_themeperfolder_assign
@@ -0,0 +1,6 @@
+COMMAND_NAME=ASSIGN_FOLDER_THEMES
+COMMAND_TYPE=EXTERNAL
+RESTART_UI=FALSE
+CHILD=TRUE
+COMMAND_STR=usleep 50000 && sh %script_dir%/om_folderthemes theme &
+PREVIEW_IMAGE=/etc/options_menu/images/preview_randtheme.png
diff --git a/mod/etc/options_menu/themes/settings/commands/c0000_themeperfolder b/mod/etc/options_menu/themes/settings/commands/c0000_themeperfolder
index 8783149..f5ab1e2 100644
--- a/mod/etc/options_menu/themes/settings/commands/c0000_themeperfolder
+++ b/mod/etc/options_menu/themes/settings/commands/c0000_themeperfolder
@@ -1,7 +1,6 @@
-COMMAND_NAME=Theme Per Folder
-COMMAND_TYPE=EXTERNAL
+COMMAND_NAME=THEME_PER_FOLDER
+COMMAND_TYPE=INTERNAL
RESTART_UI=FALSE
-COMMAND_STR=sh %script_dir%/om_theme tpf
-PREVIEW_IMAGE=/etc/options_menu/images/randtheme.png
-PREVIEW_IMAGE_X=920
-PREVIEW_IMAGE_Y=262
\ No newline at end of file
+COMMAND_STR=sh %script_dir%/om_toggle tpf
+STATE_STR=sh %script_dir%/om_state theme_per_folder
+PREVIEW_IMAGE=/etc/options_menu/images/preview_randtheme.png
diff --git a/mod/etc/options_menu/themes/settings/commands/c0000_themerandomizer b/mod/etc/options_menu/themes/settings/commands/c0000_themerandomizer
index 52b5916..96179d7 100644
--- a/mod/etc/options_menu/themes/settings/commands/c0000_themerandomizer
+++ b/mod/etc/options_menu/themes/settings/commands/c0000_themerandomizer
@@ -1,7 +1,6 @@
-COMMAND_NAME=Theme Randomizer
-COMMAND_TYPE=EXTERNAL
+COMMAND_NAME=THEME_RANDOMIZER
+COMMAND_TYPE=INTERNAL
RESTART_UI=FALSE
-COMMAND_STR=usleep 50000 && sh %script_dir%/om_theme &
-PREVIEW_IMAGE=/etc/options_menu/images/randtheme.png
-PREVIEW_IMAGE_X=920
-PREVIEW_IMAGE_Y=262
\ No newline at end of file
+COMMAND_STR=sh %script_dir%/om_toggle randomizer
+STATE_STR=sh %script_dir%/om_state theme_randomizer
+PREVIEW_IMAGE=/etc/options_menu/images/preview_theme_randomizer.png
diff --git a/mod/etc/options_menu/themes/creator/sprites/pole/commands/c9998_separator b/mod/etc/options_menu/themes/settings/commands/c0000_zseparator
similarity index 100%
rename from mod/etc/options_menu/themes/creator/sprites/pole/commands/c9998_separator
rename to mod/etc/options_menu/themes/settings/commands/c0000_zseparator
diff --git a/mod/etc/options_menu/themes/settings/commands/c0001_audiorandomizer b/mod/etc/options_menu/themes/settings/commands/c0001_audiorandomizer
index 81c1119..12e6126 100644
--- a/mod/etc/options_menu/themes/settings/commands/c0001_audiorandomizer
+++ b/mod/etc/options_menu/themes/settings/commands/c0001_audiorandomizer
@@ -1,7 +1,6 @@
-COMMAND_NAME=Audio Randomizer: Home
-COMMAND_TYPE=EXTERNAL
+COMMAND_NAME=AUDIO_RANDOMIZER_HOME
+COMMAND_TYPE=INTERNAL
RESTART_UI=FALSE
-COMMAND_STR=usleep 50000 && sh %script_dir%/om_audio &
-PREVIEW_IMAGE=/etc/options_menu/images/audiohome.png
-PREVIEW_IMAGE_X=920
-PREVIEW_IMAGE_Y=262
\ No newline at end of file
+COMMAND_STR=sh %script_dir%/om_toggle audio
+STATE_STR=sh %script_dir%/om_state audio_randomizer
+PREVIEW_IMAGE=/etc/options_menu/images/preview_audio_randomizer_home.png
diff --git a/mod/etc/options_menu/themes/settings/commands/c0001_audiorandomizerall b/mod/etc/options_menu/themes/settings/commands/c0001_audiorandomizerall
index 2ec6eb0..ffeccc0 100644
--- a/mod/etc/options_menu/themes/settings/commands/c0001_audiorandomizerall
+++ b/mod/etc/options_menu/themes/settings/commands/c0001_audiorandomizerall
@@ -1,7 +1,7 @@
-COMMAND_NAME= All Folders (Only Themes Without ANY Audio Files)
-COMMAND_TYPE=EXTERNAL
+COMMAND_NAME=AUDIO_RANDOMIZER_ALL_FOLDERS
+COMMAND_TYPE=INTERNAL
RESTART_UI=FALSE
-COMMAND_STR=usleep 50000 && sh %script_dir%/om_audio -amh &
-PREVIEW_IMAGE=/etc/options_menu/images/audioall.png
-PREVIEW_IMAGE_X=920
-PREVIEW_IMAGE_Y=262
\ No newline at end of file
+CHILD=TRUE
+COMMAND_STR=sh %script_dir%/om_toggle audio -amh
+STATE_STR=sh %script_dir%/om_state audio_randomizer_all
+PREVIEW_IMAGE=/etc/options_menu/images/preview_audio_randomizer_all.png
diff --git a/mod/etc/options_menu/themes/settings/commands/c0001_systemcleanup b/mod/etc/options_menu/themes/settings/commands/c0001_systemcleanup
index aeff326..8bac2bb 100644
--- a/mod/etc/options_menu/themes/settings/commands/c0001_systemcleanup
+++ b/mod/etc/options_menu/themes/settings/commands/c0001_systemcleanup
@@ -1,7 +1,5 @@
-COMMAND_NAME=Clean Up
+COMMAND_NAME=CLEAN_UP
COMMAND_TYPE=EXTERNAL
RESTART_UI=FALSE
COMMAND_STR=sh %script_dir%/om_systemcleanup &
-PREVIEW_IMAGE=/etc/options_menu/images/uninstall.png
-PREVIEW_IMAGE_X=920
-PREVIEW_IMAGE_Y=262
\ No newline at end of file
+PREVIEW_IMAGE=/etc/options_menu/images/preview_clean_up.png
diff --git a/mod/etc/options_menu/themes/settings/commands/c0001_uninstaller b/mod/etc/options_menu/themes/settings/commands/c0001_uninstaller
index 97c18a5..8d6f4d3 100644
--- a/mod/etc/options_menu/themes/settings/commands/c0001_uninstaller
+++ b/mod/etc/options_menu/themes/settings/commands/c0001_uninstaller
@@ -1,7 +1,5 @@
-COMMAND_NAME=Reset Settings
+COMMAND_NAME=RESET_SETTINGS
COMMAND_TYPE=INTERNAL
RESTART_UI=FALSE
COMMAND_STR=usleep 50000 && sh %script_dir%/om_reset
-PREVIEW_IMAGE=/etc/options_menu/images/uninstall.png
-PREVIEW_IMAGE_X=920
-PREVIEW_IMAGE_Y=262
\ No newline at end of file
+PREVIEW_IMAGE=/etc/options_menu/images/preview_uninstall.png
diff --git a/mod/etc/options_menu/themes/settings/commands/c9999_Back b/mod/etc/options_menu/themes/settings/commands/c9999_Back
deleted file mode 100644
index 15ce5c5..0000000
--- a/mod/etc/options_menu/themes/settings/commands/c9999_Back
+++ /dev/null
@@ -1,7 +0,0 @@
-COMMAND_NAME=Back
-COMMAND_TYPE=EXTERNAL
-RESTART_UI=FALSE
-COMMAND_STR=usleep 50000 && %options_path%/options --commandPath %options_path%/themes/commands/ --scriptPath %options_path%/themes/scripts --title "Theme Selector" &
-PREVIEW_IMAGE=/etc/options_menu/images/back.png
-PREVIEW_IMAGE_X=920
-PREVIEW_IMAGE_Y=262
\ No newline at end of file
diff --git a/mod/etc/options_menu/themes/scripts/om_restorethumb b/mod/etc/options_menu/themes/settings/scripts/om_folderthemes
similarity index 80%
rename from mod/etc/options_menu/themes/scripts/om_restorethumb
rename to mod/etc/options_menu/themes/settings/scripts/om_folderthemes
index d115b36..94c7826 100644
--- a/mod/etc/options_menu/themes/scripts/om_restorethumb
+++ b/mod/etc/options_menu/themes/settings/scripts/om_folderthemes
@@ -1,6 +1,7 @@
#!/bin/sh
+# shellcheck disable=SC2086,SC2154,SC3046,SC3051
#
-# Copyright (c) 2022 DefKorns (https://defkorns.github.io/LICENSE)
+# Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -15,9 +16,8 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
+# shellcheck source=/dev/null
source $mountpoint/etc/options_menu/themes/scripts/om_vars
script_init
-find "$gStorage" -path "*_small.png*" | while read -r file; do
- copier "$file"_backup "$file" && rm "$file"_backup
-done
+folderThemesMenu "${1:-theme}"
diff --git a/mod/etc/options_menu/themes/settings/scripts/om_reset b/mod/etc/options_menu/themes/settings/scripts/om_reset
index 0439865..ab31691 100644
--- a/mod/etc/options_menu/themes/settings/scripts/om_reset
+++ b/mod/etc/options_menu/themes/settings/scripts/om_reset
@@ -1,25 +1,33 @@
#!/bin/sh
+# shellcheck disable=SC2086,SC2154,SC3046,SC3051
+#
+# Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
#
-# Copyright (c) 2022 DefKorns (https://defkorns.github.io/LICENSE)
-#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
-#
+#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
-#
+#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-# shellcheck disable=SC2154,SC1090,SC2039,SC2086
+# shellcheck source=/dev/null
source $mountpoint/etc/options_menu/themes/scripts/om_vars
script_init
+font_path="$mountpoint/media/$modname/fonts"
+[ ! -d "$font_path" ] && font_path="$rootfs/usr/share/fonts"
+[ -f "${font_path}/title.fnt.backup" ] && cp "${font_path}/title.fnt.backup" "${font_path}/title.fnt"
+for font_name in s m l small medium large; do
+ rm -f "${font_path}/${font_name}.fnt"
+done
+
remove "$b_file" "$p_file"
hakchi reboot
-$optionsMenu/options --commandPath $omModSettingsCmds/ --scriptPath $omModSettingsScripts --title "$omTitle" &
+$options_menu/lib/theme_manager --commandPath $om_mod_settings_cmds/ --scriptPath $om_mod_settings_scripts --title THEME_SETTINGS --layout list &
diff --git a/mod/etc/options_menu/themes/settings/scripts/om_state b/mod/etc/options_menu/themes/settings/scripts/om_state
new file mode 100644
index 0000000..3154124
--- /dev/null
+++ b/mod/etc/options_menu/themes/settings/scripts/om_state
@@ -0,0 +1,35 @@
+#!/bin/sh
+# shellcheck disable=SC2086,SC2154,SC3046,SC3051
+#
+# Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+# on/off state of a Settings toggle; reads the path om_settings cached
+mod_state_path="$(cat /tmp/om_state_path 2> /dev/null)"
+if [ -z "$mod_state_path" ]; then
+ # shellcheck source=/dev/null
+ source $mountpoint/etc/options_menu/scripts/om_functions
+ script_init
+fi
+
+case "$1" in
+ theme_per_folder) flag="$mod_state_path/om_themeperfolder" ;;
+ music_per_folder) flag="$mod_state_path/om_musicperfolder" ;;
+ theme_randomizer) flag="$mod_state_path/om_themerandomizer" ;;
+ audio_randomizer) flag="$mod_state_path/om_audiorandomizer" ;;
+ audio_randomizer_all) flag="$mod_state_path/om_advmusichack" ;;
+esac
+
+[ -f "$flag" ] && echo on || echo off
diff --git a/mod/etc/options_menu/themes/settings/scripts/om_systemcleanup b/mod/etc/options_menu/themes/settings/scripts/om_systemcleanup
index 498fbb6..85656c0 100644
--- a/mod/etc/options_menu/themes/settings/scripts/om_systemcleanup
+++ b/mod/etc/options_menu/themes/settings/scripts/om_systemcleanup
@@ -1,6 +1,6 @@
#!/bin/sh
-# shellcheck disable=SC1090,SC1091,SC2039,SC2086,SC2154,SC3046
-# Copyright (c) 2022 DefKorns (https://defkorns.github.io/LICENSE)
+# shellcheck disable=SC2086,SC2154,SC3046,SC3051
+# Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -15,13 +15,14 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
+# shellcheck source=/dev/null
source $mountpoint/etc/options_menu/themes/scripts/om_vars
script_init
-decodepng "$omLoaders/loading.png" >/dev/fb0
+loadingSplash
modCleanUp
usleep 1000
-$optionsMenu/options --commandPath $omModSettingsCmds/ --scriptPath $omModSettingsScripts --title "$omTitle" &
+$options_menu/lib/theme_manager --commandPath $om_mod_settings_cmds/ --scriptPath $om_mod_settings_scripts --title THEME_SETTINGS --layout list &
diff --git a/mod/etc/options_menu/themes/settings/scripts/om_theme b/mod/etc/options_menu/themes/settings/scripts/om_toggle
similarity index 75%
rename from mod/etc/options_menu/themes/settings/scripts/om_theme
rename to mod/etc/options_menu/themes/settings/scripts/om_toggle
index bd05f99..275a52d 100644
--- a/mod/etc/options_menu/themes/settings/scripts/om_theme
+++ b/mod/etc/options_menu/themes/settings/scripts/om_toggle
@@ -1,6 +1,7 @@
#!/bin/sh
+# shellcheck disable=SC2086,SC2154,SC3046,SC3051
#
-# Copyright (c) 2022 DefKorns (https://defkorns.github.io/LICENSE)
+# Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -15,10 +16,12 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-# shellcheck disable=SC2154,SC1090,SC2039,SC2086
+# shellcheck source=/dev/null
source $mountpoint/etc/options_menu/themes/scripts/om_vars
script_init
-themeSettings "$1"
-
-$optionsMenu/options --commandPath $omModSettingsCmds/ --scriptPath $omModSettingsScripts --title "$omTitle" &
+case "$1" in
+ audio) audioSettings "$2" ;;
+ *) themeSettings "$1" ;;
+esac
+folderThemesRow
diff --git a/mod/install b/mod/install
index 1907cbe..d68cf9c 100644
--- a/mod/install
+++ b/mod/install
@@ -1,6 +1,6 @@
#!/bin/sh
# shellcheck disable=SC2154
-# Copyright (c) 2022 DefKorns (https://defkorns.github.io/LICENSE)
+# Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -32,6 +32,33 @@ hmod_dir="$mountpoint/var/lib/hakchi/hmod"
b_file="$pre_initd/b8024_themeselector"
p_file="$pre_initd/p8024_themeselector"
+# Uninstall any previous or legacy install (Theme Randomizer, Advanced Music Hack)
+ts_state="$mountpoint/$profilepath/$modname"
+ts_backup="$installpath/om_ts_backup"
+rm -rf "$ts_backup"
+mkdir -p "$ts_backup/state"
+for ts_dir in themes music_menu; do
+ [ -d "$usr_share/$ts_dir" ] && mv "$usr_share/$ts_dir" "$ts_backup/"
+done
+for ts_file in lastTheme prevTheme active_theme folder_themes folder_music; do
+ [ -f "$ts_state/$ts_file" ] && cp -p "$ts_state/$ts_file" "$ts_backup/state/"
+done
+for ts_unfile in "$hmod_dir/uninstall-"*andomiz* \
+ "$hmod_dir/uninstall-advanced_music_hack" \
+ "$hmod_dir/uninstall-om_theme-selector"* \
+ "$hmod_dir/uninstall-om_theme_selector"*; do
+ [ -f "$ts_unfile" ] && pack_uninstall "${ts_unfile##*/uninstall-}"
+done
+cd "$transferpath" || return 1
+for ts_dir in themes music_menu; do
+ [ -d "$ts_backup/$ts_dir" ] && rm -rf "${usr_share:?}/$ts_dir" && mv "$ts_backup/$ts_dir" "$usr_share/"
+done
+mkdir -p "$ts_state"
+for ts_file in "$ts_backup/state/"*; do
+ [ -f "$ts_file" ] && cp -p "$ts_file" "$ts_state/"
+done
+rm -rf "$ts_backup"
+
mkdir -p "$usr_share/music_menu"
move "$usr_share/menu_music"/* "$usr_share/music_menu"
move "$usr_share/advanced_music_hack/bgm_boot.wav" "$usr_share/themes/bgm_boot.wav"
@@ -46,20 +73,46 @@ del "$rootfs/fontfix"
del "$rootfs/chmenu"
del "$rootfs/chmenu_backup"
del "$rootfs/b8025_fontfix_backup"
+del "$ts_state/om_themeperfolderchild"
-find "$hmod_dir" -type f -name "*andomiz*" -print0 -exec rm -f {} \;
-find "$hmod_dir" -type f -name "*advanced_music_hack" -print0 -exec rm -f {} \;
# Marks this mod as uninstalled
unset cfg_theme_randomizR_enabled
transfer_default
-
-om_firstrun="$mountpoint/$profilepath/$modname/om_firstrun"
+# saves aren't mounted yet during install - om_theme_selector resets settings on first run
+om_firstrun="$rootfs/etc/options_menu/themes/om_firstrun"
[ ! -f "$om_firstrun" ] && touch "$om_firstrun"
-copy "$om_themes_scripts/om_fontfix" "$pre_initd/b8025_fontfix"
-copy "$om_themes_scripts/om_chmenu" "$rootfs/bin/chmenu"
-chmod +x "$rootfs/bin/chmenu"
-chmod +x "$usr/bin/pngpack-upx"
+for legacy_img in back chip_apply chip_clear chip_diy chip_download chip_preview chip_refresh chip_settings clear colors_nes default_preview diy_preview luigi mario packed packed_nes pole randtheme reboot sbm settings uninstall update wall wall_far wall_near; do
+ del "$rootfs/etc/options_menu/images/$legacy_img.png"
+done
+find "$rootfs/etc/options_menu/themes/creator/sprites" -path '*/commands/c0001_*' -type f -exec rm {} + 2>/dev/null
+del "$rootfs/etc/options_menu/themes/settings/commands/c0000_themeperfolder_1assign"
+ts_root="etc/options_menu/themes"
+for shipped_cmd in "$transferpath/$ts_root/commands/"* "$transferpath/$ts_root/"*/commands/*; do
+ cmd_name="${shipped_cmd##*/}"
+ cmd_dir="${shipped_cmd%/*}"
+ cmd_dir="$rootfs/${cmd_dir#"$transferpath/"}"
+ case "$cmd_name" in
+ _*) for renamed_cmd in "$cmd_dir"/c[0-9][0-9][0-9][0-9]"$cmd_name"; do del "$renamed_cmd"; done ;;
+ c[0-9][0-9][0-9][0-9]_*) del "$cmd_dir/_${cmd_name#*_}" ;;
+ esac
+done
+chmod +x "$rootfs/bin/sprite_crop"
+del "$rootfs/bin/theme_downloader"
+chmod +x "$rootfs/etc/options_menu/lib/theme_manager"
+
+theme_cfg="$rootfs/etc/options_menu/theme.cfg"
+if [ -f "$theme_cfg" ] && ! grep -q "^ActiveThemeBorder=" "$theme_cfg"; then
+ echo "ActiveThemeBorder=76,175,110" >> "$theme_cfg"
+fi
+
+# the previous version's uninstall strips the chmenu hook; at boot the rootfs write shows at once
+if ! grep -q "# themeselector fix" "$rootfs/bin/chmenu"; then
+ # shellcheck disable=SC1004
+ sed -i '/^overmount_games.*/a # themeselector fix \
+themeSelector \
+fontFix' "$rootfs/bin/chmenu"
+fi
sed '1,2d' "$om_themes_scripts/om_themescript" >"$b_file"
[ ! -f "$p_file" ] && echo themeSelector >"$p_file"
diff --git a/mod/readme.md b/mod/readme.md
index 176355b..a467b15 100644
--- a/mod/readme.md
+++ b/mod/readme.md
@@ -1,75 +1,76 @@
# Options Menu - Theme Selector
-**This modification is only for use with [CompCom's](https://github.com/CompCom) Options Menu.**
-### HMOD NOT COMPATIBLE WITH EXTERNAL UI.
PREVIOUS VERSIONS OF THEME RANDOMIZER AND ADVANCED MUSIC HACK WILL BE UNINSTALLED.
-### What is it?
-This [Options Menu](https://github.com/CompCom/OptionsMenu/releases/latest) module gives you the possibility to choose your favorite theme and apply it on your NESCE or SNESCE.
-It includes all the features of it's predecessor and much more
+**Requires [my Options Menu fork](https://github.com/DefKorns/OptionsMenu/releases) as a base — not compatible with any other UI.**
-[](https://i.imgur.com/qyTv9Fs.png)
-### Features
-* Graphical interface to allow you to select themes
-* Download themes directy from the internet (Wi-Fi mod required)
-* Theme randomizer (Off by default)
-* Audio randomizer on home folder (Off by default)
-* Set theme per folder (keeping the same the per sub-folder) (**Off by default**)
-* Create a theme based your existent theme resources
-* Custom fonts per theme
-* Select a theme for the entire system
-* Select a theme just for the home folder
-* Custom icons for Option Menu
+[](https://youtu.be/3UilWr1NvFA)
-### Can i use it with NAND
-Well, yes. But this mod was designed for USB-HOST, as the themes may include audio files and they may get over 10mb.
+## What is it?
-### Set theme per folder, what is that?
-If you are like me and have tons of games organized with tons of system folders... Wouldn't be nice if you could have a theme associated with each system? Now you can, just create your own theme and add it to the HMOD or directly to your USB/SD theme folder.
-On this version all subfolders, will keep the parent theme.
+A graphical theme manager for the NES, SNES, Famicom and Super Famicom Classic consoles. Pick a theme from a preview grid, download more from an online catalog, build your own from pieces of the themes you already have, and give each folder its own theme and music — all from the console itself.
-### Ok and how will the system load the theme?
-Just give it the same name as your folder (lower snake_case). Eg: game folder: `Nintendo - Nintendo Entertainment System` theme folder: `nintendo_-_nintendo_entertainment_system`.
+Open the Options Menu (hold **L+R** on a SNES/Super Famicom, **B+Down** on a NES/Famicom) and go to **Themes**.
-### What if i want a specific theme on my main menu?
-Add a theme as before but name it `default` in order to make it you home menu theme.
-That simple.
+Full guides: [github.com/DefKorns/om_theme-selector/wiki](https://github.com/DefKorns/om_theme-selector/wiki)
-### I own a Famicom/Shonen/Super Famicom, can i install this?
-The mod is compatible with all Nintendo Classic consoles including all regions.
+## Features
-### What do you mean with "Create a theme based your existent theme resources"?
-Exactly that! If you have several themes on your console/usb, why not take advantage of their resources and create your own DIY theme?
-Simply select from the listed previews and create your own DIY theme.
+- **Installed themes grid** — browse your themes by their preview and apply one; the active theme is outlined
+- **Download themes** from an online catalog of 90+ themes, each credited to its author (**Wi-Fi mod required**)
+- **DIY theme** — mix the UI, background and demo sprites (or background color, on a NES) of the themes you already have, preview it live, then save it as a theme of your own
+- **Theme and music per folder** — give any folder (and Home) its own theme and its own menu music from one screen; folders without one inherit their parent's
+- **Theme randomizer** — a different theme every time you change folder (off by default)
+- **Audio randomizer** on Home, optionally on every folder (off by default)
+- Custom fonts per theme
+- Delete a theme from the grid by holding **B**
+- **Clean Up** removes the theme files your console type doesn't use
+- **Reset Settings** puts the mod back to its defaults without uninstalling it or deleting your themes
-### Requirements
-* [Hakchi CE v3.5.0 >=](https://github.com/TeamShinkansen/hakchi2/releases/latest)
-* [Options Menu v.1.3.4 >=](https://github.com/CompCom/OptionsMenu/releases/latest)
-* [Hakchi Wi-Fi mod (WPA Supplicant)](https://hakchi.net/hakchi/hmods/wpa-supplicant.hmod)
+## Getting themes
-### How do i use it
+**From the console** (needs the Wi-Fi mod): go to **Themes → Download Themes**, select **Update Theme List**, then pick a theme and press **A**; a progress bar shows the download. Back in **Installed Themes**, select it and press **A** to apply it.
-**NAND or USB/SD**
+**By hand:** copy a theme folder, or a catalog package such as `SNES..tar.gz` from [om-theme-downloads](https://github.com/DefKorns/om-theme-downloads/releases/tag/themes-v1), into your themes folder (see below), then open **Themes**: packages are unpacked and new themes added on their own.
-- Install Hmod
-- Download the themes and enjoy
+## Theme and music per folder
-**NAND** themes will be on `/var/lib/hakchi/usr/share/themes/your-system`
+Turn on **Theme Per Folder** in **Settings**, then open **Assign Theme to Folder** right below it. Your folders are listed as a tree: press Left/Right on a folder to cycle through the themes, or A to pick one from a list. **Automatic** clears a folder's assignment, so it uses its parent folder's theme (a top-level folder uses the console's theme). The same theme can go on as many folders as you like.
-**USB/SD** themes will be on `/media/hakchi/themes/your-system`
+**Music Per Folder** works the same way with the `.wav` files in `music_menu`, with or without Theme Per Folder.
-> **Note:** Where `your-system` changes depending on own mini `nes`, `snes` or `shonen`
+A theme named after a folder (lower snake_case, e.g. `nintendo_-_nintendo_entertainment_system`) or named `default` (for Home) still works too.
-### Notes
-Enjoy it as much as i did making it.
+## Requirements
-### Credits
-- [DefKorns](https://gitlab.com/DefKorns)
-- [DanTheMan827](https://github.com/DanTheMan827) (packed.png extractor)
+- [Hakchi2 CE](https://github.com/TeamShinkansen/hakchi2/releases/latest)
+- [My Options Menu fork](https://github.com/DefKorns/OptionsMenu/releases) v2.1.0 or newer
+- Hakchi Wi-Fi mod (WPA Supplicant), from KMFDManic's store in hakchi — only to download themes from the console
+ - Recommended adapter: TP-Link TL-WN725N (Realtek 8188 chipset). Other adapters may work, but aren't guaranteed to.
+- Font Remount mod (`font_remount`), one of hakchi's built-in mods — applies the theme fonts
+- A USB/SD drive is recommended: themes are often 5–30 MB each
+
+## Where things live
+
+| | USB/SD | NAND |
+| --- | --- | --- |
+| Themes | `/media/hakchi/themes/` | `/var/lib/hakchi/rootfs/usr/share/themes/` |
+| Menu music | `/media/hakchi/music_menu` | `/var/lib/hakchi/rootfs/usr/share/music_menu` |
+
+`` is `nes`, `snes` or `shonen`. With a USB/SD drive connected, themes always go there.
+
+## Credits
+
+- [DefKorns](https://github.com/DefKorns)
+- [DanTheMan827](https://github.com/DanTheMan827) — packed.png extractor
+- Theme authors — credited on each theme in the download catalog
+
+## Thanks
-### Thanks
- AluCarD
+- Aranthys
+- AxelStone
- [BsLeNuL](https://github.com/bslenul)
-- [CompCom](https://github.com/CompCom)
+- [CompCom](https://github.com/CompCom) — original Options Menu
- [KMFDManic](https://github.com/KMFDManic)
- [ModMyClassic](https://modmyclassic.com/)
- NESminiling0618
-- [Swingflip](https://github.com/swingflip)
\ No newline at end of file
+- [Swingflip](https://github.com/swingflip)
diff --git a/mod/uninstall b/mod/uninstall
index 8fec0f0..4322dad 100644
--- a/mod/uninstall
+++ b/mod/uninstall
@@ -1,7 +1,7 @@
#!/bin/sh
# shellcheck disable=SC2154,SC2068
#
-# Copyright (c) 2022 DefKorns (https://defkorns.github.io/LICENSE)
+# Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -16,98 +16,125 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-remove() {
+removeItem() {
for f in $@; do
[ -f "$f" ] || [ -d "$f" ] && rm -rf "$f"
done
}
-FONT_PATH="$rootfs/usr/share/fonts"
-TITLE_FONT="$FONT_PATH/title.fnt"
-TITLE_FONT_BACKUP="${TITLE_FONT}.backup"
-UI0="ui/$sftype-$sfregion"
-UI1=""
+font_path="$rootfs/usr/share/fonts"
+ui_dir="ui/$sftype-$sfregion"
+ui_subdir=""
case "$sftype" in
hvcj)
- UI0="ui/hvc"
+ ui_dir="ui/hvc"
;;
nes)
- UI0="clover-ui"
- UI1="nes/"
+ ui_dir="clover-ui"
+ ui_subdir="nes/"
if [ "$sfregion" = "jpn" ]; then
- UI0="clover-ui"
- UI1="hvc"
+ ui_dir="clover-ui"
+ ui_subdir="hvc"
fi
;;
snes)
- [ "$sfregion" = "jpn" ] && UI0="ui/shvc"
+ [ "$sfregion" = "jpn" ] && ui_dir="ui/shvc"
;;
esac
-ORIGINAL_THEME="$mountpoint/usr/share/$UI0"
+original_theme="$mountpoint/usr/share/$ui_dir"
-copy "$rootfs/etc/options_menu/themes/scripts/om_chmenu_og" "$rootfs/bin/chmenu"
-copy "$rootfs/etc/options_menu/themes/scripts/om_fontfix_og" "$rootfs/etc/preinit.d/b8025_fontfix"
-remove "$mountpoint/$profilepath/$modname/lastTheme"
-remove "$mountpoint/$profilepath/$modname/prevTheme"
-remove "$mountpoint/$profilepath/$modname/activeTheme"
-remove "$mountpoint/$profilepath/$modname/om_advmusichack"
-remove "$mountpoint/$profilepath/$modname/om_audiorandomizer"
-remove "$mountpoint/$profilepath/$modname/om_themerandomizer"
-remove "$mountpoint/$profilepath/$modname/om_firstrun"
-remove "$rootfs/usr/share/themes"
-remove "$rootfs/usr/share/music_menu"
+sed -i '/# themeselector fix *$/,+2d' "$rootfs/bin/chmenu"
+sed -i '/# themeselector$/,+4d' "$rootfs/etc/preinit.d/b8025_fontfix"
+removeItem "$mountpoint/$profilepath/$modname/lastTheme"
+removeItem "$mountpoint/$profilepath/$modname/prevTheme"
+removeItem "$mountpoint/$profilepath/$modname/active_theme"
+removeItem "$mountpoint/$profilepath/$modname/folder_themes"
+removeItem "$mountpoint/$profilepath/$modname/folder_music"
+removeItem "$mountpoint/$profilepath/$modname/om_advmusichack"
+removeItem "$mountpoint/$profilepath/$modname/om_audiorandomizer"
+removeItem "$mountpoint/$profilepath/$modname/om_themerandomizer"
+removeItem "$mountpoint/$profilepath/$modname/om_firstrun"
+removeItem "$rootfs/usr/share/themes"
+removeItem "$rootfs/usr/share/music_menu"
chmod +x "$rootfs/bin/chmenu"
-remove "$rootfs/etc/preinit.d/b8024_themeselector"
-remove "$rootfs/etc/preinit.d/p8024_themeselector"
-remove "$rootfs/etc/options_menu/commands/c0090_ThemeOptions"
-remove "$rootfs/etc/options_menu/scripts/om_themeselector"
-remove "$rootfs/etc/options_menu/themes"
-remove "$rootfs/etc/options_menu/images/audioall.png"
-remove "$rootfs/etc/options_menu/images/audiohome.png"
-remove "$rootfs/etc/options_menu/images/back.png"
-remove "$rootfs/etc/options_menu/images/clear.png"
-remove "$rootfs/etc/options_menu/images/colors_nes.png"
-remove "$rootfs/etc/options_menu/images/default_preview.png"
-remove "$rootfs/etc/options_menu/images/diy.png"
-remove "$rootfs/etc/options_menu/images/download.png"
-remove "$rootfs/etc/options_menu/images/loading"
-remove "$rootfs/etc/options_menu/images/downloadtheme.png"
-remove "$rootfs/etc/options_menu/images/downloadthemelist.png"
-remove "$rootfs/etc/options_menu/images/themepreview.png"
-remove "$rootfs/etc/options_menu/images/downloadtheme.png"
-remove "$rootfs/etc/options_menu/images/downloadthemelist.png"
-remove "$rootfs/etc/options_menu/images/loading.png"
-remove "$rootfs/etc/options_menu/images/themepreview.png"
-remove "$rootfs/etc/options_menu/images/loadingassets.png"
-remove "$rootfs/etc/options_menu/images/luigi.png"
-remove "$rootfs/etc/options_menu/images/mario.png"
-remove "$rootfs/etc/options_menu/images/packed.png"
-remove "$rootfs/etc/options_menu/images/packed_nes.png"
-remove "$rootfs/etc/options_menu/images/pole.png"
-remove "$rootfs/etc/options_menu/images/randtheme.png"
-remove "$rootfs/etc/options_menu/images/retro_gaming.zip"
-remove "$rootfs/etc/options_menu/images/saving.png"
-remove "$rootfs/etc/options_menu/images/sbm.png"
-remove "$rootfs/etc/options_menu/images/settings.png"
-remove "$rootfs/etc/options_menu/images/themes.png"
-remove "$rootfs/etc/options_menu/images/uninstall.png"
-remove "$rootfs/etc/options_menu/images/update.png"
-remove "$rootfs/etc/options_menu/images/wall.png"
-remove "$rootfs/etc/options_menu/images/wall_far.png"
-remove "$rootfs/etc/options_menu/images/loading"
-remove "$rootfs/etc/usr/bin/pngpack-upx"
+removeItem "$rootfs/etc/preinit.d/b8024_themeselector"
+removeItem "$rootfs/etc/preinit.d/p8024_themeselector"
+removeItem "$rootfs/etc/options_menu/commands/c0075_ThemeOptions"
+removeItem "$rootfs/etc/options_menu/scripts/om_themeselector"
+removeItem "$rootfs/etc/options_menu/scripts/om_theme_selector"
+removeItem "$rootfs/etc/options_menu/themes"
+removeItem "$rootfs/etc/options_menu/images/advanced.png"
+removeItem "$rootfs/etc/options_menu/images/audio.png"
+removeItem "$rootfs/etc/options_menu/images/audioall.png"
+removeItem "$rootfs/etc/options_menu/images/audiohome.png"
+removeItem "$rootfs/etc/options_menu/images/back.png"
+removeItem "$rootfs/etc/options_menu/images/ui/chip_apply.png"
+removeItem "$rootfs/etc/options_menu/images/ui/chip_clear.png"
+removeItem "$rootfs/etc/options_menu/images/ui/chip_diy.png"
+removeItem "$rootfs/etc/options_menu/images/ui/chip_download.png"
+removeItem "$rootfs/etc/options_menu/images/ui/chip_preview.png"
+removeItem "$rootfs/etc/options_menu/images/ui/chip_refresh.png"
+removeItem "$rootfs/etc/options_menu/images/ui/chip_settings.png"
+removeItem "$rootfs/etc/options_menu/images/clear.png"
+removeItem "$rootfs/etc/options_menu/images/colors_nes.png"
+removeItem "$rootfs/etc/options_menu/images/default_preview.png"
+removeItem "$rootfs/etc/options_menu/images/diy.png"
+removeItem "$rootfs/etc/options_menu/images/download.png"
+removeItem "$rootfs/etc/options_menu/images/exit.png"
+removeItem "$rootfs/etc/options_menu/images/loading"
+removeItem "$rootfs/etc/options_menu/images/luigi.png"
+removeItem "$rootfs/etc/options_menu/images/mario.png"
+removeItem "$rootfs/etc/options_menu/images/packed.png"
+removeItem "$rootfs/etc/options_menu/images/packed_nes.png"
+removeItem "$rootfs/etc/options_menu/images/pole.png"
+removeItem "$rootfs/etc/options_menu/images/preview_assign_music.png"
+removeItem "$rootfs/etc/options_menu/images/preview_audio_randomizer_all.png"
+removeItem "$rootfs/etc/options_menu/images/preview_audio_randomizer_home.png"
+removeItem "$rootfs/etc/options_menu/images/preview_clean_up.png"
+removeItem "$rootfs/etc/options_menu/images/preview_colors_nes.png"
+removeItem "$rootfs/etc/options_menu/images/preview_default.png"
+removeItem "$rootfs/etc/options_menu/images/preview_diy.png"
+removeItem "$rootfs/etc/options_menu/images/preview_diy_theme.png"
+removeItem "$rootfs/etc/options_menu/images/preview_download_themes.png"
+removeItem "$rootfs/etc/options_menu/images/preview_luigi.png"
+removeItem "$rootfs/etc/options_menu/images/preview_mario.png"
+removeItem "$rootfs/etc/options_menu/images/preview_music_per_folder.png"
+removeItem "$rootfs/etc/options_menu/images/preview_packed.png"
+removeItem "$rootfs/etc/options_menu/images/preview_packed_nes.png"
+removeItem "$rootfs/etc/options_menu/images/preview_pole.png"
+removeItem "$rootfs/etc/options_menu/images/preview_randtheme.png"
+removeItem "$rootfs/etc/options_menu/images/preview_sbm.png"
+removeItem "$rootfs/etc/options_menu/images/preview_theme_options.png"
+removeItem "$rootfs/etc/options_menu/images/preview_theme_randomizer.png"
+removeItem "$rootfs/etc/options_menu/images/preview_uninstall.png"
+removeItem "$rootfs/etc/options_menu/images/preview_update_theme_list.png"
+removeItem "$rootfs/etc/options_menu/images/preview_wall.png"
+removeItem "$rootfs/etc/options_menu/images/preview_wall_far.png"
+removeItem "$rootfs/etc/options_menu/images/preview_wall_near.png"
+removeItem "$rootfs/etc/options_menu/images/randtheme.png"
+removeItem "$rootfs/etc/options_menu/images/reboot.png"
+removeItem "$rootfs/etc/options_menu/images/retro_gaming.zip"
+removeItem "$rootfs/etc/options_menu/images/retroarch.png"
+removeItem "$rootfs/etc/options_menu/images/saving.png"
+removeItem "$rootfs/etc/options_menu/images/sbm.png"
+removeItem "$rootfs/etc/options_menu/images/settings.png"
+removeItem "$rootfs/etc/options_menu/images/shutdown.png"
+removeItem "$rootfs/etc/options_menu/images/themes.png"
+removeItem "$rootfs/etc/options_menu/images/uninstall.png"
+removeItem "$rootfs/etc/options_menu/images/update.png"
+removeItem "$rootfs/etc/options_menu/images/wall.png"
+removeItem "$rootfs/etc/options_menu/images/wall_far.png"
+removeItem "$rootfs/etc/options_menu/images/wall_near.png"
+removeItem "$rootfs/etc/usr/bin/pngpack-upx"
+removeItem "$rootfs/bin/sprite_crop"
+removeItem "$rootfs/etc/options_menu/lib/theme_manager"
-[ -f "$TITLE_FONT_BACKUP" ] && mv "$TITLE_FONT_BACKUP" "$TITLE_FONT"
-for FONT in s m l small medium large; do
- umount "${ORIGINAL_THEME}/resources/fonts/${UI1}/${FONT}.fnt"
- rm "${FONT_PATH}/${FONT}.fnt"
+[ -f "${font_path}/title.fnt.backup" ] && cp "${font_path}/title.fnt.backup" "${font_path}/title.fnt"
+for font_name in s m l small medium large; do
+ umount "${original_theme}/resources/fonts/${ui_subdir}/${font_name}.fnt" 2>/dev/null
+ rm -f "${font_path}/${font_name}.fnt"
done
-echo "COMMAND_NAME=Exit
-COMMAND_TYPE=EXTERNAL
-RESTART_UI=TRUE
-COMMAND_STR=echo \"Closing Options Menu\"" >"$rootfs/etc/options_menu/commands/c9999_Exit"
-
unset cfg_OM_ThemeSelector_enabled
diff --git a/mod/var/lib/clover/profiles/0/hakchi/om_firstrun b/mod/var/lib/clover/profiles/0/hakchi/om_firstrun
deleted file mode 100644
index e69de29..0000000
diff --git a/release-notes/v3.0.0.md b/release-notes/v3.0.0.md
new file mode 100644
index 0000000..1450675
--- /dev/null
+++ b/release-notes/v3.0.0.md
@@ -0,0 +1,62 @@
+# Theme Selector 3.0.0
+
+Theme Selector 3 is a ground-up rewrite. The menu is now its own app, with a preview grid, in-app theme downloads, a DIY theme builder, and a theme and music for every folder.
+
+## Requirements
+
+- [Options Menu v2.1.0](https://github.com/DefKorns/OptionsMenu/releases) or newer. Older Options Menu versions don't work.
+- Font Remount mod (`font_remount`), one of hakchi's built-in mods. It applies the theme fonts.
+- To download themes from the console: the Hakchi Wi-Fi mod (WPA Supplicant) from KMFDManic's store in hakchi, and a USB Wi-Fi adapter (the TP-Link TL-WN725N is recommended).
+- A USB/SD drive is recommended: themes are often 5–30 MB each.
+
+Installing it uninstalls Theme Selector 2, Theme Randomizer and Advanced Music Hack, and keeps your themes, your music and your last applied theme.
+
+## What's new
+
+### Theme Manager
+
+- Browse your installed themes in a preview grid and apply one with **A**. The theme you're using is outlined.
+- Hold **B** on a theme to delete it, after a confirmation. The theme you're using can't be deleted.
+- Same look as the Options Menu, with its colors taken from `/etc/options_menu/theme.cfg`; the outline of the active theme has its own key, `ActiveThemeBorder`.
+- Only themes and options that fit your console are shown.
+
+### Download themes
+
+- Browse the online catalog for your console and install themes straight from it, each one credited to its author. Themes added to the catalog since you last looked come first.
+- A progress bar shows each download, with the free space left where the theme is going. A full NAND is refused instead of filled.
+- **All Themes** downloads the whole catalog at once and skips the themes you already have. It only shows up with a USB/SD drive connected.
+- Downloading a theme you already have asks before overwriting it.
+- Theme packages copied by hand into the themes folder are unpacked the next time you open **Themes**.
+
+### Theme and music per folder
+
+- **Theme Per Folder:** give any folder, and Home, its own theme from a folder tree. Folders on *Automatic* use their parent folder's theme. Themes named after a folder, and a theme named `default` for Home, still work.
+- **Music Per Folder:** the same, with the `.wav` files in your `music_menu` folder.
+
+### DIY themes
+
+Build your own theme from pieces of the themes you already have: Base UI, background, walls, characters, pole, or the background color on a NES. Every piece is previewed, identical pieces from different themes are listed once, and you can add your own.
+
+### Settings
+
+- **Theme Randomizer:** a different theme each time you change folder.
+- **Audio Randomizer:** random Home music, optionally on every folder.
+- Switches toggle in place, without reloading the screen.
+- **Clean Up** runs on its own after installing, and **Reset Settings** puts the console back to its original theme without uninstalling anything.
+
+### Every console and region
+
+- NES, Famicom, Famicom Shonen Jump, SNES (USA/EUR) and Super Famicom.
+- A theme made for one region works on the others: its font names and color palette are adjusted to your firmware.
+- Custom fonts per theme.
+- Nine languages, as in the Options Menu.
+
+## Updating
+
+Install it over the previous version. Your themes, music, applied theme and folder assignments are kept, but every option in **Settings** is turned off again: turn back on the ones you use.
+
+## Thanks
+
+- **[DanTheMan827](https://github.com/DanTheMan827)** for the packed.png extractor, and with **[KMFDManic](https://github.com/KMFDManic)** for always answering my questions about how things work.
+- **Aranthys** and **AxelStone** for testing.
+- Every theme author in the catalog.
diff --git a/src/command_loader.cpp b/src/command_loader.cpp
new file mode 100644
index 0000000..5395a19
--- /dev/null
+++ b/src/command_loader.cpp
@@ -0,0 +1,310 @@
+/**
+ * Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ */
+
+#include "command_loader.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+
+void ReplaceAll(std::string & command, const std::string & oldString, const std::string & newString)
+{
+ size_t pos;
+ while((pos = command.find(oldString)) != std::string::npos)
+ command.replace(pos, oldString.size(), newString);
+}
+
+struct DirCloser { void operator()(DIR * dir) const { if(dir) closedir(dir); } };
+using DirHandle = std::unique_ptr;
+
+struct PipeCloser { void operator()(FILE * pipe) const { if(pipe) pclose(pipe); } };
+using PipeHandle = std::unique_ptr;
+
+// my own convention, not OptionsMenu's Command format
+struct SelectorKeys { bool nesOnly = false; bool snesOnly = false; std::string author; std::string attentionPath; std::string attentionNotice; bool previewAlignTop = false; bool isNew = false; };
+
+SelectorKeys ReadSelectorKeys(const std::string & path)
+{
+ const std::string authorKey = "AUTHOR=";
+ const std::string attentionPathKey = "ATTENTION_PATH=";
+ const std::string attentionNoticeKey = "ATTENTION_NOTICE=";
+ SelectorKeys keys;
+ std::ifstream in(path);
+ std::string line;
+ while(std::getline(in, line))
+ {
+ if(!line.empty() && line.back() == '\r')
+ line.pop_back();
+ if(line == "NES_ONLY=TRUE")
+ keys.nesOnly = true;
+ else if(line == "SNES_ONLY=TRUE")
+ keys.snesOnly = true;
+ else if(line == "PREVIEW_ALIGN_TOP=TRUE")
+ keys.previewAlignTop = true;
+ else if(line == "NEW=TRUE")
+ keys.isNew = true;
+ else if(line.compare(0, authorKey.size(), authorKey) == 0)
+ keys.author = line.substr(authorKey.size());
+ else if(line.compare(0, attentionPathKey.size(), attentionPathKey) == 0)
+ keys.attentionPath = line.substr(attentionPathKey.size());
+ else if(line.compare(0, attentionNoticeKey.size(), attentionNoticeKey) == 0)
+ keys.attentionNotice = line.substr(attentionNoticeKey.size());
+ }
+ return keys;
+}
+
+// some filesystems leave d_type as DT_UNKNOWN
+unsigned char EntryType(const std::string & dirPath, const dirent * entry)
+{
+ if(entry->d_type != DT_UNKNOWN)
+ return entry->d_type;
+ struct stat st;
+ if(stat((dirPath + "/" + entry->d_name).c_str(), &st) != 0)
+ return DT_UNKNOWN;
+ return S_ISREG(st.st_mode) ? DT_REG : S_ISDIR(st.st_mode) ? DT_DIR : DT_UNKNOWN;
+}
+
+bool HasAnyFile(const std::string & dirPath)
+{
+ DirHandle dir(opendir(dirPath.c_str()));
+ if(!dir)
+ return false;
+ while(auto entry = readdir(dir.get()))
+ {
+ const std::string name = entry->d_name;
+ if(name == "." || name == "..")
+ continue;
+ const unsigned char type = EntryType(dirPath, entry);
+ if(type == DT_REG || (type == DT_DIR && HasAnyFile(dirPath + "/" + name)))
+ return true;
+ }
+ return false;
+}
+
+}
+
+// inherited by child screens, so only the first one forks a shell
+std::string ReadSftype()
+{
+ if(const char * env = std::getenv("OM_SFTYPE"))
+ return env;
+
+ std::string result;
+ PipeHandle pipe(popen("source /etc/preinit; script_init; echo $sftype", "r"));
+ if(pipe)
+ {
+ char buffer[32] = {0};
+ if(fgets(buffer, sizeof(buffer), pipe.get()))
+ result = buffer;
+ }
+ while(!result.empty() && (result.back() == '\n' || result.back() == '\r'))
+ result.pop_back();
+
+ setenv("OM_SFTYPE", result.c_str(), 1);
+ return result;
+}
+
+AppOptions ParseArgs(int argc, char * argv[], const std::string & optionsLocation)
+{
+ AppOptions options;
+ options.commandLocation = optionsLocation + "themes/commands/";
+ options.scriptLocation = optionsLocation + "themes/scripts/";
+
+ for(int i = 1; i < argc; ++i)
+ {
+ if(std::strcmp(argv[i], "--commandPath") == 0 && i+1 < argc)
+ {
+ options.commandLocation = argv[++i];
+ options.isRootScreen = false;
+ }
+ else if(std::strcmp(argv[i], "--scriptPath") == 0 && i+1 < argc)
+ options.scriptLocation = argv[++i];
+ else if(std::strcmp(argv[i], "--title") == 0 && i+1 < argc)
+ options.titleKey = argv[++i];
+ else if(std::strcmp(argv[i], "--layout") == 0 && i+1 < argc)
+ options.gridLayout = (std::string(argv[++i]) == "grid");
+ else if(std::strcmp(argv[i], "--folderThemes") == 0 && i+1 < argc)
+ options.folderThemesDir = argv[++i];
+ else if(std::strcmp(argv[i], "--mapping") == 0 && i+1 < argc)
+ options.folderThemesMapping = argv[++i];
+ else if(std::strcmp(argv[i], "--musicMapping") == 0 && i+1 < argc)
+ options.folderMusicMapping = argv[++i];
+ else if(std::strcmp(argv[i], "--folderChannel") == 0 && i+1 < argc)
+ options.folderMusicScreen = std::strcmp(argv[++i], "music") == 0;
+ else if(std::strcmp(argv[i], "--selectedMarker") == 0 && i+1 < argc)
+ options.selectedMarker = argv[++i];
+ else if(std::strcmp(argv[i], "--downloads") == 0)
+ options.downloadsScreen = true;
+ else if(std::strcmp(argv[i], "--themePath") == 0 && i+1 < argc)
+ options.themePath = argv[++i];
+ }
+ if(!options.commandLocation.empty() && options.commandLocation.back() != '/')
+ options.commandLocation += '/';
+ if(!options.scriptLocation.empty() && options.scriptLocation.back() != '/')
+ options.scriptLocation += '/';
+ return options;
+}
+
+std::string ReadBackStack(bool isRootScreen)
+{
+ const char * backStackEnv = std::getenv("OM_BACK_STACK");
+ return (isRootScreen && backStackEnv) ? backStackEnv : "";
+}
+
+// right after the "All" entry, otherwise in catalog order
+void MoveNewThemesUp(std::vector & commands, std::vector & items, const std::vector & allEntry)
+{
+ std::vector order(commands.size());
+ for(size_t i = 0; i < order.size(); ++i)
+ order[i] = i;
+ auto Rank = [&](size_t i) { return !items[i].isThemeItem ? 0 : allEntry[i] ? 1 : items[i].isNew ? 2 : 3; };
+ std::stable_sort(order.begin(), order.end(), [&](size_t a, size_t b) { return Rank(a) < Rank(b); });
+
+ std::vector sortedCommands;
+ std::vector sortedItems;
+ for(size_t i : order)
+ {
+ sortedCommands.push_back(std::move(commands[i]));
+ sortedItems.push_back(std::move(items[i]));
+ }
+ commands = std::move(sortedCommands);
+ items = std::move(sortedItems);
+}
+
+bool LoadCommands(const std::string & commandLocation, const std::string & scriptLocation, const std::string & optionsLocation,
+ const std::string & themePath, std::vector & commands, std::vector & items)
+{
+ std::vector allEntry;
+ DirHandle dir(opendir(commandLocation.c_str()));
+ if(!dir)
+ {
+ std::cerr << "Cannot open input folder.\n";
+ return false;
+ }
+
+ std::list fileList;
+ while(auto entry = readdir(dir.get()))
+ if(entry->d_name[0] == 'c' && EntryType(commandLocation, entry) == DT_REG)
+ fileList.push_back(entry->d_name);
+ fileList.sort();
+
+ const bool usbReady = DirHandle(opendir("/media/hakchi/")) != nullptr;
+
+ std::ifstream in;
+ std::string sftype;
+ bool sftypeLoaded = false;
+ for(auto & file : fileList)
+ {
+ in.open(commandLocation + file);
+ if(!in.is_open())
+ continue;
+ Command c(in); // closes `in` itself once read
+
+ // only a leading c0000_0000 is the sentinel; later empty ones are blank slots
+ if(commands.empty() && c.command.empty())
+ continue;
+ if(c.usbOnly && !usbReady)
+ continue;
+ SelectorKeys keys = ReadSelectorKeys(commandLocation + file);
+ if(keys.nesOnly || keys.snesOnly)
+ {
+ if(!sftypeLoaded)
+ {
+ sftype = ReadSftype();
+ sftypeLoaded = true;
+ }
+ if((keys.nesOnly && sftype != "nes") || (keys.snesOnly && sftype == "nes"))
+ continue;
+ }
+ if(!c.command.empty())
+ {
+ ReplaceAll(c.command, "%options_path%", optionsLocation);
+ ReplaceAll(c.command, "%script_dir%", scriptLocation);
+ ReplaceAll(c.deleteCommand, "%options_path%", optionsLocation);
+ ReplaceAll(c.deleteCommand, "%script_dir%", scriptLocation);
+ if(c.isToggle)
+ {
+ ReplaceAll(c.stateCommand, "%options_path%", optionsLocation);
+ ReplaceAll(c.stateCommand, "%script_dir%", scriptLocation);
+ c.UpdateState();
+ }
+ }
+ commands.push_back(c);
+ const bool needsAttention = !keys.attentionPath.empty() && HasAnyFile(keys.attentionPath);
+ struct stat st;
+ const bool installed = !themePath.empty() && file.compare(0, 6, "c0010_") == 0 &&
+ stat((themePath + "/" + file.substr(6)).c_str(), &st) == 0 && S_ISDIR(st.st_mode);
+ items.push_back({ file.compare(0, 6, "c0000_") != 0, std::move(keys.author), needsAttention, std::move(keys.attentionNotice), keys.previewAlignTop, keys.isNew, installed });
+ allEntry.push_back(file.size() > 4 && file.compare(file.size() - 4, 4, "_all") == 0);
+ }
+ MoveNewThemesUp(commands, items, allEntry);
+ return true;
+}
+
+void AppendBackNavigation(const std::string & optionsLocation, const AppOptions & options, const std::string & backStack,
+ std::vector & commands, std::vector & items)
+{
+ std::string backCommand;
+ if(!backStack.empty())
+ {
+ std::vector entries;
+ for(size_t start = 0; start < backStack.size();)
+ {
+ size_t sep = backStack.find(';', start);
+ if(sep == std::string::npos) sep = backStack.size();
+ entries.push_back(backStack.substr(start, sep-start));
+ start = sep+1;
+ }
+ std::string lastEntry = entries.back();
+ entries.pop_back();
+ std::string remainingStack;
+ for(size_t i = 0; i < entries.size(); ++i)
+ {
+ if(i) remainingStack += ";";
+ remainingStack += entries[i];
+ }
+
+ size_t c1 = lastEntry.find(','), c2 = lastEntry.find(',', c1+1);
+ std::string backPath = lastEntry.substr(0, c1);
+ std::string backScriptPath = lastEntry.substr(c1+1, c2-c1-1);
+ std::string backTitleKey = lastEntry.substr(c2+1);
+
+ backCommand = "rm -f " + std::string(FocusStatePrefix) + "*; usleep 50000 && OM_BACK_STACK=\"" + remainingStack + "\" " + optionsLocation + "options --commandPath " + backPath
+ + (backScriptPath.empty() ? "" : " --scriptPath " + backScriptPath)
+ + " --title \"" + backTitleKey + "\" &";
+ }
+ else if(!options.isRootScreen)
+ {
+ std::ifstream returnScript(options.scriptLocation + "om_return");
+ if(returnScript.good())
+ backCommand = "sh " + options.scriptLocation + "om_return &";
+ else
+ backCommand = "usleep 50000 && " + optionsLocation + "lib/theme_manager --title INSTALLED_THEMES --layout grid &";
+ }
+ else
+ return;
+
+ Command back;
+ back.name = "BACK";
+ back.runInternal = false;
+ back.restartUI = false;
+ back.previewImage = optionsLocation + "images/preview_placeholder.png";
+ back.command = backCommand;
+ commands.push_back(back);
+ items.push_back({ false, "", false, "", false });
+}
diff --git a/src/command_loader.h b/src/command_loader.h
new file mode 100644
index 0000000..463c24d
--- /dev/null
+++ b/src/command_loader.h
@@ -0,0 +1,61 @@
+/**
+ * Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ */
+
+#ifndef COMMAND_LOADER_H_
+#define COMMAND_LOADER_H_
+
+#include "command.h"
+
+#include
+#include
+
+struct AppOptions
+{
+ std::string commandLocation;
+ std::string scriptLocation;
+ std::string titleKey = "THEME_SELECTOR";
+ bool isRootScreen = true; // no --commandPath override - top of the theme-selector's own tree
+ bool gridLayout = true;
+ std::string folderThemesDir;
+ std::string folderThemesMapping;
+ std::string folderMusicMapping;
+ bool folderMusicScreen = false;
+ std::string selectedMarker;
+ bool downloadsScreen = false;
+ std::string themePath;
+};
+
+// each screen remembers its last focused row here; cleared on leaving the theme manager
+constexpr const char * FocusStatePrefix = "/tmp/om_focus";
+
+std::string ReadSftype();
+
+AppOptions ParseArgs(int argc, char * argv[], const std::string & optionsLocation);
+
+// OM_BACK_STACK is set by OptionsMenu before launching "Theme Options"
+std::string ReadBackStack(bool isRootScreen);
+
+struct ItemInfo
+{
+ bool isThemeItem; // false for a fixed "c0000_*" action, true for a theme/asset row
+ std::string author;
+ bool needsAttention;
+ std::string attentionNotice;
+ bool previewAlignTop;
+ bool isNew;
+ bool installed;
+};
+
+bool LoadCommands(const std::string & commandLocation, const std::string & scriptLocation, const std::string & optionsLocation,
+ const std::string & themePath, std::vector & commands, std::vector & items);
+
+void AppendBackNavigation(const std::string & optionsLocation, const AppOptions & options, const std::string & backStack,
+ std::vector & commands, std::vector & items);
+
+#endif
diff --git a/src/folder_theme_model.cpp b/src/folder_theme_model.cpp
new file mode 100644
index 0000000..c17f3f9
--- /dev/null
+++ b/src/folder_theme_model.cpp
@@ -0,0 +1,237 @@
+/**
+ * Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ */
+
+#include "folder_theme_model.h"
+
+#include
+#include
+#include
+
+namespace
+{
+ const char * const HomeKey = "home";
+ const char * const HomeNameMatch = "default";
+ const char * const RandomGlobal = "@random";
+
+ std::vector SplitTabs(const std::string & line)
+ {
+ std::vector fields;
+ size_t start = 0;
+ for(size_t tab; (tab = line.find('\t', start)) != std::string::npos; start = tab + 1)
+ fields.push_back(line.substr(start, tab - start));
+ fields.push_back(line.substr(start));
+ return fields;
+ }
+
+ bool ReadLine(std::ifstream & in, std::string & line)
+ {
+ if(!std::getline(in, line))
+ return false;
+ if(!line.empty() && line.back() == '\r')
+ line.pop_back();
+ return true;
+ }
+
+ std::vector ReadOptions(const std::string & path)
+ {
+ std::vector options;
+ std::ifstream in(path);
+ for(std::string line; ReadLine(in, line);)
+ {
+ std::vector f = SplitTabs(line);
+ if(f[0].empty())
+ continue;
+ f.resize(5);
+ options.push_back({ f[0], f[1].empty() ? f[0] : f[1], f[2], f[3] == "1", f[4] == "1" });
+ }
+ return options;
+ }
+}
+
+bool FolderThemeModel::Load(const std::string & dataDir, const std::string & themeMapping, const std::string & musicMapping)
+{
+ std::ifstream folders(dataDir + "/folders.list");
+ std::vector lastAtDepth;
+ for(std::string line; ReadLine(folders, line);)
+ {
+ std::vector f = SplitTabs(line);
+ if(f.size() < 3 || f[0].empty())
+ continue;
+ FolderEntry entry{ f[0], f[1], std::atoi(f[2].c_str()), -1 };
+ if(entry.depth < 0 || entry.depth > static_cast(lastAtDepth.size()))
+ entry.depth = static_cast(lastAtDepth.size());
+ if(entry.depth > 0)
+ entry.parent = lastAtDepth[entry.depth - 1];
+ lastAtDepth.resize(entry.depth);
+ lastAtDepth.push_back(static_cast(folders_.size()));
+ folders_.push_back(entry);
+ }
+
+ slot(Channel::Theme).options = ReadOptions(dataDir + "/themes.list");
+ slot(Channel::Music).options = ReadOptions(dataDir + "/music.list");
+
+ std::ifstream global(dataDir + "/global");
+ std::string globalId;
+ ReadLine(global, globalId);
+ globalThemeRandom_ = globalId == RandomGlobal;
+ globalTheme_ = OptionIndex(slot(Channel::Theme), globalId);
+
+ std::ifstream audio(dataDir + "/audio");
+ for(std::string line; ReadLine(audio, line);)
+ {
+ musicRandomHome_ = musicRandomHome_ || line == "random_home";
+ musicRandomFolders_ = musicRandomFolders_ || line == "random_folders";
+ }
+
+ themesPerFolder_ = !themeMapping.empty();
+ LoadMapping(slot(Channel::Theme), themeMapping);
+ LoadMapping(slot(Channel::Music), musicMapping);
+ return !folders_.empty();
+}
+
+void FolderThemeModel::LoadMapping(Slot & s, const std::string & mappingPath)
+{
+ s.mappingPath = mappingPath;
+ s.assigned.assign(folders_.size(), -1);
+ if(mappingPath.empty())
+ return;
+ std::ifstream mapping(mappingPath);
+ for(std::string line; ReadLine(mapping, line);)
+ {
+ size_t eq = line.rfind('=');
+ int option = eq == std::string::npos ? -1 : OptionIndex(s, line.substr(eq + 1));
+ int folder = eq == std::string::npos ? -1 : FolderIndex(line.substr(0, eq));
+ if(option < 0 || folder < 0)
+ {
+ if(!line.empty())
+ s.unknownLines.push_back(line);
+ continue;
+ }
+ for(size_t i = 0; i < folders_.size(); ++i)
+ if(folders_[i].key == folders_[folder].key)
+ s.assigned[i] = option;
+ }
+}
+
+bool FolderThemeModel::Save(Channel channel) const
+{
+ const Slot & s = slot(channel);
+ if(s.mappingPath.empty())
+ return false;
+ const std::string tmpPath = s.mappingPath + ".tmp";
+ {
+ std::ofstream out(tmpPath, std::ios::trunc);
+ if(!out)
+ return false;
+ for(const std::string & line : s.unknownLines)
+ out << line << '\n';
+ std::vector written;
+ for(size_t i = 0; i < folders_.size(); ++i)
+ {
+ if(s.assigned[i] < 0)
+ continue;
+ bool seen = false;
+ for(const std::string & key : written)
+ seen = seen || key == folders_[i].key;
+ if(seen)
+ continue;
+ written.push_back(folders_[i].key);
+ out << folders_[i].key << '=' << s.options[s.assigned[i]].id << '\n';
+ }
+ if(!out)
+ return false;
+ }
+ return std::rename(tmpPath.c_str(), s.mappingPath.c_str()) == 0;
+}
+
+void FolderThemeModel::Assign(Channel channel, int folder, int option)
+{
+ // the mapping is keyed by folder name, so same-named folders share it
+ Slot & s = slot(channel);
+ for(size_t i = 0; i < folders_.size(); ++i)
+ if(folders_[i].key == folders_[folder].key)
+ s.assigned[i] = option;
+}
+
+ResolvedOption FolderThemeModel::Resolve(Channel channel, int folder) const
+{
+ int own = slot(channel).assigned[folder];
+ if(own >= 0)
+ return { own, OptionSource::Assigned, folder };
+ return ResolveAutomatic(channel, folder);
+}
+
+ResolvedOption FolderThemeModel::ResolveAutomatic(Channel channel, int folder) const
+{
+ const FolderEntry & entry = folders_[folder];
+ if(channel == Channel::Theme && (themesPerFolder_ || IsHome(folder)))
+ {
+ int nameMatch = OptionIndex(slot(channel), IsHome(folder) ? HomeNameMatch : entry.key);
+ if(nameMatch >= 0)
+ return { nameMatch, OptionSource::NameMatch, folder };
+ }
+ if(channel == Channel::Theme && !themesPerFolder_)
+ return { globalTheme_, OptionSource::Global, -1 };
+
+ if(entry.parent >= 0)
+ {
+ ResolvedOption up = Resolve(channel, entry.parent);
+ if(up.source == OptionSource::Assigned || up.source == OptionSource::NameMatch || up.source == OptionSource::Inherited)
+ {
+ up.source = OptionSource::Inherited;
+ return up;
+ }
+ }
+
+ if(channel == Channel::Music)
+ return AutomaticMusic(folder);
+ return { globalTheme_, OptionSource::Global, -1 };
+}
+
+// mirrors om_themescript: the theme's own .wav wins, else the randomizer where it applies, else stock
+ResolvedOption FolderThemeModel::AutomaticMusic(int folder) const
+{
+ int theme = Resolve(Channel::Theme, folder).option;
+ const std::vector & themes = slot(Channel::Theme).options;
+ if(theme >= 0 && themes[theme].hasMusic)
+ return { -1, OptionSource::ThemeMusic, -1 };
+ bool randomApplies = IsHome(folder) ? musicRandomHome_ : musicRandomHome_ && musicRandomFolders_;
+ if(randomApplies && !(theme >= 0 && themes[theme].needsOverlay))
+ return { -1, OptionSource::RandomMusic, -1 };
+ return { -1, OptionSource::OriginalMusic, -1 };
+}
+
+int FolderThemeModel::UsageCount(Channel channel, int option) const
+{
+ int count = 0;
+ for(int assigned : slot(channel).assigned)
+ count += assigned == option ? 1 : 0;
+ return count;
+}
+
+int FolderThemeModel::OptionIndex(const Slot & s, const std::string & id) const
+{
+ for(size_t i = 0; i < s.options.size(); ++i)
+ if(s.options[i].id == id)
+ return static_cast(i);
+ return -1;
+}
+
+int FolderThemeModel::FolderIndex(const std::string & key) const
+{
+ for(size_t i = 0; i < folders_.size(); ++i)
+ if(folders_[i].key == key)
+ return static_cast(i);
+ return -1;
+}
+
+bool FolderThemeModel::IsHome(int folder) const
+{
+ return folders_[folder].key == HomeKey && folders_[folder].depth == 0;
+}
diff --git a/src/folder_theme_model.h b/src/folder_theme_model.h
new file mode 100644
index 0000000..a35749a
--- /dev/null
+++ b/src/folder_theme_model.h
@@ -0,0 +1,87 @@
+/**
+ * Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ */
+
+#ifndef FOLDER_THEME_MODEL_H_
+#define FOLDER_THEME_MODEL_H_
+
+#include
+#include
+
+struct FolderEntry
+{
+ std::string key;
+ std::string name;
+ int depth;
+ int parent;
+};
+
+struct FolderOption
+{
+ std::string id;
+ std::string title;
+ std::string preview;
+ bool hasMusic;
+ bool needsOverlay;
+};
+
+enum class Channel { Theme = 0, Music = 1 };
+
+enum class OptionSource { Assigned, NameMatch, Inherited, Global, ThemeMusic, RandomMusic, OriginalMusic };
+
+struct ResolvedOption
+{
+ int option; // -1: nothing specific (stock UI, a random theme, the theme's own or random music)
+ OptionSource source;
+ int fromFolder;
+};
+
+// folder -> theme/music, resolved like om_themescript
+class FolderThemeModel
+{
+public:
+ bool Load(const std::string & dataDir, const std::string & themeMapping, const std::string & musicMapping);
+ bool Save(Channel channel) const;
+
+ const std::vector & Folders() const { return folders_; }
+ const std::vector & Options(Channel channel) const { return slot(channel).options; }
+ bool GlobalThemeIsRandom() const { return globalThemeRandom_; }
+
+ int Assigned(Channel channel, int folder) const { return slot(channel).assigned[folder]; }
+ void Assign(Channel channel, int folder, int option); // -1 clears
+ ResolvedOption Resolve(Channel channel, int folder) const;
+ ResolvedOption ResolveAutomatic(Channel channel, int folder) const;
+ int UsageCount(Channel channel, int option) const;
+
+private:
+ struct Slot
+ {
+ std::vector options;
+ std::vector assigned;
+ std::vector unknownLines; // folders or options not on this console right now, kept on save
+ std::string mappingPath;
+ };
+
+ Slot & slot(Channel channel) { return slots_[static_cast(channel)]; }
+ const Slot & slot(Channel channel) const { return slots_[static_cast(channel)]; }
+ void LoadMapping(Slot & s, const std::string & mappingPath);
+ int OptionIndex(const Slot & s, const std::string & id) const;
+ int FolderIndex(const std::string & key) const;
+ bool IsHome(int folder) const;
+ ResolvedOption AutomaticMusic(int folder) const;
+
+ std::vector folders_;
+ Slot slots_[2];
+ int globalTheme_ = -1;
+ bool globalThemeRandom_ = false;
+ bool musicRandomHome_ = false;
+ bool musicRandomFolders_ = false;
+ bool themesPerFolder_ = false; // off: om_themescript ignores assignments and name matches alike
+};
+
+#endif
diff --git a/src/folder_themes_layout.cpp b/src/folder_themes_layout.cpp
new file mode 100644
index 0000000..88abcf1
--- /dev/null
+++ b/src/folder_themes_layout.cpp
@@ -0,0 +1,435 @@
+/**
+ * Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ */
+
+#include "theme_manager_app.h"
+#include "folder_theme_model.h"
+#include "texture_utils.h"
+#include "framework/draw_helpers.h"
+#include "framework/list_screen.h"
+#include "framework/powerwatch.h"
+#include "localization.h"
+
+#include
+#include
+#include
+#include
+
+namespace
+{
+ const int ListRight = 760;
+ const int DetailX = 800;
+ const int DetailW = 384;
+ const int PreviewH = 216;
+ const int TreeIndent = 26;
+ const int RowGlyphSize = 16;
+ const int ValueMaxW = 250;
+
+ const SDL_Rect PickerRect{ DetailX - 16, UiTheme::HeaderDividerY + 14, DetailW + 32, UiTheme::FooterDividerY - UiTheme::HeaderDividerY - 28 };
+ const int PickerHeaderH = 44;
+ const int PickerRowH = 40;
+ const int ThumbW = 60, ThumbH = 34;
+
+ bool ReadParameter(const std::string & name)
+ {
+ std::ifstream in("/sys/module/clovercon/parameters/" + name);
+ int value = 0;
+ return (in >> value) && value != 0;
+ }
+
+ // hakchi's "Use X/Y on Classic Controller as autofire A/B" - Y then arrives as B presses
+ bool PadHasY()
+ {
+ return ReadSftype() != "nes" && !ReadParameter("autofire_xy");
+ }
+
+ std::string Format(const std::string & key, const std::string & value)
+ {
+ std::string text = Translate(key);
+ size_t at = text.find("%s");
+ return at == std::string::npos ? text : text.replace(at, 2, value);
+ }
+}
+
+int ThemeManagerApp::RunFolderThemesLayout()
+{
+ FolderThemeModel model;
+ if(!model.Load(options_.folderThemesDir, options_.folderThemesMapping, options_.folderMusicMapping))
+ return 1;
+ const std::vector & folders = model.Folders();
+ const int folderCount = static_cast(folders.size());
+ const bool hasY = PadHasY();
+ const Channel channel = options_.folderMusicScreen ? Channel::Music : Channel::Theme;
+ auto OptionCount = [&]() { return static_cast(model.Options(channel).size()) + 1; }; // 0 = Automatic
+
+ const int rowPitch = std::max(UiTheme::RowPitch, GetTTFLineHeight(RowGlyphSize));
+ const int displayRows = std::max(1, (UiTheme::FooterDividerY - UiTheme::ListBottomMargin - UiTheme::RowFirstY) / rowPitch);
+ const int pickerRows = (PickerRect.h - PickerHeaderH - 8) / PickerRowH;
+
+ ValuePicker valuePicker(optionsLocation_, renderer_);
+
+ auto MakeText = [&](const std::string & text, int size, Color color) { return Texture(text, size, renderer_, 0, 0, false, ToAbgr(color), true); };
+ auto MakeBadge = [&](const char * letter, const std::string & labelKey, Color rim, Color fill)
+ {
+ return badges_->Make(letter, Translate(labelKey), rim, fill);
+ };
+ const Badge listBadgeA = MakeBadge("A", "FOLDER_THEME_LIST", UiTheme::BadgeADark, UiTheme::BadgeA);
+ const Badge pickBadgeA = MakeBadge("A", "HINT_SELECT", UiTheme::BadgeADark, UiTheme::BadgeA);
+ Badge backBadge = MakeBadge("B", "BACK", UiTheme::BadgeBDark, UiTheme::BadgeB);
+ Badge cancelBadge = MakeBadge("B", "FOLDER_THEME_CANCEL", UiTheme::BadgeBDark, UiTheme::BadgeB);
+ Badge autoBadge = MakeBadge("Y", "AUTOMATIC", UiTheme::BadgeYDark, UiTheme::BadgeY);
+ valuePicker.SetHintLabel(Translate("FOLDER_THEME_CHANGE"));
+
+ auto OptionName = [&](Channel ch, const ResolvedOption & resolved)
+ {
+ if(resolved.option >= 0)
+ return model.Options(ch)[resolved.option].title;
+ switch(resolved.source)
+ {
+ case OptionSource::ThemeMusic: return Translate("FOLDER_MUSIC_THEME");
+ case OptionSource::RandomMusic: return Translate("FOLDER_MUSIC_RANDOM");
+ case OptionSource::OriginalMusic: return Translate("FOLDER_MUSIC_ORIGINAL");
+ default: return Translate(model.GlobalThemeIsRandom() ? "FOLDER_THEME_RANDOM" : "FOLDER_THEME_STOCK");
+ }
+ };
+ auto AutomaticLabel = [&](int folder) { return Translate("AUTOMATIC") + " · " + OptionName(channel, model.ResolveAutomatic(channel, folder)); };
+
+ const std::vector & themes = model.Options(Channel::Theme);
+ std::vector previews(themes.size());
+ std::vector previewLoaded(themes.size(), 0); // not vector: Preview() needs a real reference
+ std::vector previewUsed(themes.size(), 0);
+ unsigned previewClock = 0;
+ int previewCount = 0;
+ const int maxPreviews = pickerRows + 4; // whole picker page + detail, so a redraw never evicts what it draws
+ Texture fallbackPreview;
+ char fallbackLoaded = 0;
+ auto Preview = [&](int theme) -> Texture &
+ {
+ char & loaded = theme >= 0 ? previewLoaded[theme] : fallbackLoaded;
+ Texture & slot = theme >= 0 ? previews[theme] : fallbackPreview;
+ if(theme >= 0)
+ previewUsed[theme] = ++previewClock;
+ if(!loaded && theme >= 0 && previewCount >= maxPreviews)
+ {
+ int oldest = -1;
+ for(int i = 0; i < static_cast(previews.size()); ++i)
+ if(previewLoaded[i] && i != theme && (oldest < 0 || previewUsed[i] < previewUsed[oldest]))
+ oldest = i;
+ previews[oldest] = Texture();
+ previewLoaded[oldest] = 0;
+ --previewCount;
+ }
+ if(!loaded)
+ {
+ loaded = 1;
+ if(theme >= 0)
+ ++previewCount;
+ std::string path = theme >= 0 ? themes[theme].preview : "";
+ if(path.empty() || !std::ifstream(path).good())
+ path = optionsLocation_ + (theme < 0 && model.GlobalThemeIsRandom() ? "images/preview_randtheme.png" : "images/preview_default.png");
+ SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "1");
+ slot = Texture(path, renderer_, 0, 0);
+ SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "0");
+ }
+ return slot;
+ };
+ auto DrawCover = [&](Texture art, const SDL_Rect & box, int radius, Color around)
+ {
+ DrawRoundedFillRect(renderer_, box, UiTheme::SelectedRowBg, radius);
+ if(art.rect.w <= 0)
+ return;
+ SDL_RenderSetClipRect(renderer_, &box);
+ FitCover(art, box.x, box.y, box.w, box.h, 0);
+ art.Draw(renderer_);
+ SDL_RenderSetClipRect(renderer_, nullptr);
+ DrawRoundedCornerMask(renderer_, box, around, radius);
+ };
+
+ std::vector nameTex(folders.size()), valueTex(folders.size());
+ auto RebuildRows = [&]()
+ {
+ for(int i = 0; i < folderCount; ++i)
+ {
+ int own = model.Assigned(channel, i);
+ std::string value = own >= 0 ? model.Options(channel)[own].title : AutomaticLabel(i);
+ valueTex[i] = MakeText(TruncateToWidth(value, RowGlyphSize, ValueMaxW), RowGlyphSize, own >= 0 ? UiTheme::Text : UiTheme::TextDim);
+ int textX = UiTheme::RowTextX + folders[i].depth * TreeIndent + (folders[i].depth > 0 ? 14 : 0);
+ int nameAvail = ListRight - 16 - valuePicker.Width(valueTex[i]) - 16 - textX;
+ nameTex[i] = MakeText(TruncateToWidth(folders[i].name, RowGlyphSize, nameAvail), RowGlyphSize, UiTheme::Text);
+ nameTex[i].rect.x = textX;
+ }
+ };
+
+ int selected = 0, topRow = 0;
+ Texture detailName, detailTag, detailSource, detailUsage;
+ Color detailTagFill{};
+ int detailTheme = -1;
+ auto RebuildDetail = [&]()
+ {
+ detailTheme = model.Resolve(Channel::Theme, selected).option;
+ ResolvedOption resolved = model.Resolve(channel, selected);
+ detailName = MakeText(TruncateToWidth(OptionName(channel, resolved), 22, DetailW), 22, UiTheme::Text);
+
+ std::string tagKey = "FOLDER_THEME_TAG_GLOBAL", sourceText = Translate("FOLDER_THEME_SOURCE_GLOBAL");
+ Color tagText = UiTheme::Text;
+ detailTagFill = UiTheme::Border;
+ switch(resolved.source)
+ {
+ case OptionSource::Assigned:
+ tagKey = "FOLDER_THEME_TAG_ASSIGNED";
+ sourceText = Translate("FOLDER_THEME_SOURCE_ASSIGNED");
+ detailTagFill = UiTheme::Accent;
+ tagText = UiTheme::Bg;
+ break;
+ case OptionSource::NameMatch:
+ tagKey = "FOLDER_THEME_TAG_NAME";
+ sourceText = Translate("FOLDER_THEME_SOURCE_NAME");
+ break;
+ case OptionSource::Inherited:
+ tagKey = "FOLDER_THEME_TAG_INHERITED";
+ sourceText = Format("FOLDER_THEME_SOURCE_INHERITED", folders[resolved.fromFolder].name);
+ detailTagFill = UiTheme::BadgeXDark;
+ break;
+ case OptionSource::ThemeMusic:
+ tagKey = "FOLDER_MUSIC_TAG_THEME";
+ sourceText = Translate("FOLDER_MUSIC_SOURCE_THEME");
+ break;
+ case OptionSource::RandomMusic:
+ tagKey = "FOLDER_MUSIC_TAG_RANDOM";
+ sourceText = Translate("FOLDER_MUSIC_SOURCE_RANDOM");
+ break;
+ case OptionSource::OriginalMusic:
+ tagKey = "FOLDER_MUSIC_TAG_ORIGINAL";
+ sourceText = Translate("FOLDER_MUSIC_SOURCE_ORIGINAL");
+ break;
+ case OptionSource::Global:
+ break;
+ }
+ detailTag = MakeText(Translate(tagKey), 13, tagText);
+ int tagW = detailTag.rect.w + 16;
+ detailSource = MakeText(TruncateToWidth(sourceText, 16, DetailW - tagW - 10), 16, UiTheme::Text);
+
+ int usage = resolved.option >= 0 ? model.UsageCount(channel, resolved.option) : 0;
+ std::string usageText;
+ if(resolved.option >= 0)
+ usageText = usage > 1 ? Format("FOLDER_THEME_USED_IN", std::to_string(usage))
+ : Translate(usage == 1 ? "FOLDER_THEME_USED_ONCE" : "FOLDER_THEME_UNUSED");
+ detailUsage = MakeText(usageText, 14, UiTheme::TextDim);
+ };
+
+ auto Select = [&](int folder)
+ {
+ selected = folder;
+ if(selected < topRow)
+ topRow = selected;
+ else if(selected >= topRow + displayRows)
+ topRow = selected - displayRows + 1;
+ RebuildDetail();
+ };
+ auto Assign = [&](int option)
+ {
+ if(model.Assigned(channel, selected) == option)
+ return;
+ model.Assign(channel, selected, option);
+ model.Save(channel);
+ RebuildRows();
+ RebuildDetail();
+ };
+ auto Cycle = [&](int step)
+ {
+ int option = model.Assigned(channel, selected) + 1;
+ Assign((option + step + OptionCount()) % OptionCount() - 1);
+ };
+
+ bool picking = false;
+ int pick = 0, pickTop = 0;
+ Texture pickerTitle;
+ std::vector optionTex;
+ auto OpenPicker = [&]()
+ {
+ picking = true;
+ pick = model.Assigned(channel, selected) + 1;
+ pickTop = std::max(0, std::min(pick - pickerRows / 2, OptionCount() - pickerRows));
+ pickerTitle = MakeText(TruncateToWidth(Format(channel == Channel::Theme ? "FOLDER_THEME_FOR" : "FOLDER_MUSIC_FOR", folders[selected].name), 16, PickerRect.w - 32), 16, UiTheme::Text);
+ optionTex.clear();
+ int labelW = PickerRect.w - 24 - (channel == Channel::Theme ? ThumbW + 12 : 8) - 24;
+ optionTex.push_back(MakeText(TruncateToWidth(AutomaticLabel(selected), 16, labelW), 16, UiTheme::Text));
+ for(const FolderOption & option : model.Options(channel))
+ optionTex.push_back(MakeText(TruncateToWidth(option.title, 16, labelW), 16, UiTheme::Text));
+ };
+ auto MovePick = [&](int to)
+ {
+ pick = std::max(0, std::min(OptionCount() - 1, to));
+ if(pick < pickTop)
+ pickTop = pick;
+ else if(pick >= pickTop + pickerRows)
+ pickTop = pick - pickerRows + 1;
+ };
+
+ RebuildRows();
+ Select(0);
+
+ const bool hasBack = pinnedStartIndex_ < static_cast(commands_.size());
+ for(;;)
+ {
+ FrameEvent frameEvent = PollFrameEvents();
+ if(frameEvent == FrameEvent::Quit)
+ return 0;
+ if(frameEvent == FrameEvent::PowerButtonPressed)
+ {
+ ResumeUnderlyingUi();
+ break;
+ }
+
+ if(picking)
+ {
+ if(controller_->GetButtonStatus(A) || controller_->GetButtonStatus(START))
+ {
+ Assign(pick - 1);
+ picking = false;
+ }
+ else if(controller_->GetButtonStatus(B))
+ picking = false;
+ else if(controller_->HeldRepeat(UP))
+ MovePick(pick - 1);
+ else if(controller_->HeldRepeat(DOWN))
+ MovePick(pick + 1);
+ }
+ else
+ {
+ if(controller_->GetButtonStatus(A) || controller_->GetButtonStatus(START))
+ OpenPicker();
+ else if(controller_->GetButtonStatus(B))
+ {
+ if(hasBack && ActivateCommand(commands_[pinnedStartIndex_]))
+ break;
+ }
+ else if(hasY && controller_->GetButtonStatus(Y))
+ Assign(-1);
+ else if(controller_->HeldRepeat(UP))
+ Select((selected - 1 + folderCount) % folderCount);
+ else if(controller_->HeldRepeat(DOWN))
+ Select((selected + 1) % folderCount);
+ else if(controller_->HeldRepeat(LEFT))
+ Cycle(-1);
+ else if(controller_->HeldRepeat(RIGHT))
+ Cycle(1);
+ }
+
+ badgeA_ = picking ? pickBadgeA : listBadgeA;
+ DrawChromeCommon();
+ DrawSectionTitle();
+ {
+ int rightEdge = UiTheme::BadgeClusterRightX - UiTheme::BadgeOuterSize - UiTheme::BadgeLabelGap - badgeA_.label.rect.w - UiTheme::BadgeGroupGap;
+ if(picking)
+ rightEdge = DrawBadge(cancelBadge, rightEdge);
+ else
+ {
+ if(hasBack)
+ rightEdge = DrawBadge(backBadge, rightEdge);
+ if(hasY)
+ rightEdge = DrawBadge(autoBadge, rightEdge);
+ rightEdge = valuePicker.DrawHint(rightEdge, UiTheme::BadgeBandY);
+ }
+ DrawFooterDivider(renderer_, rightEdge);
+ }
+
+ for(int row = 0; row < displayRows && topRow + row < folderCount; ++row)
+ {
+ int i = topRow + row;
+ int y = UiTheme::RowFirstY + row * rowPitch;
+ bool isSelected = i == selected;
+ SDL_Rect rowRect{ UiTheme::ListX, y - 2, ListRight - UiTheme::ListX, rowPitch - 2 };
+ if(isSelected)
+ {
+ DrawRoundedFillRect(renderer_, rowRect, UiTheme::SelectedRowBg, UiTheme::BoxRadius);
+ DrawStrokeRect(renderer_, rowRect, UiTheme::Accent, 2, UiTheme::BoxRadius);
+ }
+ else if(i + 1 < folderCount && row + 1 < displayRows)
+ DrawHLine(renderer_, UiTheme::ListX, ListRight, y + rowPitch - 3, UiTheme::Border);
+
+ int midY = y - 2 + (rowPitch - 2) / 2;
+ if(folders[i].depth > 0)
+ {
+ int twigX = nameTex[i].rect.x - 14;
+ DrawVLine(renderer_, twigX, y + 6, midY, UiTheme::Border, 2);
+ DrawHLine(renderer_, twigX, twigX + 9, midY, UiTheme::Border, 2);
+ }
+ nameTex[i].rect.y = midY - nameTex[i].rect.h / 2;
+ nameTex[i].Draw(renderer_);
+
+ valuePicker.DrawValue(valueTex[i], ListRight - 16, midY, isSelected && !picking);
+ }
+ scrollArrows_->Draw(topRow > 0, topRow + displayRows < folderCount);
+
+ {
+ SDL_Rect previewBox{ DetailX, UiTheme::RowFirstY, DetailW, PreviewH };
+ DrawCover(Preview(detailTheme), previewBox, UiTheme::BoxRadius, UiTheme::Bg);
+ DrawStrokeRect(renderer_, previewBox, UiTheme::Border, UiTheme::BorderWidth, UiTheme::BoxRadius);
+ int y = previewBox.y + PreviewH + 16;
+ detailName.rect.x = DetailX;
+ detailName.rect.y = y;
+ detailName.Draw(renderer_);
+ y += detailName.rect.h + 12;
+ SDL_Rect tagRect{ DetailX, y, detailTag.rect.w + 16, detailTag.rect.h + 8 };
+ DrawRoundedFillRect(renderer_, tagRect, detailTagFill, 6);
+ detailTag.rect.x = tagRect.x + 8;
+ detailTag.rect.y = tagRect.y + 4;
+ detailTag.Draw(renderer_);
+ detailSource.rect.x = tagRect.x + tagRect.w + 10;
+ detailSource.rect.y = tagRect.y + (tagRect.h - detailSource.rect.h) / 2;
+ detailSource.Draw(renderer_);
+ detailUsage.rect.x = DetailX;
+ detailUsage.rect.y = tagRect.y + tagRect.h + 12;
+ detailUsage.Draw(renderer_);
+ }
+
+ if(picking)
+ {
+ DrawRoundedFillRect(renderer_, PickerRect, UiTheme::Bg, UiTheme::BoxRadius);
+ DrawStrokeRect(renderer_, PickerRect, UiTheme::Accent, 2, UiTheme::BoxRadius);
+ pickerTitle.rect.x = PickerRect.x + 16;
+ pickerTitle.rect.y = PickerRect.y + (PickerHeaderH - pickerTitle.rect.h) / 2;
+ pickerTitle.Draw(renderer_);
+
+ const bool thumbs = channel == Channel::Theme;
+ int current = model.Assigned(channel, selected) + 1;
+ for(int row = 0; row < pickerRows && pickTop + row < OptionCount(); ++row)
+ {
+ int option = pickTop + row;
+ SDL_Rect rowRect{ PickerRect.x + 8, PickerRect.y + PickerHeaderH + row * PickerRowH, PickerRect.w - 16, PickerRowH - 4 };
+ if(option == pick)
+ {
+ DrawRoundedFillRect(renderer_, rowRect, UiTheme::SelectedRowBg, UiTheme::BoxRadius);
+ DrawStrokeRect(renderer_, rowRect, UiTheme::Accent, 2, UiTheme::BoxRadius);
+ }
+ int labelX = rowRect.x + 16;
+ if(thumbs)
+ {
+ SDL_Rect thumb{ rowRect.x + 8, rowRect.y + (rowRect.h - ThumbH) / 2, ThumbW, ThumbH };
+ if(option == 0)
+ DrawStrokeRect(renderer_, thumb, UiTheme::Border, 2, 4);
+ else
+ DrawCover(Preview(option - 1), thumb, 4, option == pick ? UiTheme::SelectedRowBg : UiTheme::Bg);
+ labelX = thumb.x + ThumbW + 12;
+ }
+ Texture & label = optionTex[option];
+ label.rect.x = labelX;
+ label.rect.y = rowRect.y + (rowRect.h - label.rect.h) / 2;
+ label.Draw(renderer_);
+ if(option == current)
+ DrawRoundedFillRect(renderer_, { rowRect.x + rowRect.w - 22, rowRect.y + rowRect.h / 2 - 5, 10, 10 }, UiTheme::BadgeA, 5);
+ }
+ }
+
+ SetDrawColor(renderer_, bg_);
+ sdlContext_->EndFrame();
+ }
+
+ return 0;
+}
diff --git a/src/main.cpp b/src/main.cpp
new file mode 100644
index 0000000..0df2a84
--- /dev/null
+++ b/src/main.cpp
@@ -0,0 +1,49 @@
+/**
+ * Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ */
+
+// Each screen relaunches this binary with its own --commandPath.
+
+#include "single_instance_lock.h"
+#include "command_loader.h"
+#include "theme_manager_app.h"
+#include "framework/sdl_helper.h"
+#include "localization.h"
+
+#include
+#include
+#include
+
+int main(int argc, char * argv[])
+{
+ SingleInstanceLock::Acquire();
+
+ const std::string optionsLocation = "/etc/options_menu/";
+ AppOptions options = ParseArgs(argc, argv, optionsLocation);
+ const std::string backStack = ReadBackStack(options.isRootScreen);
+
+ LoadLanguageFromConfig(optionsLocation);
+ SetTTFFontPath(optionsLocation);
+ UiTheme::LoadThemeConfig(optionsLocation);
+
+ std::vector commands;
+ std::vector items;
+ if(!LoadCommands(options.commandLocation, options.scriptLocation, optionsLocation, options.themePath, commands, items))
+ return 1;
+
+ AppendBackNavigation(optionsLocation, options, backStack, commands, items);
+
+ if(commands.empty())
+ {
+ std::cerr << "No usable commands in " << options.commandLocation << "\n";
+ return 1;
+ }
+
+ ThemeManagerApp app(optionsLocation, std::move(options), std::move(commands), std::move(items));
+ return app.Run();
+}
diff --git a/src/single_instance_lock.cpp b/src/single_instance_lock.cpp
new file mode 100644
index 0000000..687e98c
--- /dev/null
+++ b/src/single_instance_lock.cpp
@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ */
+
+#include "single_instance_lock.h"
+
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+
+std::string ProcessName(const std::string & pid)
+{
+ std::string name;
+ std::getline(std::ifstream("/proc/" + pid + "/comm"), name);
+ return name;
+}
+
+}
+
+void SingleInstanceLock::Acquire()
+{
+ static SingleInstanceLock instance;
+ (void)instance;
+}
+
+SingleInstanceLock::SingleInstanceLock()
+{
+ TakeOverStaleInstance();
+ std::ofstream(LockPath, std::ios::trunc) << getpid();
+ std::signal(SIGTERM, &SingleInstanceLock::HandleSigTerm);
+}
+
+SingleInstanceLock::~SingleInstanceLock() { std::remove(LockPath); }
+
+void SingleInstanceLock::TakeOverStaleInstance()
+{
+ std::ifstream in(LockPath);
+ pid_t oldPid = 0;
+ if(in.good())
+ in >> oldPid;
+ in.close();
+ if(oldPid <= 0 || oldPid == getpid() || kill(oldPid, 0) != 0)
+ return;
+ // the pid may have been reused after a crash left the lock behind
+ if(ProcessName(std::to_string(oldPid)) != ProcessName("self"))
+ return;
+ kill(oldPid, SIGTERM);
+ for(int i = 0; i < 40 && kill(oldPid, 0) == 0; ++i)
+ usleep(50000);
+ if(kill(oldPid, 0) == 0)
+ kill(oldPid, SIGKILL);
+}
+
+void SingleInstanceLock::HandleSigTerm(int)
+{
+ unlink(LockPath);
+ _exit(0);
+}
diff --git a/src/single_instance_lock.h b/src/single_instance_lock.h
new file mode 100644
index 0000000..6a51138
--- /dev/null
+++ b/src/single_instance_lock.h
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ */
+
+#ifndef SINGLE_INSTANCE_LOCK_H_
+#define SINGLE_INSTANCE_LOCK_H_
+
+// takes over a stale instance instead of refusing to start
+class SingleInstanceLock
+{
+public:
+ SingleInstanceLock(const SingleInstanceLock &) = delete;
+ SingleInstanceLock & operator=(const SingleInstanceLock &) = delete;
+
+ static void Acquire();
+
+private:
+ static constexpr const char * LockPath = "/tmp/theme_manager.lock";
+
+ SingleInstanceLock();
+ ~SingleInstanceLock();
+
+ static void TakeOverStaleInstance();
+ static void HandleSigTerm(int);
+};
+
+#endif
diff --git a/src/storage_indicator.cpp b/src/storage_indicator.cpp
new file mode 100644
index 0000000..e15342f
--- /dev/null
+++ b/src/storage_indicator.cpp
@@ -0,0 +1,139 @@
+/**
+ * Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ */
+
+#include "storage_indicator.h"
+#include "framework/draw_helpers.h"
+#include "localization.h"
+
+#include
+#include
+#include
+#include
+
+namespace
+{
+ constexpr const char * UsbPath = "/media";
+ constexpr const char * NandPath = "/var/lib/hakchi";
+ constexpr int FontSize = 14;
+ constexpr int BarW = 110;
+ constexpr int BarH = 10;
+ constexpr int InnerGap = 10;
+ constexpr int MeterGap = 32;
+ constexpr double LowFree = 0.25;
+ constexpr double CriticalFree = 0.10;
+ constexpr Color Healthy{ 0x4C, 0xAF, 0x6E };
+
+ bool UsbMounted()
+ {
+ struct stat media, root;
+ return stat(UsbPath, &media) == 0 && stat("/", &root) == 0 && media.st_dev != root.st_dev;
+ }
+
+ std::string FormatFree(double bytes)
+ {
+ const double mb = bytes / (1024.0 * 1024.0);
+ char text[32];
+ if(mb >= 1024.0)
+ std::snprintf(text, sizeof(text), "%.1f GB", mb / 1024.0);
+ else
+ std::snprintf(text, sizeof(text), "%.1f MB", mb);
+ return text;
+ }
+
+ Color LevelColor(double freeFraction)
+ {
+ if(freeFraction < CriticalFree)
+ return UiTheme::BadgeB;
+ if(freeFraction < LowFree)
+ return UiTheme::Accent;
+ return Healthy;
+ }
+}
+
+StorageIndicator::StorageIndicator(SDL_Renderer * renderer)
+ : renderer_(renderer)
+{
+ Refresh();
+}
+
+void StorageIndicator::Update()
+{
+ if(SDL_GetTicks() - lastRefreshMs_ >= RefreshMs)
+ Refresh();
+}
+
+void StorageIndicator::Refresh()
+{
+ lastRefreshMs_ = SDL_GetTicks();
+ const bool usb = UsbMounted();
+ if(meters_.size() != (usb ? 2u : 1u))
+ {
+ meters_.assign(usb ? 2 : 1, Meter{});
+ meters_[0].name = "NAND";
+ meters_[0].path = NandPath;
+ if(usb)
+ {
+ meters_[1].name = "USB";
+ meters_[1].path = UsbPath;
+ }
+ }
+ destination_ = static_cast(meters_.size()) - 1;
+ for(int i = 0; i < static_cast(meters_.size()); ++i)
+ ReadMeter(meters_[i], i == destination_);
+}
+
+void StorageIndicator::ReadMeter(Meter & meter, bool isDestination)
+{
+ struct statvfs vfs;
+ if(statvfs(meter.path.c_str(), &vfs) != 0 || vfs.f_blocks == 0)
+ return;
+ const double blockSize = static_cast(vfs.f_frsize);
+ const double total = vfs.f_blocks * blockSize;
+ const double available = vfs.f_bavail * blockSize;
+ meter.usedFraction = (vfs.f_blocks - vfs.f_bfree) * blockSize / total;
+ meter.freeFraction = available / total;
+
+ const std::string valueText = FormatFree(available) + " " + Translate("FREE_SPACE");
+ if(valueText == meter.valueText && meter.label.texture)
+ return;
+ meter.valueText = valueText;
+ const Uint32 textColor = ToAbgr(isDestination ? UiTheme::Text : UiTheme::TextDim);
+ meter.label = Texture(meter.name, FontSize, renderer_, 0, 0, false, textColor, true);
+ meter.value = Texture(valueText, FontSize, renderer_, 0, 0, false, textColor, true);
+}
+
+void StorageIndicator::Draw(int rightX, int centerY)
+{
+ int x = rightX;
+ for(int i = static_cast(meters_.size()) - 1; i >= 0; --i)
+ {
+ Meter & meter = meters_[i];
+ x -= meter.value.rect.w;
+ meter.value.rect.x = x;
+ meter.value.rect.y = centerY - meter.value.rect.h / 2;
+ meter.value.Draw(renderer_);
+
+ x -= InnerGap + BarW;
+ const SDL_Rect track{ x, centerY - BarH / 2, BarW, BarH };
+ DrawRoundedFillRect(renderer_, track, UiTheme::Border, BarH / 2);
+ const int usedW = static_cast(BarW * meter.usedFraction + 0.5);
+ if(usedW > 0)
+ {
+ const Color level = i == destination_ ? LevelColor(meter.freeFraction) : UiTheme::TextDim;
+ DrawRoundedFillRect(renderer_, { x, track.y, std::max(usedW, BarH), BarH }, level, BarH / 2);
+ }
+
+ x -= InnerGap + meter.label.rect.w;
+ meter.label.rect.x = x;
+ meter.label.rect.y = centerY - meter.label.rect.h / 2;
+ meter.label.Draw(renderer_);
+
+ x -= MeterGap;
+ }
+}
diff --git a/src/storage_indicator.h b/src/storage_indicator.h
new file mode 100644
index 0000000..99f484f
--- /dev/null
+++ b/src/storage_indicator.h
@@ -0,0 +1,49 @@
+/**
+ * Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ */
+
+#ifndef STORAGE_INDICATOR_H_
+#define STORAGE_INDICATOR_H_
+
+#include "framework/sdl_helper.h"
+
+#include
+#include
+
+class StorageIndicator
+{
+public:
+ explicit StorageIndicator(SDL_Renderer * renderer);
+
+ void Update();
+ void Refresh();
+ void Draw(int rightX, int centerY);
+
+private:
+ struct Meter
+ {
+ std::string name;
+ std::string path;
+ double usedFraction = 0;
+ double freeFraction = 1;
+ std::string valueText;
+ Texture label;
+ Texture value;
+ };
+
+ static constexpr Uint32 RefreshMs = 2000;
+
+ SDL_Renderer * renderer_;
+ std::vector meters_;
+ int destination_ = 0;
+ Uint32 lastRefreshMs_ = 0;
+
+ void ReadMeter(Meter & meter, bool isDestination);
+};
+
+#endif
diff --git a/src/texture_utils.cpp b/src/texture_utils.cpp
new file mode 100644
index 0000000..88613a2
--- /dev/null
+++ b/src/texture_utils.cpp
@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ */
+
+#include "texture_utils.h"
+
+#include
+
+namespace {
+
+void FitScaled(Texture & tex, int boxX, int boxY, int boxW, int boxH, int padding, bool cover)
+{
+ const int maxW = boxW - 2*padding;
+ const int maxH = boxH - 2*padding;
+ if(tex.rect.w <= 0 || tex.rect.h <= 0)
+ return;
+ const double scaleW = static_cast(maxW) / tex.rect.w;
+ const double scaleH = static_cast(maxH) / tex.rect.h;
+ const double scale = cover ? std::max(scaleW, scaleH) : std::min(scaleW, scaleH);
+ tex.rect.w = static_cast(tex.rect.w * scale);
+ tex.rect.h = static_cast(tex.rect.h * scale);
+ tex.rect.x = boxX + (boxW - tex.rect.w) / 2;
+ tex.rect.y = boxY + (boxH - tex.rect.h) / 2;
+}
+
+} // namespace
+
+void FitCentered(Texture & tex, int boxX, int boxY, int boxW, int boxH, int padding)
+{
+ FitScaled(tex, boxX, boxY, boxW, boxH, padding, false);
+}
+
+void FitCover(Texture & tex, int boxX, int boxY, int boxW, int boxH, int padding)
+{
+ FitScaled(tex, boxX, boxY, boxW, boxH, padding, true);
+}
diff --git a/src/texture_utils.h b/src/texture_utils.h
new file mode 100644
index 0000000..9c1d641
--- /dev/null
+++ b/src/texture_utils.h
@@ -0,0 +1,21 @@
+/**
+ * Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ */
+
+#ifndef TEXTURE_UTILS_H_
+#define TEXTURE_UTILS_H_
+
+#include "framework/sdl_helper.h"
+#include "framework/text_layout.h"
+
+#include
+
+void FitCentered(Texture & tex, int boxX, int boxY, int boxW, int boxH, int padding); // letterboxed
+void FitCover(Texture & tex, int boxX, int boxY, int boxW, int boxH, int padding); // cropped
+
+#endif
diff --git a/src/theme_manager_app.cpp b/src/theme_manager_app.cpp
new file mode 100644
index 0000000..9d7b80d
--- /dev/null
+++ b/src/theme_manager_app.cpp
@@ -0,0 +1,979 @@
+/**
+ * Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ */
+
+#include "theme_manager_app.h"
+#include "download_screen.h"
+#include "texture_utils.h"
+#include "framework/draw_helpers.h"
+#include "framework/powerwatch.h"
+#include "localization.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#ifndef MOD_VERSION
+#define MOD_VERSION "dev"
+#endif
+
+namespace
+{
+ Color ActiveThemeBorder{ 0x4C, 0xAF, 0x6E }; // green
+
+ void LoadActiveThemeBorderColor(const std::string & optionsLocation)
+ {
+ std::ifstream in(optionsLocation + "theme.cfg");
+ std::string line;
+ while(std::getline(in, line))
+ {
+ if(!line.empty() && line.back() == '\r')
+ line.pop_back();
+ size_t eq = line.find('=');
+ if(eq == std::string::npos || line[0] == '#' || line.compare(0, eq, "ActiveThemeBorder") != 0)
+ continue;
+ int r, g, b;
+ if(std::sscanf(line.c_str() + eq + 1, "%d,%d,%d", &r, &g, &b) == 3)
+ ActiveThemeBorder = { static_cast(r), static_cast(g), static_cast(b) };
+ }
+ }
+}
+
+ThemeManagerApp::ThemeManagerApp(std::string optionsLocation, AppOptions options, std::vector commands, std::vector items)
+ : optionsLocation_(std::move(optionsLocation))
+ , options_(std::move(options))
+ , commands_(std::move(commands))
+ , items_(std::move(items))
+{
+ LoadActiveThemeBorderColor(optionsLocation_);
+
+ sdlContext_ = std::make_unique(std::chrono::milliseconds(33), false);
+ renderer_ = sdlContext_->renderer;
+ controller_ = std::make_unique(1);
+
+ bg_ = UiTheme::Bg;
+ SetDrawColor(renderer_, bg_);
+
+ switchOn_ = Texture(optionsLocation_ + UiTheme::AssetSwitchOn, renderer_);
+ switchOff_ = Texture(optionsLocation_ + UiTheme::AssetSwitchOff, renderer_);
+
+ chrome_ = std::make_unique(optionsLocation_, renderer_, "Theme Manager", MOD_VERSION, "Theme Manager - by DefKorns");
+ scrollArrows_ = std::make_unique(optionsLocation_, renderer_);
+ titleText_ = Texture(Translate(options_.titleKey), UiTheme::SectionTitleFontSize, renderer_, UiTheme::SectionTitleX, UiTheme::SectionTitleY, false, ToAbgr(UiTheme::Text), true);
+
+ badges_ = std::make_unique(optionsLocation_, renderer_);
+ badgeA_ = badges_->Make("A", Translate("HINT_SELECT"), UiTheme::BadgeADark, UiTheme::BadgeA);
+ badgeRowRightEdge_ = UiTheme::BadgeClusterRightX - UiTheme::BadgeOuterSize - UiTheme::BadgeLabelGap - badgeA_.label.rect.w - UiTheme::BadgeGroupGap;
+ if(options_.downloadsScreen)
+ storage_ = std::make_unique(renderer_);
+ badgeHold_ = badges_->Make("B", Translate("HINT_DELETE"), UiTheme::BadgeXDark, UiTheme::BadgeX);
+
+ ComputePinnedAndThemeRanges();
+}
+
+void ThemeManagerApp::ComputePinnedAndThemeRanges()
+{
+ pinnedStartIndex_ = static_cast(commands_.size());
+ while(pinnedStartIndex_ > 0 && (commands_[pinnedStartIndex_-1].name == "BACK" || commands_[pinnedStartIndex_-1].name == "EXIT"))
+ --pinnedStartIndex_;
+
+ themeStart_ = 0;
+ while(themeStart_ < pinnedStartIndex_ && !items_[themeStart_].isThemeItem)
+ ++themeStart_;
+}
+
+int ThemeManagerApp::DrawBadge(Badge & badge, int rightEdgeX)
+{
+ return badges_->Draw(badge, rightEdgeX, UiTheme::BadgeBandY);
+}
+
+bool ThemeManagerApp::Confirm(const ScreenSnapshot & background, const std::string & title)
+{
+ ConfirmDialog dialog(renderer_, *badges_, title,
+ badges_->Make("A", Translate("HINT_CONFIRM"), UiTheme::BadgeADark, UiTheme::BadgeA),
+ badges_->Make("B", Translate("HINT_CANCEL"), UiTheme::BadgeBDark, UiTheme::BadgeB));
+ controller_->GetButtonStatus(B); // consume the still-held B from the triggering long-press
+ bool confirmed = false;
+ for(;;)
+ {
+ controller_->Update();
+ if(controller_->GetButtonStatus(B))
+ break;
+ if(controller_->GetButtonStatus(A) || controller_->GetButtonStatus(START))
+ {
+ confirmed = true;
+ break;
+ }
+ sdlContext_->StartFrame();
+ background.Draw();
+ dialog.Draw();
+ SetDrawColor(renderer_, bg_);
+ sdlContext_->EndFrame();
+ }
+ return confirmed;
+}
+
+bool ThemeManagerApp::ConfirmDelete(const ScreenSnapshot & background)
+{
+ const std::string & blockedKey = commands_[currentCommandId_].deleteBlockedKey;
+ if(!blockedKey.empty())
+ {
+ MessageDialog dialog(renderer_, *badges_, Translate(blockedKey),
+ badges_->Make("B", Translate("HINT_BACK"), UiTheme::BadgeBDark, UiTheme::BadgeB));
+ controller_->GetButtonStatus(B);
+ for(;;)
+ {
+ controller_->Update();
+ if(controller_->GetButtonStatus(B) || controller_->GetButtonStatus(A) || controller_->GetButtonStatus(START))
+ break;
+ sdlContext_->StartFrame();
+ background.Draw();
+ dialog.Draw();
+ SetDrawColor(renderer_, bg_);
+ sdlContext_->EndFrame();
+ }
+ return false;
+ }
+ const std::string & confirmKey = commands_[currentCommandId_].deleteConfirmKey;
+ if(!Confirm(background, Translate(confirmKey.empty() ? "DELETE_CONFIRM_GENERIC" : confirmKey)))
+ return false;
+ system(commands_[currentCommandId_].deleteCommand.c_str());
+ return true;
+}
+
+bool ThemeManagerApp::FinishFrame()
+{
+ SetDrawColor(renderer_, bg_);
+ if(!deleteRequested_ && !overwriteRequested_)
+ {
+ sdlContext_->EndFrame();
+ return false;
+ }
+ const ScreenSnapshot background(renderer_);
+ sdlContext_->EndFrame();
+ if(overwriteRequested_)
+ {
+ overwriteRequested_ = false;
+ if(Confirm(background, Translate("DOWNLOAD_OVERWRITE_CONFIRM")))
+ RunDownload(commands_[currentCommandId_]);
+ return false;
+ }
+ deleteRequested_ = false;
+ return ConfirmDelete(background);
+}
+
+void ThemeManagerApp::DrawFooterBadges(Badge & badgeB)
+{
+ int rightEdge = badgeRowRightEdge_;
+ if(pinnedStartIndex_ < (int)commands_.size())
+ rightEdge = DrawBadge(badgeB, rightEdge);
+ if(!commands_[currentCommandId_].deleteCommand.empty() || !commands_[currentCommandId_].deleteBlockedKey.empty())
+ rightEdge = DrawBadge(badgeHold_, rightEdge);
+ DrawFooterDivider(renderer_, rightEdge);
+}
+
+void ThemeManagerApp::DrawChromeCommon()
+{
+ chrome_->DrawFrame();
+ DrawBadge(badgeA_, UiTheme::BadgeClusterRightX);
+}
+
+void ThemeManagerApp::DrawSectionTitle()
+{
+ int accentBarY = titleText_.rect.y + (titleText_.rect.h - UiTheme::SectionAccentBarH) / 2;
+ DrawFillRect(renderer_, { UiTheme::SectionTitleX - UiTheme::SectionAccentBarW - 14, accentBarY, UiTheme::SectionAccentBarW, UiTheme::SectionAccentBarH }, UiTheme::Accent);
+ titleText_.Draw(renderer_);
+}
+
+Texture ThemeManagerApp::MakeScrollArrow(int x, int y) const
+{
+ Texture arrow(optionsLocation_ + UiTheme::AssetChevronUp, renderer_, x, y);
+ SetColorMod(arrow.texture.get(), UiTheme::ScrollArrow);
+ arrow.rect.x -= arrow.rect.w / 2;
+ return arrow;
+}
+
+std::string ThemeManagerApp::FocusStatePath() const
+{
+ std::string sanitized = options_.commandLocation;
+ for(char & c : sanitized)
+ if(c == '/') c = '_';
+ return FocusStatePrefix + sanitized;
+}
+
+void ThemeManagerApp::SaveFocusState() const
+{
+ std::ofstream(FocusStatePath(), std::ios::trunc) << commands_[currentCommandId_].name;
+}
+
+int ThemeManagerApp::LoadFocusIndex() const
+{
+ std::ifstream in(FocusStatePath());
+ std::string name;
+ if(!std::getline(in, name) || name.empty())
+ return -1;
+ for(int i = 0; i < static_cast(commands_.size()); ++i)
+ if(commands_[i].name == name)
+ return i;
+ return -1;
+}
+
+ThemeManagerApp::FrameEvent ThemeManagerApp::PollFrameEvents()
+{
+ sdlContext_->StartFrame();
+ controller_->Update();
+
+ SDL_Event e;
+ while(SDL_PollEvent(&e))
+ if(e.type == SDL_QUIT)
+ return FrameEvent::Quit;
+
+ if(sdlContext_->powerwatch->buttonPress())
+ return FrameEvent::PowerButtonPressed;
+
+ return FrameEvent::Continue;
+}
+
+void ThemeManagerApp::ResumeUnderlyingUi() const
+{
+ system(("rm -f " + std::string(FocusStatePrefix) + "*; /bin/sh " + optionsLocation_ + "scripts/ResumeUI.sh").c_str());
+}
+
+bool ThemeManagerApp::ActivateCommand(Command & cmd)
+{
+ if(!cmd.runInternal)
+ {
+ system(cmd.command.c_str());
+ return true;
+ }
+ if(options_.downloadsScreen && cmd.command.find("/get_all") == std::string::npos)
+ {
+ if(items_[static_cast(&cmd - commands_.data())].installed)
+ overwriteRequested_ = true;
+ else
+ RunDownload(cmd);
+ return false;
+ }
+ cmd.RunCommand(*sdlContext_, controller_.get(), *chrome_, bg_);
+ if(cmd.isToggle)
+ cmd.UpdateState();
+ // the caller's frame was cleared before the command drew over it
+ SetDrawColor(renderer_, bg_);
+ SDL_RenderClear(renderer_);
+ return false;
+}
+
+void ThemeManagerApp::ReloadCommands()
+{
+ std::vector commands;
+ std::vector items;
+ if(!LoadCommands(options_.commandLocation, options_.scriptLocation, optionsLocation_, options_.themePath, commands, items))
+ return;
+ AppendBackNavigation(optionsLocation_, options_, ReadBackStack(options_.isRootScreen), commands, items);
+ if(commands.empty())
+ return;
+ commands_ = std::move(commands);
+ items_ = std::move(items);
+ ComputePinnedAndThemeRanges();
+}
+
+void ThemeManagerApp::RunDownload(Command & cmd)
+{
+ const size_t index = static_cast(&cmd - commands_.data());
+ const std::string title = cmd.name.substr(0, cmd.name.find(" ["));
+ if(RunDownloadScreen(cmd.command, Translate("INSTALLING_THEME"), Translate(title), items_[index].author, *sdlContext_, *controller_, *chrome_))
+ {
+ items_[index].isNew = false;
+ items_[index].installed = true;
+ }
+ SetDrawColor(renderer_, bg_);
+ SDL_RenderClear(renderer_);
+}
+
+void ThemeManagerApp::UpdateBButton(bool & tapped)
+{
+ tapped = false;
+ bool bHeldNow = controller_->PeekButtonStatus(B);
+ if(bHeldNow)
+ {
+ if(!bHoldFired_ && controller_->HeldMillis(B) >= BHoldThresholdMs)
+ {
+ bHoldFired_ = true;
+ if(!commands_[currentCommandId_].deleteCommand.empty() || !commands_[currentCommandId_].deleteBlockedKey.empty())
+ deleteRequested_ = true;
+ }
+ }
+ else
+ {
+ tapped = bWasHeld_ && !bHoldFired_;
+ bHoldFired_ = false;
+ controller_->HeldMillis(B); // releases the hold timer, or the next press starts already past the threshold
+ }
+ bWasHeld_ = bHeldNow;
+}
+
+int ThemeManagerApp::RunGridLayout()
+{
+ const bool squareTiles = themeStart_ < pinnedStartIndex_ && commands_[themeStart_].previewSquare;
+ const int explicitCols = themeStart_ < pinnedStartIndex_ ? commands_[themeStart_].previewGridCols : -1;
+ const int GridCols = explicitCols > 0 ? explicitCols : (squareTiles ? 7 : 4);
+ const bool hasCaptions = themeStart_ < pinnedStartIndex_ && !commands_[themeStart_].previewHideLabel;
+ const int GridGap = 20;
+
+ // empty slots keep their width so the other chips don't shift
+ int stripEnd = themeStart_;
+ while(stripEnd > 0 && commands_[stripEnd-1].command.empty())
+ --stripEnd;
+ std::vector stripSlots;
+ for(int i = 0; i < stripEnd; ++i)
+ stripSlots.push_back(i);
+ const int stripSlotCount = static_cast(stripSlots.size());
+ std::vector stripIndices;
+ for(int i : stripSlots)
+ if(!commands_[i].command.empty())
+ stripIndices.push_back(i);
+ const int stripCount = static_cast(stripIndices.size());
+ auto StripToCommand = [&](int pos) -> int { return stripIndices[pos]; };
+ auto CommandToStrip = [&](int idx) -> int
+ {
+ for(int p = 0; p < stripCount; ++p)
+ if(stripIndices[p] == idx)
+ return p;
+ return 0;
+ };
+ const int chipGap = 14;
+ const int chipIconSize = 22;
+ const int chipIconGap = 12;
+ const int chipPadX = 20;
+ const int chipLabelSize = 14;
+ const int MinChipW = 140;
+
+ const bool hasStrip = stripSlotCount > 0;
+ const int StripTop = UiTheme::HeaderDividerY + 22;
+ const int StripH = hasStrip ? 58 : 0;
+ const int GridLeft = UiTheme::FrameX + 32;
+ const int GridRight = UiTheme::FrameX + UiTheme::FrameW - 32;
+ auto ChipIconW = [&](int idx) { return commands_[idx].previewImage.empty() ? 0 : chipIconSize + chipIconGap; };
+ std::vector chipWidths;
+ for(int idx : stripSlots)
+ {
+ const Command & c = commands_[idx];
+ const int contentW = c.command.empty() ? 0 : ChipIconW(idx) + TextWidth(Translate(c.name), chipLabelSize);
+ chipWidths.push_back(std::max(MinChipW, contentW + 2*chipPadX));
+ }
+ const int stripAvail = GridRight - GridLeft - std::max(0, stripSlotCount-1)*chipGap;
+ auto StripWidthAt = [&](int cap)
+ {
+ int total = 0;
+ for(int w : chipWidths)
+ total += std::min(w, cap);
+ return total;
+ };
+ int chipCap = chipWidths.empty() ? 0 : *std::max_element(chipWidths.begin(), chipWidths.end());
+ while(chipCap > MinChipW && StripWidthAt(chipCap) > stripAvail)
+ --chipCap;
+ for(int & w : chipWidths)
+ w = std::min(w, chipCap);
+ std::vector chipWidthOf(commands_.size(), 0);
+ for(int pos = 0; pos < stripSlotCount; ++pos)
+ chipWidthOf[stripSlots[pos]] = chipWidths[pos];
+ titleText_ = Texture(Translate(options_.titleKey), UiTheme::SectionTitleFontSize - 6, renderer_, UiTheme::SectionTitleX, 0, false, ToAbgr(UiTheme::Text), true);
+ titleText_.rect.y = hasStrip ? (StripTop + StripH + 22) : (UiTheme::HeaderDividerY + 22);
+ const int GridTop = titleText_.rect.y + titleText_.rect.h + 20;
+
+ const int noticeSize = 14;
+ const int noticeGap = 24;
+ const int attentionDotSize = 10;
+ const int attentionDotInset = 7;
+ Texture noticeText;
+ for(int i = 0; i < (int)commands_.size(); ++i)
+ {
+ if(!items_[i].needsAttention || items_[i].attentionNotice.empty())
+ continue;
+ const int noticeAvail = GridRight - (titleText_.rect.x + titleText_.rect.w + noticeGap);
+ noticeText = Texture(TruncateToWidth(Translate(items_[i].attentionNotice), noticeSize, noticeAvail), noticeSize, renderer_, 0, 0, false, ToAbgr(UiTheme::Accent), true);
+ noticeText.rect.x = GridRight - noticeText.rect.w;
+ noticeText.rect.y = titleText_.rect.y + (titleText_.rect.h - noticeText.rect.h) / 2;
+ break;
+ }
+ const int GridBottom = UiTheme::FooterDividerY - 14;
+ const int LabelH = hasCaptions ? 18 : 0;
+ const int RowGap = hasCaptions ? 8 : GridGap;
+ const int TileW = (GridRight - GridLeft - (GridCols-1)*GridGap) / GridCols;
+ int TileH, GridRowsVisible;
+ if(hasStrip)
+ {
+ TileH = hasCaptions ? TileW / 2 : TileW * 9 / 16;
+ GridRowsVisible = std::max(1, (GridBottom - GridTop + RowGap) / (TileH + LabelH + RowGap));
+ }
+ else
+ {
+ GridRowsVisible = 3;
+ TileH = (GridBottom - GridTop - (GridRowsVisible-1)*RowGap - GridRowsVisible*LabelH) / GridRowsVisible;
+ }
+ const int GridRowPitch = TileH + LabelH + RowGap;
+
+ Texture gridScrollUp = MakeScrollArrow(GridRight + 22, GridTop + 24);
+ Texture gridScrollDown = gridScrollUp;
+ gridScrollDown.rect.y = GridBottom - 24 - gridScrollDown.rect.h;
+
+ // B runs Back/Exit directly - it's a footer badge, not a chip
+ Badge badgeB = badges_->Make("B", pinnedStartIndex_ < (int)commands_.size() ? Translate(commands_[pinnedStartIndex_].name) : "", UiTheme::BadgeBDark, UiTheme::BadgeB);
+
+ std::vector chipIcons(commands_.size());
+ std::vector chipLabels(commands_.size());
+ std::vector tileImages(commands_.size());
+ std::vector tileLabels(commands_.size());
+ std::vector tileAuthors(commands_.size());
+ std::vector> tileRunFrames(commands_.size()); // sized >1 only for an animated _run01.png tile
+ std::vector tileHideLabel(commands_.size(), false); // PREVIEW_HIDE_LABEL
+ std::vector tileFitContain(commands_.size(), false); // PREVIEW_FIT_CONTAIN
+ Texture newBadge(Translate("NEW_BADGE"), 13, renderer_, 0, 0, false, ToAbgr(UiTheme::Bg), true);
+ const int newBadgePad = 7;
+ for(int i = 0; i < (int)commands_.size(); ++i)
+ {
+ Command & c = commands_[i];
+ if(c.command.empty())
+ continue; // reserved blank slot, not a chip or tile
+ std::string label = Translate(c.name);
+ if(i < themeStart_ || i >= pinnedStartIndex_)
+ {
+ const int labelAvail = chipWidthOf[i] - 2*chipPadX - ChipIconW(i);
+ if(c.previewImage.size())
+ {
+ SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "1"); // nearest looks jagged on small glyphs
+ Texture icon(c.previewImage, renderer_, 0, 0);
+ SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "0");
+ FitCentered(icon, 0, 0, chipIconSize, chipIconSize, 0);
+ SetColorMod(icon.texture.get(), UiTheme::Accent);
+ chipIcons[i] = icon;
+ }
+ label = TruncateToWidth(label, chipLabelSize, labelAvail);
+ chipLabels[i] = Texture(label, chipLabelSize, renderer_, 0, 0, false, ToAbgr(UiTheme::Text), true);
+ }
+ else
+ {
+ label = TruncateToWidth(label, 15, TileW - 28);
+ tileLabels[i] = Texture(label, 15, renderer_, 0, 0, false, ToAbgr(UiTheme::Text), true);
+ if(!items_[i].author.empty())
+ {
+ std::string author = TruncateToWidth("by: " + items_[i].author, 13, TileW - 20);
+ tileAuthors[i] = Texture(author, 13, renderer_, 0, 0, false, ToAbgr(UiTheme::Text), true);
+ }
+ }
+ }
+ std::vector tileImageLoaded(commands_.size(), false);
+ auto EnsureTileImage = [&](int idx)
+ {
+ if(tileImageLoaded[idx])
+ return;
+ tileImageLoaded[idx] = true;
+ const Command & c = commands_[idx];
+ if(c.previewImage.empty())
+ return;
+ SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, c.previewNearest ? "0" : "1");
+ Texture art(c.previewImage, renderer_, 0, 0);
+ SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "0");
+
+ tileHideLabel[idx] = c.previewHideLabel;
+ tileFitContain[idx] = c.previewFitContain;
+
+ const std::string runSuffix = "01.png";
+ bool isRunCycle = c.previewNearest && c.previewImage.size() > runSuffix.size() &&
+ c.previewImage.compare(c.previewImage.size() - runSuffix.size(), runSuffix.size(), runSuffix) == 0;
+
+ if(tileFitContain[idx])
+ FitCentered(art, 0, 0, TileW, TileH, 0);
+ else
+ FitCover(art, 0, 0, TileW, TileH, 0);
+ tileImages[idx] = art;
+
+ if(isRunCycle)
+ {
+ std::string base = c.previewImage.substr(0, c.previewImage.size() - runSuffix.size());
+ std::vector frames{ art };
+ for(int n = 2; n <= 8; ++n)
+ {
+ std::string framePath = base + (n < 10 ? "0" : "") + std::to_string(n) + ".png";
+ if(!std::ifstream(framePath).good())
+ break;
+ Texture frame(framePath, renderer_, 0, 0);
+ FitCentered(frame, 0, 0, TileW, TileH, 0);
+ frames.push_back(frame);
+ }
+ if(frames.size() > 1)
+ tileRunFrames[idx] = std::move(frames);
+ }
+ };
+
+ // the theme applied on the console
+ std::vector isActiveTheme(commands_.size(), false);
+ const std::string activeMarker = options_.titleKey == "INSTALLED_THEMES" ? "/var/lib/clover/profiles/0/hakchi/lastTheme" : options_.selectedMarker;
+ auto RefreshActiveTiles = [&]()
+ {
+ std::fill(isActiveTheme.begin(), isActiveTheme.end(), false);
+ std::string activeName;
+ if(!activeMarker.empty())
+ {
+ std::ifstream in(activeMarker);
+ std::getline(in, activeName);
+ }
+ if(!activeName.empty() && activeName.back() == '\r')
+ activeName.pop_back();
+ if(activeName.empty())
+ return;
+
+ const std::string suffix = ".sh";
+ for(int i = themeStart_; i < pinnedStartIndex_; ++i)
+ {
+ const std::string & cmd = commands_[i].command;
+ if(cmd.size() <= suffix.size() || cmd.compare(cmd.size()-suffix.size(), suffix.size(), suffix) != 0)
+ continue;
+ size_t slashPos = cmd.find_last_of('/');
+ size_t start = (slashPos == std::string::npos) ? 0 : slashPos+1;
+ if(cmd.compare(start, cmd.size()-suffix.size()-start, activeName) == 0)
+ isActiveTheme[i] = true;
+ }
+ };
+ RefreshActiveTiles();
+
+ int gridTopRow = 0;
+ auto SetCurrentCommand = [&](int newId)
+ {
+ currentCommandId_ = newId;
+ if(newId >= themeStart_ && newId < pinnedStartIndex_)
+ {
+ int row = (newId - themeStart_) / GridCols;
+ if(row < gridTopRow) gridTopRow = row;
+ else if(row >= gridTopRow + GridRowsVisible) gridTopRow = row - GridRowsVisible + 1;
+ }
+ SaveFocusState();
+ };
+ int restoredId = LoadFocusIndex();
+ SetCurrentCommand(restoredId >= 0 ? restoredId : (themeStart_ < pinnedStartIndex_ ? themeStart_ : (stripCount > 0 ? StripToCommand(0) : 0)));
+
+ // fixed for the whole screen (only gridTopRow scrolls within them)
+ const int lastThemeIndex = pinnedStartIndex_ - 1;
+ const int totalRows = (lastThemeIndex - themeStart_ + GridCols) / GridCols;
+
+ for(;;)
+ {
+ FrameEvent frameEvent = PollFrameEvents();
+ if(frameEvent == FrameEvent::Quit)
+ return 0;
+ if(frameEvent == FrameEvent::PowerButtonPressed)
+ {
+ ResumeUnderlyingUi();
+ break;
+ }
+
+ bool inGrid = currentCommandId_ >= themeStart_ && currentCommandId_ < pinnedStartIndex_;
+
+ if(controller_->GetButtonStatus(A) || controller_->GetButtonStatus(START))
+ {
+ if(ActivateCommand(commands_[currentCommandId_]))
+ break;
+ RefreshActiveTiles();
+ if(storage_)
+ storage_->Refresh();
+ }
+ else if(controller_->HeldRepeat(LEFT))
+ {
+ if(inGrid && currentCommandId_ > themeStart_)
+ SetCurrentCommand(currentCommandId_ - 1);
+ else if(!inGrid && stripCount > 0)
+ SetCurrentCommand(StripToCommand(std::max(0, CommandToStrip(currentCommandId_) - 1)));
+ }
+ else if(controller_->HeldRepeat(RIGHT))
+ {
+ if(inGrid && currentCommandId_ < pinnedStartIndex_ - 1)
+ SetCurrentCommand(currentCommandId_ + 1);
+ else if(!inGrid && stripCount > 0)
+ SetCurrentCommand(StripToCommand(std::min(stripCount - 1, CommandToStrip(currentCommandId_) + 1)));
+ }
+ else if(controller_->HeldRepeat(UP))
+ {
+ if(inGrid)
+ {
+ int col = (currentCommandId_ - themeStart_) % GridCols;
+ if(currentCommandId_ - themeStart_ < GridCols) // top grid row - jump up to the strip
+ {
+ if(stripCount > 0)
+ SetCurrentCommand(StripToCommand(std::min(stripCount - 1, col)));
+ }
+ else
+ SetCurrentCommand(currentCommandId_ - GridCols);
+ }
+ }
+ else if(controller_->HeldRepeat(DOWN))
+ {
+ if(!inGrid && themeStart_ < pinnedStartIndex_)
+ {
+ int col = std::min(GridCols - 1, CommandToStrip(currentCommandId_));
+ SetCurrentCommand(std::min(pinnedStartIndex_ - 1, themeStart_ + col));
+ }
+ else if(inGrid && currentCommandId_ + GridCols < pinnedStartIndex_)
+ SetCurrentCommand(currentCommandId_ + GridCols);
+ }
+ {
+ bool tapped = false;
+ UpdateBButton(tapped);
+ // quick tap runs Back/Exit
+ if(tapped && pinnedStartIndex_ < (int)commands_.size() && ActivateCommand(commands_[pinnedStartIndex_]))
+ break;
+ }
+
+ DrawChromeCommon();
+ DrawSectionTitle();
+ DrawFooterBadges(badgeB);
+
+ {
+ int x = GridLeft;
+ for(int pos = 0; pos < stripSlotCount; ++pos)
+ {
+ const int idx = stripSlots[pos];
+ const int chipW = chipWidths[pos];
+ if(commands_[idx].command.empty())
+ {
+ x += chipW + chipGap;
+ continue;
+ }
+ bool selected = idx == currentCommandId_;
+ SDL_Rect chipRect{ x, StripTop, chipW, StripH };
+ if(selected)
+ {
+ DrawRoundedFillRect(renderer_, chipRect, UiTheme::SelectedRowBg, UiTheme::BoxRadius);
+ DrawStrokeRect(renderer_, chipRect, UiTheme::Accent, 2, UiTheme::BoxRadius);
+ }
+ else
+ DrawStrokeRect(renderer_, chipRect, UiTheme::Border, UiTheme::BorderWidth, UiTheme::BoxRadius);
+
+ // icon+label centered as one group, not each alone
+ Texture & icon = chipIcons[idx];
+ Texture & label = chipLabels[idx];
+ int iconW = icon.rect.h > 0 ? chipIconSize : 0;
+ int groupW = iconW + (iconW > 0 ? chipIconGap : 0) + label.rect.w;
+ int gx = x + (chipW - groupW) / 2;
+ if(icon.rect.h > 0)
+ {
+ icon.rect.x = gx + (chipIconSize - icon.rect.w) / 2;
+ icon.rect.y = StripTop + (StripH - icon.rect.h) / 2;
+ icon.Draw(renderer_);
+ gx += iconW + chipIconGap;
+ }
+ label.rect.x = gx;
+ label.rect.y = StripTop + (StripH - label.rect.h) / 2;
+ label.Draw(renderer_);
+
+ if(items_[idx].needsAttention)
+ DrawRoundedFillRect(renderer_, { x + chipW - attentionDotSize - attentionDotInset, StripTop + attentionDotInset, attentionDotSize, attentionDotSize }, UiTheme::Accent, attentionDotSize / 2);
+
+ x += chipW + chipGap;
+ }
+ }
+ noticeText.Draw(renderer_);
+ if(storage_)
+ {
+ storage_->Update();
+ storage_->Draw(GridRight, titleText_.rect.y + titleText_.rect.h / 2);
+ }
+
+ {
+ for(int row = 0; row < GridRowsVisible; ++row)
+ {
+ int rowStart = themeStart_ + (gridTopRow+row)*GridCols;
+ if(rowStart > lastThemeIndex)
+ break;
+ int y = GridTop + row*GridRowPitch;
+ for(int col = 0; col < GridCols; ++col)
+ {
+ int idx = rowStart + col;
+ if(idx > lastThemeIndex)
+ break;
+ if(commands_[idx].command.empty())
+ continue; // reserved blank cell, not a tile
+ int x = GridLeft + col*(TileW+GridGap);
+ bool selected = idx == currentCommandId_;
+ SDL_Rect tileRect{ x, y, TileW, TileH };
+ DrawRoundedFillRect(renderer_, tileRect, UiTheme::SelectedRowBg, UiTheme::BoxRadius);
+ EnsureTileImage(idx);
+ std::vector & runFrames = tileRunFrames[idx];
+ Texture & tileArt = runFrames.size() > 1
+ ? runFrames[(SDL_GetTicks() / 100) % runFrames.size()]
+ : tileImages[idx];
+ Texture & author = tileAuthors[idx];
+ if(tileArt.rect.w > 0 || author.rect.w > 0)
+ {
+ // clip to the tile, then mask the clip's square corners back to rounded
+ SDL_RenderSetClipRect(renderer_, &tileRect);
+ if(tileArt.rect.w > 0)
+ {
+ if(tileFitContain[idx])
+ // 10px margin keeps clear of the rounded-corner mask below
+ FitCentered(tileArt, x, y, TileW, TileH, 10);
+ else
+ {
+ FitCover(tileArt, x, y, TileW, TileH, 0);
+ if(items_[idx].previewAlignTop)
+ tileArt.rect.y = y;
+ }
+ tileArt.Draw(renderer_);
+ }
+ if(author.rect.w > 0)
+ {
+ const int stripH = author.rect.h + 6;
+ const SDL_Rect strip{ x, y + TileH - stripH, TileW, stripH };
+ SDL_SetRenderDrawBlendMode(renderer_, SDL_BLENDMODE_BLEND);
+ SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 0xB4);
+ SDL_RenderFillRect(renderer_, &strip);
+ SDL_SetRenderDrawBlendMode(renderer_, SDL_BLENDMODE_NONE);
+ author.rect.x = x + (TileW - author.rect.w) / 2;
+ author.rect.y = strip.y + (stripH - author.rect.h) / 2;
+ author.Draw(renderer_);
+ }
+ SDL_RenderSetClipRect(renderer_, nullptr);
+ DrawRoundedCornerMask(renderer_, tileRect, UiTheme::SelectedRowBg, UiTheme::BoxRadius);
+ }
+ Color stroke = UiTheme::Border;
+ int strokeW = UiTheme::BorderWidth;
+ if(selected)
+ {
+ stroke = UiTheme::Accent;
+ strokeW = 3;
+ }
+ else if(isActiveTheme[idx])
+ {
+ stroke = ActiveThemeBorder;
+ strokeW = 3;
+ }
+ DrawStrokeRect(renderer_, tileRect, stroke, strokeW, UiTheme::BoxRadius);
+ if(items_[idx].isNew)
+ {
+ const SDL_Rect pill{ x + 10, y + 10, newBadge.rect.w + 2 * newBadgePad, newBadge.rect.h + 4 };
+ DrawRoundedFillRect(renderer_, pill, UiTheme::Accent, pill.h / 2);
+ newBadge.rect.x = pill.x + newBadgePad;
+ newBadge.rect.y = pill.y + 2;
+ newBadge.Draw(renderer_);
+ }
+ if(!tileHideLabel[idx])
+ {
+ tileLabels[idx].rect.x = x + (TileW - tileLabels[idx].rect.w) / 2;
+ tileLabels[idx].rect.y = y + TileH + (LabelH - tileLabels[idx].rect.h) / 2;
+ tileLabels[idx].Draw(renderer_);
+ }
+ }
+ }
+
+ if(gridTopRow > 0)
+ gridScrollUp.Draw(renderer_);
+ if(gridTopRow + GridRowsVisible < totalRows)
+ gridScrollDown.Draw(renderer_, SDL_FLIP_VERTICAL);
+ }
+
+ if(FinishFrame())
+ break;
+ }
+
+ return 0;
+}
+
+int ThemeManagerApp::RunListLayout()
+{
+ SDL_Rect selectedRowRect{ UiTheme::ListX, UiTheme::RowFirstY - 2, UiTheme::ListContentRightX - UiTheme::ListX, 0 };
+
+ // B runs Back/Exit, as in the grid
+ Badge badgeB = badges_->Make("B", pinnedStartIndex_ < (int)commands_.size() ? Translate(commands_[pinnedStartIndex_].name) : "", UiTheme::BadgeBDark, UiTheme::BadgeB);
+
+ const int RowGlyphSize = 16;
+ const int RowTextGapPx = 16;
+ const int ChildIndent = 4*16;
+ auto BuildRowTextures = [&]()
+ {
+ for(Command & c : commands_)
+ {
+ int textX = UiTheme::RowTextX + (c.child ? ChildIndent : 0);
+ std::string label = TruncateToWidth(Translate(c.name), RowGlyphSize, UiTheme::RowControlRightX - RowTextGapPx - textX);
+ c.texture = Texture(label, RowGlyphSize, renderer_, textX, 0, false, ToAbgr(UiTheme::Text), true);
+ }
+ };
+ BuildRowTextures();
+
+ const int modernRowPitch = std::max(UiTheme::RowPitch, GetTTFLineHeight(RowGlyphSize));
+ const int DisplayItemCount = std::max(1, (UiTheme::FooterDividerY - UiTheme::ListBottomMargin - UiTheme::RowFirstY) / modernRowPitch);
+
+ int topListItemNumber = pinnedStartIndex_ + 1; // forces the first SetCurrentCommand to lay out row positions
+ bool rowsReloaded = false;
+ std::shared_ptr PreviewImage;
+ auto SetCurrentCommand = [&] (int newCommandId)
+ {
+ currentCommandId_ = newCommandId;
+ const Command & currentCommand = commands_[currentCommandId_];
+
+ // pinned rows don't affect scroll position
+ int scrollTarget = std::min(currentCommandId_, std::max(0, pinnedStartIndex_-1));
+
+ bool updateCommandYPos = rowsReloaded;
+ rowsReloaded = false;
+ if(scrollTarget < topListItemNumber)
+ {
+ topListItemNumber = scrollTarget;
+ updateCommandYPos = true;
+ }
+ else if(scrollTarget >= topListItemNumber+DisplayItemCount)
+ {
+ topListItemNumber = scrollTarget-DisplayItemCount+1;
+ updateCommandYPos = true;
+ }
+
+ // a short list never scrolls, or a restored focus would hide earlier rows
+ const int maxTopListItemNumber = std::max(0, pinnedStartIndex_-DisplayItemCount);
+ if(topListItemNumber > maxTopListItemNumber)
+ {
+ topListItemNumber = maxTopListItemNumber;
+ updateCommandYPos = true;
+ }
+ if(updateCommandYPos)
+ {
+ int y = UiTheme::RowFirstY;
+ for(int i = 0, count = std::min(DisplayItemCount, pinnedStartIndex_-topListItemNumber); i < count; ++i)
+ {
+ Texture & rowTexture = commands_[i+topListItemNumber].texture;
+ rowTexture.rect.y = y + (modernRowPitch - rowTexture.rect.h) / 2 + UiTheme::RowTextYNudge;
+ y += modernRowPitch;
+ }
+ }
+
+ selectedRowRect.y = currentCommand.texture.rect.y - 2;
+ selectedRowRect.h = currentCommand.texture.rect.h + 4;
+
+ if(currentCommand.previewImage.size())
+ {
+ SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, currentCommand.previewNearest ? "0" : "1");
+ PreviewImage = std::make_shared(currentCommand.previewImage, renderer_, 0, 0);
+ SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "0");
+ FitCentered(*PreviewImage, UiTheme::DetailX, UiTheme::PreviewBoxY, UiTheme::DetailW, UiTheme::PreviewBoxH, UiTheme::ContentPadding);
+ }
+ else
+ PreviewImage.reset();
+ SaveFocusState();
+ };
+ int restoredId = LoadFocusIndex();
+ SetCurrentCommand(restoredId >= 0 && restoredId < pinnedStartIndex_ ? restoredId : 0);
+
+ auto DrawRow = [&](Command & rowCommand, bool isLastOverall)
+ {
+ bool selected = &rowCommand == &commands_[currentCommandId_];
+ rowCommand.texture.Draw(renderer_);
+ if(rowCommand.isToggle)
+ {
+ Texture & rowSwitch = rowCommand.stateOn ? switchOn_ : switchOff_;
+ rowSwitch.rect.x = UiTheme::SwitchRightX - UiTheme::SwitchW;
+ rowSwitch.rect.y = rowCommand.texture.rect.y + (rowCommand.texture.rect.h - UiTheme::SwitchH) / 2;
+ rowSwitch.Draw(renderer_);
+ }
+ // skip for separators and the last row - nothing to separate there
+ if(!selected && !rowCommand.command.empty() && !isLastOverall)
+ DrawHLine(renderer_, UiTheme::ListX, UiTheme::ListContentRightX, rowCommand.texture.rect.y + rowCommand.texture.rect.h + 3, UiTheme::Border);
+ };
+
+ auto DrawChrome = [&]()
+ {
+ DrawChromeCommon();
+ DrawSectionTitle();
+ DrawFooterBadges(badgeB);
+
+ DrawRoundedFillRect(renderer_, selectedRowRect, UiTheme::SelectedRowBg, UiTheme::BoxRadius);
+ DrawStrokeRect(renderer_, selectedRowRect, UiTheme::Accent, 2, UiTheme::BoxRadius);
+
+ if(PreviewImage.get())
+ {
+ SDL_Rect previewBox{ UiTheme::DetailX, UiTheme::PreviewBoxY, UiTheme::DetailW, UiTheme::PreviewBoxH };
+ DrawStrokeRect(renderer_, previewBox, UiTheme::Border, UiTheme::BorderWidth, UiTheme::BoxRadius);
+ PreviewImage->Draw(renderer_);
+ }
+ };
+
+ for(;;)
+ {
+ FrameEvent frameEvent = PollFrameEvents();
+ if(frameEvent == FrameEvent::Quit)
+ return 0;
+ if(frameEvent == FrameEvent::PowerButtonPressed)
+ {
+ ResumeUnderlyingUi();
+ break;
+ }
+
+ if(controller_->GetButtonStatus(A) || controller_->GetButtonStatus(START))
+ {
+ const bool isInternalToggle = commands_[currentCommandId_].runInternal && commands_[currentCommandId_].isToggle;
+ const std::string focusedName = commands_[currentCommandId_].name;
+ if(ActivateCommand(commands_[currentCommandId_]))
+ break;
+ if(isInternalToggle)
+ {
+ ReloadCommands();
+ BuildRowTextures();
+ int focusId = std::min(currentCommandId_, std::max(0, pinnedStartIndex_-1));
+ for(int i = 0; i < pinnedStartIndex_; ++i)
+ if(commands_[i].name == focusedName)
+ {
+ focusId = i;
+ break;
+ }
+ rowsReloaded = true;
+ SetCurrentCommand(focusId);
+ }
+ }
+ else if(controller_->HeldRepeat(UP) && pinnedStartIndex_ > 0)
+ {
+ // bounded for an all-separator list; Back/Exit isn't a row
+ int newCommandId = currentCommandId_;
+ for(int tries = 0; tries < pinnedStartIndex_; ++tries)
+ {
+ newCommandId = (newCommandId-1+pinnedStartIndex_)%pinnedStartIndex_;
+ if(commands_[newCommandId].command.size() != 0)
+ break;
+ }
+ SetCurrentCommand(newCommandId);
+ }
+ else if(controller_->HeldRepeat(DOWN) && pinnedStartIndex_ > 0)
+ {
+ int newCommandId = currentCommandId_;
+ for(int tries = 0; tries < pinnedStartIndex_; ++tries)
+ {
+ newCommandId = (newCommandId+1)%pinnedStartIndex_;
+ if(commands_[newCommandId].command.size() != 0)
+ break;
+ }
+ SetCurrentCommand(newCommandId);
+ }
+ {
+ bool tapped = false;
+ UpdateBButton(tapped);
+ // quick tap runs Back/Exit
+ if(tapped && pinnedStartIndex_ < (int)commands_.size() && ActivateCommand(commands_[pinnedStartIndex_]))
+ break;
+ }
+
+ DrawChrome();
+
+ for(int i = 0, count = std::min(DisplayItemCount, pinnedStartIndex_-topListItemNumber); i < count; ++i)
+ DrawRow(commands_[i+topListItemNumber], (i+topListItemNumber) == pinnedStartIndex_-1);
+
+ // bounded by pinnedStartIndex_, not commands_.size() - pinned rows need no scroll
+ scrollArrows_->Draw(topListItemNumber != 0, (topListItemNumber + DisplayItemCount) < pinnedStartIndex_);
+
+ if(FinishFrame())
+ break;
+ }
+
+ return 0;
+}
diff --git a/src/theme_manager_app.h b/src/theme_manager_app.h
new file mode 100644
index 0000000..d2d9c66
--- /dev/null
+++ b/src/theme_manager_app.h
@@ -0,0 +1,99 @@
+/**
+ * Copyright (c) 2026 DefKorns (https://defkorns.github.io/LICENSE)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ */
+
+#ifndef THEME_MANAGER_APP_H_
+#define THEME_MANAGER_APP_H_
+
+#include "command_loader.h"
+#include "command.h"
+#include "storage_indicator.h"
+#include "framework/badge.h"
+#include "framework/controller.h"
+#include "framework/dialog.h"
+#include "framework/list_screen.h"
+#include "framework/sdl_helper.h"
+
+#include
+#include
+#include
+
+class ThemeManagerApp
+{
+public:
+ ThemeManagerApp(std::string optionsLocation, AppOptions options, std::vector commands, std::vector items);
+
+ int Run()
+ {
+ if(!options_.folderThemesDir.empty())
+ return RunFolderThemesLayout();
+ return options_.gridLayout ? RunGridLayout() : RunListLayout();
+ }
+
+private:
+ enum class FrameEvent { Continue, Quit, PowerButtonPressed };
+
+ std::string optionsLocation_;
+ AppOptions options_;
+ std::vector commands_;
+ std::vector items_;
+ int pinnedStartIndex_ = 0; // BACK/EXIT sort last - pinned near the footer instead of scrolling
+ int themeStart_ = 0; // grid layout only: first non-fixed-action row
+
+ // unique_ptr so construction can be deferred past the CHECKPOINT logs below
+ std::unique_ptr sdlContext_;
+ SDL_Renderer * renderer_ = nullptr;
+ std::unique_ptr controller_;
+ int currentCommandId_ = 0;
+ Color bg_{};
+
+ Texture switchOn_, switchOff_;
+ Texture titleText_;
+ std::unique_ptr chrome_;
+ std::unique_ptr scrollArrows_;
+ std::unique_ptr badges_;
+ std::unique_ptr storage_;
+ bool deleteRequested_ = false;
+ bool overwriteRequested_ = false;
+ Badge badgeA_;
+ Badge badgeHold_;
+ int badgeRowRightEdge_ = 0; // x just left of badge A - fixed once badgeA_ is built
+
+ static constexpr unsigned int BHoldThresholdMs = 1000;
+ bool bWasHeld_ = false;
+ bool bHoldFired_ = false;
+
+ void ComputePinnedAndThemeRanges();
+
+ int DrawBadge(Badge & badge, int rightEdgeX);
+ void DrawFooterBadges(Badge & badgeB);
+ bool Confirm(const ScreenSnapshot & background, const std::string & title);
+ bool ConfirmDelete(const ScreenSnapshot & background);
+ bool FinishFrame();
+ void DrawChromeCommon();
+ void DrawSectionTitle();
+ Texture MakeScrollArrow(int x, int y) const;
+
+ FrameEvent PollFrameEvents();
+ bool ActivateCommand(Command & cmd); // true if the caller's loop should break
+ void ReloadCommands();
+ void RunDownload(Command & cmd);
+ void UpdateBButton(bool & tapped);
+
+ void ResumeUnderlyingUi() const; // nothing else resumes it on a power-button exit
+
+ std::string FocusStatePath() const;
+ void SaveFocusState() const;
+ int LoadFocusIndex() const; // -1 if none saved
+
+ int RunGridLayout();
+ int RunListLayout();
+ int RunFolderThemesLayout();
+};
+
+#endif
diff --git a/theme-selector-workspace.code-workspace b/theme-selector-workspace.code-workspace
index 5c989ec..1e517df 100644
--- a/theme-selector-workspace.code-workspace
+++ b/theme-selector-workspace.code-workspace
@@ -1,20 +1,22 @@
{
- "folders": [
+ "folders": [
{
- "name": "om_theme-selectorv2",
+ "name": "om_theme-selector",
"path": "."
},
- {
- "name": "out",
- "path": "out"
- }
+ // {
+ // "name": "out",
+ // "path": "out"
+ // }
],
"settings": {
"files.autoSave": "off",
- // "terminal.integrated.shell.windows": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
- "terminal.integrated.shell.windows": "C:\\Program Files\\Git\\bin\\bash.exe",
-
- "shellformat.path": "C:\\bin\\shfmt_v3.2.4_windows_amd64.exe",
+ "[bash]": {
+ "editor.formatOnSave": true,
+ "editor.tabSize": 2,
+ "editor.insertSpaces": true,
+ "editor.detectIndentation": false
+ },
"window.zoomLevel": 0,
"files.associations": {
"b[0-9]*": "shellscript",
@@ -27,18 +29,40 @@
"c[0-9]*": "properties"
},
"explorer.confirmDragAndDrop": false,
- "prettier.disableLanguages": [
- "html"
- ],
"git.confirmSync": false,
"vsicons.projectDetection.autoReload": true,
"vsicons.dontShowNewVersionMessage": true,
"explorer.confirmDelete": false,
- "terminal.integrated.rendererType": "dom",
+ // "terminal.integrated.rendererType": "dom",
"workbench.iconTheme": "vscode-icons",
- "beautify.config": "",
- "http.proxyAuthorization": null,
- "workbench.colorTheme": "Flatland Monokai",
-
+ // "beautify.config": "",
+ // "http.proxyAuthorization": null,
+ //
+ "editor.tabSize": 2,
+ // "editor.wordWrap": "wordWrapColumn",
+ // "editor.wordWrapColumn": 80,
+ // "editor.fontSize": 16,
+ "editor.insertSpaces": true,
+ "editor.detectIndentation": false,
+ "editor.renderWhitespace": "all",
+ "shellcheck.enable": true,
+ "shellcheck.useWorkspaceRootAsCwd": true,
+ "shellcheck.run": "onSave",
+ "shellcheck.enableQuickFix": true,
+ "shellcheck.customArgs": [
+ "-x"
+ ],
+ "shellcheck.exclude": [
+ "3046",
+ "3014",
+ "3046",
+ "3051"
+ ],
+ // "shellformat.flag": "-i 2 -ci -sr -bn",
+ "shfmtIde.flag": "-p -bn -ci -i=2 -ln=posix/sh",
+ "shfmt.executableArgs": [
+ "-i=2",
+ "-ci"
+ ]
}
-}
\ No newline at end of file
+}
diff --git a/mod/etc/options_menu/themes/settings/scripts/om_audio b/tools/license-header.txt
similarity index 66%
rename from mod/etc/options_menu/themes/settings/scripts/om_audio
rename to tools/license-header.txt
index cce5402..e32bdde 100644
--- a/mod/etc/options_menu/themes/settings/scripts/om_audio
+++ b/tools/license-header.txt
@@ -1,6 +1,4 @@
-#!/bin/sh
-# shellcheck disable=SC1090,SC2039,SC2086,SC2154
-# Copyright (c) 2022 DefKorns (https://defkorns.github.io/LICENSE)
+# Copyright (c) {YEAR} DefKorns (https://defkorns.github.io/LICENSE)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -15,9 +13,3 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-source $mountpoint/etc/options_menu/themes/scripts/om_vars
-script_init
-
-audioSettings "$1"
-
-$optionsMenu/options --commandPath $omModSettingsCmds/ --scriptPath $omModSettingsScripts --title "$omTitle" &
diff --git a/tools/update-license-headers.sh b/tools/update-license-headers.sh
new file mode 100644
index 0000000..6b16a49
--- /dev/null
+++ b/tools/update-license-headers.sh
@@ -0,0 +1,69 @@
+#!/bin/sh
+# Stamps the canonical GPL header (tools/license-header.txt) into the top of
+# every project script, replacing only the "Copyright (c) ... gnu.org" block.
+# Everything before it (shebang, shellcheck disable comments) and after it
+# (source/script_init lines, code) is left untouched.
+#
+# Usage:
+# sh tools/update-license-headers.sh [YEAR]
+# YEAR defaults to the current year.
+set -eu
+
+script_dir="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)"
+repo_root="$(CDPATH='' cd -- "$script_dir/.." && pwd)"
+header_file="$script_dir/license-header.txt"
+year="${1:-$(date +%Y)}"
+
+if [ ! -f "$header_file" ]; then
+ echo "missing $header_file" >&2
+ exit 1
+fi
+
+files=$(
+ {
+ [ -f "$repo_root/mod/install" ] && echo "$repo_root/mod/install"
+ [ -f "$repo_root/mod/uninstall" ] && echo "$repo_root/mod/uninstall"
+ find "$repo_root/mod" -type f -name 'om_*'
+ } | sort -u | xargs grep -lE '^#!/bin/(sh|bash)' 2>/dev/null
+)
+
+updated=0
+for f in $files; do
+ if grep -q '^#[[:space:]]*Copyright (c)' "$f"; then
+ tmp="$f.tmp.$$"
+ awk -v year="$year" -v hdrfile="$header_file" '
+ BEGIN {
+ n = 0
+ while ((getline line < hdrfile) > 0) hdr[n++] = line
+ close(hdrfile)
+ }
+ skip_blank {
+ skip_blank = 0
+ if ($0 ~ /^#[ \t]*$/) next
+ }
+ !started && /^#[ \t]*Copyright \(c\)/ {
+ started = 1
+ inblock = 1
+ for (i = 0; i < n; i++) {
+ l = hdr[i]
+ gsub(/\{YEAR\}/, year, l)
+ print l
+ }
+ next
+ }
+ inblock {
+ if ($0 ~ /gnu\.org\/licenses/) {
+ inblock = 0
+ skip_blank = 1
+ }
+ next
+ }
+ { print }
+ ' "$f" >"$tmp"
+ mv "$tmp" "$f"
+ updated=$((updated + 1))
+ echo "updated: ${f#"$repo_root"/}"
+ fi
+done
+
+echo "$updated file(s) updated to copyright year $year"
diff --git a/vendor/OptionsMenu b/vendor/OptionsMenu
new file mode 160000
index 0000000..f37f1dc
--- /dev/null
+++ b/vendor/OptionsMenu
@@ -0,0 +1 @@
+Subproject commit f37f1dc0c28f09b581c6466ccf9e41b5d20f845b
diff --git a/vendor/hmod-build b/vendor/hmod-build
new file mode 160000
index 0000000..692c97a
--- /dev/null
+++ b/vendor/hmod-build
@@ -0,0 +1 @@
+Subproject commit 692c97ac0d314ee9d0d64f3cf492564351d0ff2c
diff --git a/vendor/pngpack b/vendor/pngpack
new file mode 160000
index 0000000..a5d5bec
--- /dev/null
+++ b/vendor/pngpack
@@ -0,0 +1 @@
+Subproject commit a5d5bec183cd93a9bd29df5c6cd05a8644474bc2