diff --git a/docs/list-views.md b/docs/list-views.md new file mode 100644 index 00000000..a46d0b75 --- /dev/null +++ b/docs/list-views.md @@ -0,0 +1,167 @@ +# List Views + +This document describes the list view system in minui (the launcher). + +## Overview + +List views are the primary UI element in minui, used for ROM browsing, tools, recents, and collections. + +**Implementation:** `workspace/all/minui/minui.c` + +## Current Features + +### Sorting + +ROM lists are sorted using natural sort with No-Intro conventions: + +| Feature | Description | +|---------|-------------| +| Natural sort | Numbers compared by value: "Game 2" < "Game 10" | +| Article skipping | "The Legend of Zelda" sorts under "L", not "T" | +| Case insensitive | "mario" and "Mario" sort together | + +Supported articles for sort skipping: "The ", "A ", "An " (must have trailing space) + +### Navigation + +| Feature | Description | +|---------|-------------| +| Single item scroll | D-pad up/down moves one item at a time | +| Page jump | D-pad left/right jumps by visible row count | +| Alphabetic jump | L1/R1 jumps to next/previous letter group (A-Z, #) | +| Wrap-around | Scrolling past bottom wraps to top and vice versa | +| Button repeat | 300ms initial delay, then 100ms repeat interval | + +### Selection & Highlighting + +| Feature | Description | +|---------|-------------| +| Selection pill | White rounded rectangle behind selected item | +| Text color flip | Selected text changes from white to black | + +### Text Display + +| Feature | Description | +|---------|-------------| +| Text truncation | Names exceeding available width get ellipsis (...) | +| Display name cleaning | Strips file extensions, region codes, parenthetical metadata | +| Article fixing | "Legend of Zelda, The" displays as "The Legend of Zelda" | +| Sorting prefix removal | Strips "NNN) " prefixes used for custom sort order | +| Duplicate disambiguation | Shows filename or emulator name when display names collide | + +### Display Name Aliasing (map.txt) + +Custom display names can be defined via `map.txt` files using tab-delimited format: + +``` +mario.gb Super Mario Land +tetris.gb Tetris DX +unwanted.gb .hidden +``` + +| Location | Purpose | +|----------|---------| +| `/Roms/GB/map.txt` | Aliases for ROMs in that system folder | +| `/Roms/map.txt` | Aliases for system folder names at root | +| `/Collections/map.txt` | Aliases for collection entries | + +**Special behaviors:** +- Alias starting with `.` hides the entry from the list +- List re-sorts alphabetically after aliases are applied +- Aliases are also used by minarch for in-game title display + +### Thumbnails + +| Feature | Description | +|---------|-------------| +| Thumbnail display | Shows image on right side when ROM is selected | +| Path convention | ROM at `/Roms/GB/game.gb` → thumbnail at `/Roms/GB/.res/game.gb.png` | +| Scaling | Preserves aspect ratio, max 50% screen width | +| Caching | Thumbnail cached to avoid reloading on every frame | + +### List Types + +| List | Description | +|------|-------------| +| ROM list | Files within a system folder | +| Root directory | System folders, Tools, Recently Played, Collections | +| Recently Played | Last 24 games launched (stored in `.minui/recent.txt`) | +| Collections | Custom ROM lists defined by `.txt` files | +| Multi-disc | Disc selection for games with `.m3u` playlists | + +### Hardware Status + +| Feature | Description | +|---------|-------------| +| Battery indicator | Shows current charge level (top-right) | +| Brightness indicator | Shows when adjusting brightness | +| Volume indicator | Shows when adjusting volume | + +### Button Hints + +Context-sensitive button labels displayed at bottom of screen: +- OPEN / SELECT - A button action +- BACK - B button action +- RESUME - X button when save state exists +- Action-specific labels per menu context + +### Visual Styling + +| Property | Value | +|----------|-------| +| Indentation | Tabs (4-wide) | +| Font sizes | Large (16pt), Medium (14pt), Small (12pt), Tiny (10pt) | +| Colors | White text on dark background, black text when selected | +| Spacing | DP-scaled padding and margins for cross-device consistency | +| Row height | Dynamically calculated based on screen PPI | + +## Implementation Details + +### Scrolling Window + +Lists maintain a visible window with `start` and `end` indices: + +```c +// When selection moves below visible area +if (selected >= end) { + start++; + end++; +} + +// When selection moves above visible area +if (selected < start) { + start--; + end--; +} +``` + +### Alphabetic Index + +Each directory builds an index array mapping letters (A-Z, #) to entry positions: + +```c +// getIndexChar() returns 0 for non-alpha, 1-26 for A-Z +int alpha = getIndexChar(entry->name); +if (alphas[alpha] == -1) { + alphas[alpha] = entry_index; // First entry for this letter +} +``` + +L1/R1 navigation uses this index to jump between letter groups. + +### Display Name Processing + +Names go through several transformations: + +1. `getDisplayName()` - Strips extensions, region codes, parentheticals, fixes articles +2. `trimSortingMeta()` - Removes "NNN) " prefixes +3. `map.txt` lookup - Applies custom aliases +4. `getUniqueName()` - Generates disambiguator for duplicates + +### Sorting Algorithm + +Sorting uses `strnatcasecmp()` which provides: + +1. Natural number ordering ("Game 2" < "Game 10") +2. Article skipping for sort ("The Zelda" sorts under "Z") +3. Case-insensitive comparison diff --git a/makefile.dev b/makefile.dev index 4e5ff064..7e838608 100644 --- a/makefile.dev +++ b/makefile.dev @@ -62,11 +62,13 @@ LDFLAGS = -ldl -flto $(SDL_LIBS) -lSDL2 -lSDL2_image -lSDL2_ttf -lpthread -lm -l MINUI_SOURCE = workspace/all/minui/minui.c \ workspace/all/common/scaler.c \ workspace/all/common/utils.c \ + workspace/all/common/nointro_parser.c \ workspace/all/common/api.c \ workspace/all/common/log.c \ workspace/all/common/collections.c \ workspace/all/common/pad.c \ workspace/all/common/gfx_text.c \ + workspace/all/common/str_compare.c \ workspace/desktop/platform/platform.c # Header files (dependencies) diff --git a/makefile.qa b/makefile.qa index 254de8b7..2219d7b1 100644 --- a/makefile.qa +++ b/makefile.qa @@ -188,7 +188,7 @@ TEST_INCLUDES = -I tests/support -I tests/support/unity -I workspace/all/common TEST_UNITY = tests/support/unity/unity.c # All test executables (built from tests/unit/ and tests/integration/) -TEST_EXECUTABLES = tests/utils_test tests/pad_test tests/collections_test tests/gfx_text_test tests/audio_resampler_test tests/minarch_paths_test tests/minui_utils_test tests/m3u_parser_test tests/minui_file_utils_test tests/map_parser_test tests/collection_parser_test tests/recent_parser_test tests/recent_writer_test tests/directory_utils_test tests/binary_file_utils_test tests/ui_layout_test tests/integration_workflows_test +TEST_EXECUTABLES = tests/utils_test tests/nointro_parser_test tests/pad_test tests/collections_test tests/gfx_text_test tests/audio_resampler_test tests/minarch_paths_test tests/minui_utils_test tests/m3u_parser_test tests/minui_file_utils_test tests/map_parser_test tests/collection_parser_test tests/recent_parser_test tests/recent_writer_test tests/directory_utils_test tests/binary_file_utils_test tests/ui_layout_test tests/str_compare_test tests/integration_workflows_test # Default targets: use Docker for consistency test: docker-test @@ -209,22 +209,27 @@ tests/log_test: tests/unit/all/common/test_log.c workspace/all/common/log.c $(TE @$(CC) $(TEST_INCLUDES) $(TEST_CFLAGS) -D_DEFAULT_SOURCE -o $@ $^ -lpthread # Build comprehensive utils tests -tests/utils_test: tests/unit/all/common/test_utils.c workspace/all/common/utils.c workspace/all/common/log.c $(TEST_UNITY) +tests/utils_test: tests/unit/all/common/test_utils.c workspace/all/common/utils.c workspace/all/common/nointro_parser.c workspace/all/common/log.c $(TEST_UNITY) @echo "Building comprehensive utils tests..." @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) +# Build No-Intro name parser tests +tests/nointro_parser_test: tests/unit/all/common/test_nointro_parser.c workspace/all/common/nointro_parser.c workspace/all/common/utils.c workspace/all/common/nointro_parser.c workspace/all/common/log.c $(TEST_UNITY) + @echo "Building No-Intro name parser tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) + # Build PAD (input) tests tests/pad_test: tests/unit/all/common/test_api_pad.c workspace/all/common/pad.c $(TEST_UNITY) @echo "Building PAD input tests..." @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) # Build collections (Array/Hash) tests -tests/collections_test: tests/unit/all/common/test_collections.c workspace/all/common/collections.c workspace/all/common/utils.c workspace/all/common/log.c $(TEST_UNITY) +tests/collections_test: tests/unit/all/common/test_collections.c workspace/all/common/collections.c workspace/all/common/utils.c workspace/all/common/nointro_parser.c workspace/all/common/log.c $(TEST_UNITY) @echo "Building collections tests..." @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -D_POSIX_C_SOURCE=200809L # Build GFX text utility tests (uses fff for TTF mocking) -tests/gfx_text_test: tests/unit/all/common/test_gfx_text.c workspace/all/common/gfx_text.c workspace/all/common/utils.c workspace/all/common/log.c tests/support/sdl_fakes.c $(TEST_UNITY) +tests/gfx_text_test: tests/unit/all/common/test_gfx_text.c workspace/all/common/gfx_text.c workspace/all/common/utils.c workspace/all/common/nointro_parser.c workspace/all/common/log.c tests/support/sdl_fakes.c $(TEST_UNITY) @echo "Building GFX text utility tests..." @$(CC) -o $@ $^ $(TEST_INCLUDES) -I tests/support/fff $(TEST_CFLAGS) -DUNIT_TEST_BUILD @@ -239,42 +244,42 @@ tests/minarch_paths_test: tests/unit/all/common/test_minarch_paths.c workspace/a @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) # Build LessUI launcher utility tests (pure string logic) -tests/minui_utils_test: tests/unit/all/common/test_minui_utils.c workspace/all/common/minui_utils.c workspace/all/common/utils.c workspace/all/common/log.c $(TEST_UNITY) +tests/minui_utils_test: tests/unit/all/common/test_minui_utils.c workspace/all/common/minui_utils.c workspace/all/common/utils.c workspace/all/common/nointro_parser.c workspace/all/common/log.c $(TEST_UNITY) @echo "Building LessUI launcher utility tests..." @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) # Build M3U parser tests (uses file mocking with GCC --wrap, Docker-only) -tests/m3u_parser_test: tests/unit/all/common/test_m3u_parser.c workspace/all/common/m3u_parser.c workspace/all/common/utils.c workspace/all/common/log.c tests/support/fs_mocks.c $(TEST_UNITY) +tests/m3u_parser_test: tests/unit/all/common/test_m3u_parser.c workspace/all/common/m3u_parser.c workspace/all/common/utils.c workspace/all/common/nointro_parser.c workspace/all/common/log.c tests/support/fs_mocks.c $(TEST_UNITY) @echo "Building M3U parser tests..." @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -D_POSIX_C_SOURCE=200809L -Wl,--wrap=exists -Wl,--wrap=fopen -Wl,--wrap=fclose -Wl,--wrap=fgets # Build LessUI file utility tests (uses file mocking with GCC --wrap, Docker-only) -tests/minui_file_utils_test: tests/unit/all/common/test_minui_file_utils.c workspace/all/common/minui_file_utils.c workspace/all/common/utils.c workspace/all/common/log.c tests/support/fs_mocks.c $(TEST_UNITY) +tests/minui_file_utils_test: tests/unit/all/common/test_minui_file_utils.c workspace/all/common/minui_file_utils.c workspace/all/common/utils.c workspace/all/common/nointro_parser.c workspace/all/common/log.c tests/support/fs_mocks.c $(TEST_UNITY) @echo "Building LessUI file utility tests..." @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -Wl,--wrap=exists -Wl,--wrap=fopen -Wl,--wrap=fclose -Wl,--wrap=fgets # Build map.txt parser tests (uses file mocking with GCC --wrap, Docker-only) -tests/map_parser_test: tests/unit/all/common/test_map_parser.c workspace/all/common/map_parser.c workspace/all/common/utils.c workspace/all/common/log.c tests/support/fs_mocks.c $(TEST_UNITY) +tests/map_parser_test: tests/unit/all/common/test_map_parser.c workspace/all/common/map_parser.c workspace/all/common/utils.c workspace/all/common/nointro_parser.c workspace/all/common/log.c tests/support/fs_mocks.c $(TEST_UNITY) @echo "Building map.txt parser tests..." @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -Wl,--wrap=exists -Wl,--wrap=fopen -Wl,--wrap=fclose -Wl,--wrap=fgets # Build collection parser tests (uses file mocking with GCC --wrap, Docker-only) -tests/collection_parser_test: tests/unit/all/common/test_collection_parser.c workspace/all/common/collection_parser.c workspace/all/common/utils.c workspace/all/common/log.c tests/support/fs_mocks.c $(TEST_UNITY) +tests/collection_parser_test: tests/unit/all/common/test_collection_parser.c workspace/all/common/collection_parser.c workspace/all/common/utils.c workspace/all/common/nointro_parser.c workspace/all/common/log.c tests/support/fs_mocks.c $(TEST_UNITY) @echo "Building collection parser tests..." @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -D_POSIX_C_SOURCE=200809L -Wl,--wrap=exists -Wl,--wrap=fopen -Wl,--wrap=fclose -Wl,--wrap=fgets # Build recent.txt file tests (uses file mocking with GCC --wrap, Docker-only) -tests/recent_parser_test: tests/unit/all/common/test_recent_parser.c workspace/all/common/recent_file.c workspace/all/common/utils.c workspace/all/common/log.c tests/support/fs_mocks.c $(TEST_UNITY) +tests/recent_parser_test: tests/unit/all/common/test_recent_parser.c workspace/all/common/recent_file.c workspace/all/common/utils.c workspace/all/common/nointro_parser.c workspace/all/common/log.c tests/support/fs_mocks.c $(TEST_UNITY) @echo "Building recent.txt parser tests..." @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -D_POSIX_C_SOURCE=200809L -Wl,--wrap=exists -Wl,--wrap=fopen -Wl,--wrap=fclose -Wl,--wrap=fgets # Build recent.txt writer tests (uses real temp files, no --wrap needed) -tests/recent_writer_test: tests/unit/all/common/test_recent_writer.c workspace/all/common/recent_file.c workspace/all/common/utils.c workspace/all/common/log.c $(TEST_UNITY) +tests/recent_writer_test: tests/unit/all/common/test_recent_writer.c workspace/all/common/recent_file.c workspace/all/common/utils.c workspace/all/common/nointro_parser.c workspace/all/common/log.c $(TEST_UNITY) @echo "Building recent.txt writer tests..." @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -D_POSIX_C_SOURCE=200809L # Build directory utility tests (uses real temp directories, no --wrap needed) -tests/directory_utils_test: tests/unit/all/common/test_directory_utils.c workspace/all/common/minui_file_utils.c workspace/all/common/utils.c workspace/all/common/log.c $(TEST_UNITY) +tests/directory_utils_test: tests/unit/all/common/test_directory_utils.c workspace/all/common/minui_file_utils.c workspace/all/common/utils.c workspace/all/common/nointro_parser.c workspace/all/common/log.c $(TEST_UNITY) @echo "Building directory utility tests..." @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -D_DEFAULT_SOURCE @@ -288,6 +293,11 @@ tests/ui_layout_test: tests/unit/all/common/test_ui_layout.c workspace/all/commo @echo "Building UI layout / DP system tests..." @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -lm +# Build string comparison tests (natural sort, pure algorithm) +tests/str_compare_test: tests/unit/all/common/test_str_compare.c workspace/all/common/str_compare.c $(TEST_UNITY) + @echo "Building string comparison tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) + # Build integration tests (tests multiple components working together with real file I/O) tests/integration_workflows_test: tests/integration/test_workflows.c \ tests/integration/integration_support.c \ @@ -299,6 +309,7 @@ tests/integration_workflows_test: tests/integration/test_workflows.c \ workspace/all/common/binary_file_utils.c \ workspace/all/common/minarch_paths.c \ workspace/all/common/utils.c \ + workspace/all/common/nointro_parser.c \ workspace/all/common/log.c \ $(TEST_UNITY) @echo "Building integration tests..." diff --git a/makefile.qa.bak b/makefile.qa.bak new file mode 100644 index 00000000..c6fa1832 --- /dev/null +++ b/makefile.qa.bak @@ -0,0 +1,455 @@ +# LessUI Quality Assurance +# Static analysis, testing, and code formatting +# +# This makefile provides quality assurance tools for the LessUI codebase: +# - Unit tests (Docker-based, using Ubuntu 24.04) +# - Static analysis (clang-tidy for C code, shellcheck for scripts) +# - Code formatting (clang-format with project style) +# +# Quick start: +# make test - Run all unit tests (recommended) +# make lint - Run all linting checks +# make format - Auto-format code (modifies files) +# +# The main makefile forwards to this file for test/lint/format targets. +# Run 'make -f makefile.qa help' for complete target list. + +.PHONY: help lint lint-code lint-full lint-shell analyze analyze-native test test-native format format-native format-check clean-qa clean-tests docker-build docker-test docker-lint docker-analyze docker-format docker-format-check docker-shell lint-native report + +help: + @echo "LessUI Quality Assurance Tools" + @echo "" + @echo "Main targets (use these):" + @echo " make test - Run unit tests (Docker, recommended)" + @echo " make lint - Run ALL linting checks (clang-tidy, format-check, shellcheck)" + @echo " make format - Format code with clang-format (MODIFIES FILES)" + @echo "" + @echo "Individual lint targets:" + @echo " make lint-code - Run clang-tidy on workspace/all/" + @echo " make lint-full - Run clang-tidy on entire workspace (verbose)" + @echo " make analyze - Run Clang Static Analyzer (deep analysis)" + @echo " make format-check - Check if code is formatted (no changes)" + @echo " make lint-shell - Run shellcheck on shell scripts" + @echo "" + @echo "Docker targets:" + @echo " make docker-test - Run tests in Docker container (Ubuntu 24.04)" + @echo " make docker-analyze - Run static analysis in Docker container" + @echo " make docker-build - Build Docker image" + @echo " make docker-shell - Enter Docker container for debugging" + @echo "" + @echo "Other:" + @echo " make test-native - Run tests natively (not recommended on macOS)" + @echo " make clean-qa - Clean QA artifacts" + @echo "" + @echo "Installing tools:" + @echo " macOS: brew install llvm shellcheck sdl2 sdl2_image sdl2_ttf" + @echo " Ubuntu: sudo apt-get install clang-tidy clang-format shellcheck libsdl2-dev libsdl2-image-dev libsdl2-ttf-dev" + +# clang-tidy configuration +# Checks for: bugs, performance issues, readability, security +# Configuration in .clang-tidy file +# workspace/all/ is the primary focus (platform-independent code) +# Use LLVM 18 to match CI (GitHub Actions uses Ubuntu LLVM 18.1.3) +CLANG_TIDY = $(shell /opt/homebrew/opt/llvm@18/bin/clang-tidy --version >/dev/null 2>&1 && echo /opt/homebrew/opt/llvm@18/bin/clang-tidy || which clang-tidy) +TIDY_FLAGS = --quiet + +# Source files to analyze +# Note: clock.c is excluded because it uses GCC nested functions (non-standard) +# All other files are linted on all platforms +TIDY_SOURCES = workspace/all/minui/*.c \ + workspace/all/minarch/*.c \ + workspace/all/common/*.c \ + workspace/all/minput/*.c \ + workspace/all/syncsettings/*.c + +# Auto-detect SDL2 include path (works on both macOS and Ubuntu) +SDL2_CFLAGS := $(shell sdl2-config --cflags 2>/dev/null) +SDL2_PREFIX := $(shell sdl2-config --prefix 2>/dev/null) + +# Desktop platform configuration for linting (works on macOS and Linux) +# Need parent include dir for style includes on macOS +ifneq ($(SDL2_PREFIX),) + TIDY_SDL_INCLUDES = -I$(SDL2_PREFIX)/include $(SDL2_CFLAGS) +else + TIDY_SDL_INCLUDES = $(SDL2_CFLAGS) +endif + +# Compiler flags for clang-tidy (needs to understand how to compile the code) +# Uses desktop platform which works for both macOS development and Linux CI +TIDY_COMPILE_FLAGS = -DPLATFORM=\"desktop\" \ + -DSDCARD_PATH=\"../../desktop/FAKESD\" \ + -DUSE_SDL2 \ + -DBUILD_DATE=\"$(shell date +%Y%m%d)\" \ + -DBUILD_HASH=\"tidy\" \ + -I workspace/all/common \ + -I workspace/desktop/platform \ + -I workspace/all/minarch/libretro-common/include \ + -iquote workspace/all/minarch/libretro-common/include \ + -I tests/support \ + $(TIDY_SDL_INCLUDES) \ + -std=gnu99 + +# Check if clang-tidy is installed +check-clang-tidy: + @which $(CLANG_TIDY) > /dev/null || (echo "Error: clang-tidy not installed. Run 'brew install llvm' or 'apt-get install clang-tidy'" && exit 1) + +check-shellcheck: + @which shellcheck > /dev/null || (echo "Error: shellcheck not installed. Run 'brew install shellcheck' or 'apt-get install shellcheck'" && exit 1) + +# Run all linting checks (uses Docker for consistency with CI) +lint: docker-lint + +# Run clang-tidy on common code (platform-independent, highest priority) +lint-code: check-clang-tidy + @echo "Running clang-tidy on workspace/all/ (common code)..." + @echo "Checking for: bugs, performance issues, readability, security vulnerabilities" + @echo "" + @for file in $(TIDY_SOURCES); do \ + if [ -f "$$file" ]; then \ + $(CLANG_TIDY) $(TIDY_FLAGS) "$$file" -- $(TIDY_COMPILE_FLAGS) || exit 1; \ + fi; \ + done + @echo "" + @echo "✓ Static analysis complete" + +# Run clang-tidy on entire workspace (includes platform-specific code) +lint-full: check-clang-tidy + @echo "Running clang-tidy on entire workspace..." + @find workspace -name "*.c" -type f \ + -not -path "*/libretro-common/*" \ + -not -path "*/cores/*" \ + -not -path "*/toolchains/*" \ + -not -path "*/other/*" \ + -exec $(CLANG_TIDY) $(TIDY_FLAGS) {} -- $(TIDY_COMPILE_FLAGS) \; + +# Clang Static Analyzer (deep dataflow analysis) +# Finds: NULL derefs, memory leaks, dead code, logic errors +# Runs in Docker by default for consistency with CI + +# Auto-detect scan-build location (for native builds) +SCAN_BUILD := $(shell which scan-build 2>/dev/null || which /opt/homebrew/opt/llvm/bin/scan-build 2>/dev/null) + +# Note: SDL2_CFLAGS defined above (line 67) + +ANALYZE_FLAGS = -DPLATFORM=\"desktop\" -DSDCARD_PATH=\"../../desktop/FAKESD\" -DENABLE_INFO_LOGS -DENABLE_DEBUG_LOGS \ + -I workspace/all/common -I workspace/desktop/platform \ + -I workspace/all/minarch/libretro-common/include \ + -iquote workspace/all/minarch/libretro-common/include \ + $(SDL2_CFLAGS) -DUSE_SDL2 -std=gnu99 + +check-scan-build: + @if [ -z "$(SCAN_BUILD)" ]; then \ + echo "Error: scan-build not found"; \ + echo " macOS: brew install llvm"; \ + echo " Ubuntu: sudo apt-get install clang-tools"; \ + exit 1; \ + fi + +# Default: use Docker for consistency +analyze: docker-analyze + +# Native analysis (for advanced use) +analyze-native: check-scan-build + @echo "=================================" + @echo "Running Clang Static Analyzer..." + @echo "=================================" + @echo "" + @echo "Analyzing minarch.c..." + @rm -rf /tmp/lessui-analysis + @$(SCAN_BUILD) -o /tmp/lessui-analysis --status-bugs \ + gcc -c workspace/all/minarch/minarch.c $(ANALYZE_FLAGS) \ + -o /tmp/minarch.o 2>&1 | grep -v "note:" || true + @echo "" + @echo "Analyzing minui.c..." + @$(SCAN_BUILD) -o /tmp/lessui-analysis --status-bugs \ + gcc -c workspace/all/minui/minui.c $(ANALYZE_FLAGS) \ + -o /tmp/minui.o 2>&1 | grep -v "note:" || true + @echo "" + @echo "Analyzing common/*.c..." + @for file in workspace/all/common/*.c; do \ + $(SCAN_BUILD) -o /tmp/lessui-analysis --status-bugs \ + gcc -c $$file $(ANALYZE_FLAGS) \ + -o /tmp/common.o 2>&1 | grep -v "note:" || true; \ + done + @echo "" + @echo "=================================" + @if ls /tmp/lessui-analysis/*/index.html >/dev/null 2>&1; then \ + echo "⚠️ Issues found! View report:"; \ + echo " open /tmp/lessui-analysis/*/index.html"; \ + else \ + echo "✓ No issues found"; \ + fi + @echo "=================================" + +########################################################### +# Test Configuration +TEST_CFLAGS = -std=c99 -Wall -Wextra -Wno-unused-parameter +TEST_INCLUDES = -I tests/support -I tests/support/unity -I workspace/all/common +TEST_UNITY = tests/support/unity/unity.c + +# All test executables (built from tests/unit/ and tests/integration/) +TEST_EXECUTABLES = tests/utils_test tests/nointro_parser_test tests/pad_test tests/collections_test tests/gfx_text_test tests/audio_resampler_test tests/minarch_paths_test tests/minui_utils_test tests/m3u_parser_test tests/minui_file_utils_test tests/map_parser_test tests/collection_parser_test tests/recent_parser_test tests/recent_writer_test tests/directory_utils_test tests/binary_file_utils_test tests/ui_layout_test tests/str_compare_test tests/integration_workflows_test + +# Default targets: use Docker for consistency +test: docker-test +format: docker-format + +# Native test target (for Linux or if Docker unavailable) +test-native: $(TEST_EXECUTABLES) + @echo "Running unit tests (native)..." + @for test in $(TEST_EXECUTABLES); do \ + ./$$test; \ + echo ""; \ + done + @echo "✓ All tests passed" + +# Build logging system tests +tests/log_test: tests/unit/all/common/test_log.c workspace/all/common/log.c $(TEST_UNITY) + @echo "Building logging system tests..." + @$(CC) $(TEST_INCLUDES) $(TEST_CFLAGS) -D_DEFAULT_SOURCE -o $@ $^ -lpthread + +# Build comprehensive utils tests +tests/utils_test: tests/unit/all/common/test_utils.c workspace/all/common/utils.c workspace/all/common/nointro_parser.c workspace/all/common/log.c $(TEST_UNITY) + @echo "Building comprehensive utils tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) + +# Build No-Intro name parser tests +tests/nointro_parser_test: tests/unit/all/common/test_nointro_parser.c workspace/all/common/nointro_parser.c workspace/all/common/utils.c workspace/all/common/log.c $(TEST_UNITY) + @echo "Building No-Intro name parser tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) + +# Build PAD (input) tests +tests/pad_test: tests/unit/all/common/test_api_pad.c workspace/all/common/pad.c $(TEST_UNITY) + @echo "Building PAD input tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) + +# Build collections (Array/Hash) tests +tests/collections_test: tests/unit/all/common/test_collections.c workspace/all/common/collections.c workspace/all/common/utils.c workspace/all/common/log.c $(TEST_UNITY) + @echo "Building collections tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -D_POSIX_C_SOURCE=200809L + +# Build GFX text utility tests (uses fff for TTF mocking) +tests/gfx_text_test: tests/unit/all/common/test_gfx_text.c workspace/all/common/gfx_text.c workspace/all/common/utils.c workspace/all/common/log.c tests/support/sdl_fakes.c $(TEST_UNITY) + @echo "Building GFX text utility tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) -I tests/support/fff $(TEST_CFLAGS) -DUNIT_TEST_BUILD + +# Build audio resampler tests (pure algorithm, no mocking needed) +tests/audio_resampler_test: tests/unit/all/common/test_audio_resampler.c workspace/all/common/audio_resampler.c $(TEST_UNITY) + @echo "Building audio resampler tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) + +# Build MinArch path generation tests (pure sprintf logic) +tests/minarch_paths_test: tests/unit/all/common/test_minarch_paths.c workspace/all/common/minarch_paths.c $(TEST_UNITY) + @echo "Building MinArch path generation tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) + +# Build LessUI launcher utility tests (pure string logic) +tests/minui_utils_test: tests/unit/all/common/test_minui_utils.c workspace/all/common/minui_utils.c workspace/all/common/utils.c workspace/all/common/log.c $(TEST_UNITY) + @echo "Building LessUI launcher utility tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) + +# Build M3U parser tests (uses file mocking with GCC --wrap, Docker-only) +tests/m3u_parser_test: tests/unit/all/common/test_m3u_parser.c workspace/all/common/m3u_parser.c workspace/all/common/utils.c workspace/all/common/log.c tests/support/fs_mocks.c $(TEST_UNITY) + @echo "Building M3U parser tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -D_POSIX_C_SOURCE=200809L -Wl,--wrap=exists -Wl,--wrap=fopen -Wl,--wrap=fclose -Wl,--wrap=fgets + +# Build LessUI file utility tests (uses file mocking with GCC --wrap, Docker-only) +tests/minui_file_utils_test: tests/unit/all/common/test_minui_file_utils.c workspace/all/common/minui_file_utils.c workspace/all/common/utils.c workspace/all/common/log.c tests/support/fs_mocks.c $(TEST_UNITY) + @echo "Building LessUI file utility tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -Wl,--wrap=exists -Wl,--wrap=fopen -Wl,--wrap=fclose -Wl,--wrap=fgets + +# Build map.txt parser tests (uses file mocking with GCC --wrap, Docker-only) +tests/map_parser_test: tests/unit/all/common/test_map_parser.c workspace/all/common/map_parser.c workspace/all/common/utils.c workspace/all/common/log.c tests/support/fs_mocks.c $(TEST_UNITY) + @echo "Building map.txt parser tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -Wl,--wrap=exists -Wl,--wrap=fopen -Wl,--wrap=fclose -Wl,--wrap=fgets + +# Build collection parser tests (uses file mocking with GCC --wrap, Docker-only) +tests/collection_parser_test: tests/unit/all/common/test_collection_parser.c workspace/all/common/collection_parser.c workspace/all/common/utils.c workspace/all/common/log.c tests/support/fs_mocks.c $(TEST_UNITY) + @echo "Building collection parser tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -D_POSIX_C_SOURCE=200809L -Wl,--wrap=exists -Wl,--wrap=fopen -Wl,--wrap=fclose -Wl,--wrap=fgets + +# Build recent.txt file tests (uses file mocking with GCC --wrap, Docker-only) +tests/recent_parser_test: tests/unit/all/common/test_recent_parser.c workspace/all/common/recent_file.c workspace/all/common/utils.c workspace/all/common/log.c tests/support/fs_mocks.c $(TEST_UNITY) + @echo "Building recent.txt parser tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -D_POSIX_C_SOURCE=200809L -Wl,--wrap=exists -Wl,--wrap=fopen -Wl,--wrap=fclose -Wl,--wrap=fgets + +# Build recent.txt writer tests (uses real temp files, no --wrap needed) +tests/recent_writer_test: tests/unit/all/common/test_recent_writer.c workspace/all/common/recent_file.c workspace/all/common/utils.c workspace/all/common/log.c $(TEST_UNITY) + @echo "Building recent.txt writer tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -D_POSIX_C_SOURCE=200809L + +# Build directory utility tests (uses real temp directories, no --wrap needed) +tests/directory_utils_test: tests/unit/all/common/test_directory_utils.c workspace/all/common/minui_file_utils.c workspace/all/common/utils.c workspace/all/common/log.c $(TEST_UNITY) + @echo "Building directory utility tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -D_DEFAULT_SOURCE + +# Build binary file I/O tests (uses real temp files, no --wrap needed) +tests/binary_file_utils_test: tests/unit/all/common/test_binary_file_utils.c workspace/all/common/binary_file_utils.c workspace/all/common/log.c $(TEST_UNITY) + @echo "Building binary file I/O tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -D_DEFAULT_SOURCE + +# Build UI layout / DP system tests (pure math, no mocking needed) +tests/ui_layout_test: tests/unit/all/common/test_ui_layout.c workspace/all/common/log.c $(TEST_UNITY) + @echo "Building UI layout / DP system tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) -lm + +# Build string comparison tests (natural sort, pure algorithm) +tests/str_compare_test: tests/unit/all/common/test_str_compare.c workspace/all/common/str_compare.c $(TEST_UNITY) + @echo "Building string comparison tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) $(TEST_CFLAGS) + +# Build integration tests (tests multiple components working together with real file I/O) +tests/integration_workflows_test: tests/integration/test_workflows.c \ + tests/integration/integration_support.c \ + workspace/all/common/m3u_parser.c \ + workspace/all/common/map_parser.c \ + workspace/all/common/collection_parser.c \ + workspace/all/common/recent_file.c \ + workspace/all/common/minui_file_utils.c \ + workspace/all/common/binary_file_utils.c \ + workspace/all/common/minarch_paths.c \ + workspace/all/common/utils.c \ + workspace/all/common/log.c \ + $(TEST_UNITY) + @echo "Building integration tests..." + @$(CC) -o $@ $^ $(TEST_INCLUDES) -I tests/integration $(TEST_CFLAGS) -D_POSIX_C_SOURCE=200809L -D_DEFAULT_SOURCE + +clean-tests: + rm -f tests/log_test $(TEST_EXECUTABLES) tests/*.o tests/**/*.o tests/integration/*.o + +########################################################### +# Code Formatting +# Use LLVM 18 to match CI +CLANG_FORMAT = $(shell /opt/homebrew/opt/llvm@18/bin/clang-format --version >/dev/null 2>&1 && echo /opt/homebrew/opt/llvm@18/bin/clang-format || which clang-format) +FORMAT_PATHS = workspace/all/minui/*.c \ + workspace/all/minarch/*.c \ + workspace/all/common/*.c \ + workspace/all/common/*.h \ + workspace/all/paks/*/src/*.c \ + workspace/all/syncsettings/*.c \ + workspace/*/platform/platform.c \ + workspace/*/platform/platform.h \ + workspace/desktop/platform/msettings.h + +check-clang-format: + @which $(CLANG_FORMAT) > /dev/null || (echo "Error: clang-format not installed. Run 'brew install llvm' or 'apt-get install clang-format'" && exit 1) + +# Format code in-place (MODIFIES FILES) - Native implementation +format-native: check-clang-format + @echo "Formatting code with clang-format..." + @$(CLANG_FORMAT) -i $(FORMAT_PATHS) + @echo "✓ Code formatted" + @echo "" + @echo "NOTE: Files were modified. Review changes with 'git diff'" + +# Check if code is formatted (no modifications) +format-check: check-clang-format + @echo "Checking code formatting..." + @$(CLANG_FORMAT) --dry-run -Werror $(FORMAT_PATHS) 2>&1 || \ + (echo "✗ Code is not formatted. Run 'make -f makefile.qa format' to fix." && exit 1) + @echo "✓ Code is properly formatted" + +########################################################### +# Shell Script Linting + +# Find all shell scripts we maintain (exclude third-party code) +# Excludes: +# - workspace/*/other/* (third-party SDL, DTC, etc.) +# - workspace/desktop/FAKESD/* (test data) +# - cores, toolchains (third-party) +SHELL_SCRIPTS = $(shell find skeleton workspace -name "*.sh" -type f \ + -not -path "*/cores/*" \ + -not -path "*/toolchains/*" \ + -not -path "*/other/*" \ + -not -path "*/desktop/FAKESD/*" \ + -not -path "*/em_ui.sh" \ + 2>/dev/null) +ROOT_SCRIPTS = commits.sh + +lint-shell: check-shellcheck + @echo "Running shellcheck on shell scripts..." + @echo "Checking $(shell echo $(SHELL_SCRIPTS) $(ROOT_SCRIPTS) | wc -w | tr -d ' ') scripts (forgiving rules)" + @echo "" + @ERROR_COUNT=0; \ + for script in $(ROOT_SCRIPTS) $(SHELL_SCRIPTS); do \ + if [ -f "$$script" ]; then \ + shellcheck -S warning "$$script" || ERROR_COUNT=$$((ERROR_COUNT + 1)); \ + fi; \ + done; \ + if [ $$ERROR_COUNT -gt 0 ]; then \ + echo ""; \ + echo "Found issues in $$ERROR_COUNT script(s)"; \ + echo "Note: Using forgiving rules (see .shellcheckrc)"; \ + exit 1; \ + else \ + echo "✓ All shell scripts passed"; \ + fi + +########################################################### +# Reporting and Cleanup + +clean-qa: + @echo "Cleaning QA artifacts..." + rm -f clang-tidy-report.txt + +# Generate static analysis report file +report: check-clang-tidy + @echo "Generating static analysis report..." + @for file in $(TIDY_SOURCES); do \ + if [ -f "$$file" ]; then \ + $(CLANG_TIDY) "$$file" -- $(TIDY_COMPILE_FLAGS) 2>&1 || true; \ + fi; \ + done > clang-tidy-report.txt + @echo "Report saved to clang-tidy-report.txt" + @wc -l clang-tidy-report.txt + +########################################################### +# Docker QA (recommended for consistency with CI) +DEV_IMAGE = lessui-dev +DEV_RUN = docker run --rm -v $(shell pwd):/lessui -w /lessui $(DEV_IMAGE) + +docker-build: + @echo "Building dev Docker image (Ubuntu 24.04, matches GitHub CI)..." + @docker build -q -t $(DEV_IMAGE) -f Dockerfile . + @echo "✓ Dev image ready" + +docker-test: docker-build + @echo "Running tests in Docker container (Ubuntu 24.04)..." + @echo "" + $(DEV_RUN) make -f makefile.qa clean-tests test-native + +docker-lint: docker-build + @echo "Running linting in Docker container (Ubuntu 24.04)..." + $(DEV_RUN) make -f makefile.qa lint-native + +docker-analyze: docker-build + @echo "Running static analysis in Docker container (Ubuntu 24.04)..." + $(DEV_RUN) make -f makefile.qa analyze-native + +docker-format: docker-build + @echo "Running formatter in Docker container (Ubuntu 24.04)..." + $(DEV_RUN) make -f makefile.qa format-native + +docker-format-check: docker-build + @echo "Checking formatting in Docker container (Ubuntu 24.04)..." + $(DEV_RUN) make -f makefile.qa format-check + +docker-shell: docker-build + @echo "Entering dev container shell (Ubuntu 24.04)..." + docker run --rm -it -v $(shell pwd):/lessui -w /lessui $(DEV_IMAGE) /bin/bash + +# Native targets (runs directly on host) +lint-native: check-clang-tidy check-clang-format check-shellcheck + @echo "=================================" && \ + echo "Running ALL linting checks..." && \ + echo "=================================" && \ + echo "" && \ + $(MAKE) -f makefile.qa lint-code && \ + echo "" && \ + $(MAKE) -f makefile.qa format-check && \ + echo "" && \ + $(MAKE) -f makefile.qa lint-shell && \ + echo "" && \ + echo "=================================" && \ + echo "✓ All linting checks passed" && \ + echo "=================================" diff --git a/tests/unit/all/common/test_nointro_parser.c b/tests/unit/all/common/test_nointro_parser.c new file mode 100644 index 00000000..e43f3d38 --- /dev/null +++ b/tests/unit/all/common/test_nointro_parser.c @@ -0,0 +1,472 @@ +/** + * test_nointro_parser.c - Tests for No-Intro ROM name parser + * + * Tests the parsing of No-Intro naming convention into structured data. + * https://wiki.no-intro.org/index.php?title=Naming_Convention + */ + +#include "../../../../workspace/all/common/nointro_parser.h" +#include "../../../../workspace/all/common/utils.h" +#include "../../../support/unity/unity.h" + +void setUp(void) { +} + +void tearDown(void) { +} + +/////////////////////////////// +// Basic Parsing Tests +/////////////////////////////// + +void test_parseNoIntroName_simple_no_tags(void) { + NoIntroName info; + parseNoIntroName("Super Mario Bros.nes", &info); + + TEST_ASSERT_EQUAL_STRING("Super Mario Bros", info.title); + TEST_ASSERT_EQUAL_STRING("Super Mario Bros", info.display_name); + TEST_ASSERT_EQUAL_STRING("", info.region); + TEST_ASSERT_EQUAL_STRING("", info.language); + TEST_ASSERT_FALSE(info.has_tags); +} + +void test_parseNoIntroName_with_extension(void) { + NoIntroName info; + parseNoIntroName("Tetris.gb", &info); + + TEST_ASSERT_EQUAL_STRING("Tetris", info.title); + TEST_ASSERT_EQUAL_STRING("Tetris", info.display_name); +} + +void test_parseNoIntroName_multipart_extension(void) { + NoIntroName info; + parseNoIntroName("Celeste.p8.png", &info); + + TEST_ASSERT_EQUAL_STRING("Celeste", info.title); + TEST_ASSERT_EQUAL_STRING("Celeste", info.display_name); +} + +/////////////////////////////// +// Region Parsing Tests +/////////////////////////////// + +void test_parseNoIntroName_single_region(void) { + NoIntroName info; + parseNoIntroName("Super Metroid (USA).sfc", &info); + + TEST_ASSERT_EQUAL_STRING("Super Metroid", info.title); + TEST_ASSERT_EQUAL_STRING("Super Metroid", info.display_name); + TEST_ASSERT_EQUAL_STRING("USA", info.region); + TEST_ASSERT_TRUE(info.has_tags); +} + +void test_parseNoIntroName_multi_region(void) { + NoIntroName info; + parseNoIntroName("Game (Japan, USA).gb", &info); + + TEST_ASSERT_EQUAL_STRING("Game", info.title); + TEST_ASSERT_EQUAL_STRING("Japan, USA", info.region); +} + +void test_parseNoIntroName_world_region(void) { + NoIntroName info; + parseNoIntroName("Tetris (World).gb", &info); + + TEST_ASSERT_EQUAL_STRING("Tetris", info.title); + TEST_ASSERT_EQUAL_STRING("World", info.region); +} + +void test_parseNoIntroName_europe_region(void) { + NoIntroName info; + parseNoIntroName("Sonic (Europe).md", &info); + + TEST_ASSERT_EQUAL_STRING("Sonic", info.title); + TEST_ASSERT_EQUAL_STRING("Europe", info.region); +} + +/////////////////////////////// +// Language Parsing Tests +/////////////////////////////// + +void test_parseNoIntroName_single_language(void) { + NoIntroName info; + parseNoIntroName("Game (Europe) (En).nes", &info); + + TEST_ASSERT_EQUAL_STRING("Game", info.title); + TEST_ASSERT_EQUAL_STRING("Europe", info.region); + TEST_ASSERT_EQUAL_STRING("En", info.language); +} + +void test_parseNoIntroName_multi_language(void) { + NoIntroName info; + parseNoIntroName("Super Metroid (Japan, USA) (En,Ja).sfc", &info); + + TEST_ASSERT_EQUAL_STRING("Super Metroid", info.title); + TEST_ASSERT_EQUAL_STRING("Japan, USA", info.region); + TEST_ASSERT_EQUAL_STRING("En,Ja", info.language); +} + +void test_parseNoIntroName_three_languages(void) { + NoIntroName info; + parseNoIntroName("Game (Europe) (En,Fr,De).nes", &info); + + TEST_ASSERT_EQUAL_STRING("En,Fr,De", info.language); +} + +/////////////////////////////// +// Version Parsing Tests +/////////////////////////////// + +void test_parseNoIntroName_version(void) { + NoIntroName info; + parseNoIntroName("Mario Kart (USA) (v1.2).sfc", &info); + + TEST_ASSERT_EQUAL_STRING("Mario Kart", info.title); + TEST_ASSERT_EQUAL_STRING("USA", info.region); + // Debug: check if version went to additional + if (info.version[0] == '\0' && info.additional[0] != '\0') { + printf("\nDEBUG: Version tag '%s' went to additional instead of version\n", info.additional); + } + TEST_ASSERT_EQUAL_STRING("v1.2", info.version); +} + +void test_parseNoIntroName_revision(void) { + NoIntroName info; + parseNoIntroName("Pokemon Red (USA) (Rev A).gb", &info); + + TEST_ASSERT_EQUAL_STRING("Pokemon Red", info.title); + TEST_ASSERT_EQUAL_STRING("Rev A", info.version); +} + +void test_parseNoIntroName_revision_number(void) { + NoIntroName info; + parseNoIntroName("Zelda (USA) (Rev 1).nes", &info); + + TEST_ASSERT_EQUAL_STRING("Rev 1", info.version); +} + +/////////////////////////////// +// Development Status Tests +/////////////////////////////// + +void test_parseNoIntroName_beta(void) { + NoIntroName info; + parseNoIntroName("StarFox (USA) (Beta).sfc", &info); + + TEST_ASSERT_EQUAL_STRING("StarFox", info.title); + TEST_ASSERT_EQUAL_STRING("Beta", info.dev_status); +} + +void test_parseNoIntroName_beta_numbered(void) { + NoIntroName info; + parseNoIntroName("Game (USA) (Beta 2).nes", &info); + + TEST_ASSERT_EQUAL_STRING("Beta 2", info.dev_status); +} + +void test_parseNoIntroName_proto(void) { + NoIntroName info; + parseNoIntroName("Resident Evil (USA) (Proto).psx", &info); + + TEST_ASSERT_EQUAL_STRING("Resident Evil", info.title); + TEST_ASSERT_EQUAL_STRING("Proto", info.dev_status); +} + +void test_parseNoIntroName_sample(void) { + NoIntroName info; + parseNoIntroName("Demo Game (USA) (Sample).sfc", &info); + + TEST_ASSERT_EQUAL_STRING("Sample", info.dev_status); +} + +/////////////////////////////// +// Status Flag Tests +/////////////////////////////// + +void test_parseNoIntroName_bad_dump(void) { + NoIntroName info; + parseNoIntroName("Rare Game (USA) [b].nes", &info); + + TEST_ASSERT_EQUAL_STRING("Rare Game", info.title); + TEST_ASSERT_EQUAL_STRING("USA", info.region); + TEST_ASSERT_EQUAL_STRING("b", info.status); +} + +void test_parseNoIntroName_verified(void) { + NoIntroName info; + parseNoIntroName("Perfect Dump (USA) [!].nes", &info); + + TEST_ASSERT_EQUAL_STRING("!", info.status); +} + +/////////////////////////////// +// Article Handling Tests +/////////////////////////////// + +void test_parseNoIntroName_article_the(void) { + NoIntroName info; + parseNoIntroName("Legend of Zelda, The (USA).nes", &info); + + TEST_ASSERT_EQUAL_STRING("Legend of Zelda, The", info.title); + TEST_ASSERT_EQUAL_STRING("The Legend of Zelda", info.display_name); + TEST_ASSERT_EQUAL_STRING("USA", info.region); +} + +void test_parseNoIntroName_article_a(void) { + NoIntroName info; + parseNoIntroName("Man Born in Hell, A (USA).nes", &info); + + TEST_ASSERT_EQUAL_STRING("Man Born in Hell, A", info.title); + TEST_ASSERT_EQUAL_STRING("A Man Born in Hell", info.display_name); +} + +void test_parseNoIntroName_article_an(void) { + NoIntroName info; + parseNoIntroName("Angry Bird, An (USA).nes", &info); + + TEST_ASSERT_EQUAL_STRING("Angry Bird, An", info.title); + TEST_ASSERT_EQUAL_STRING("An Angry Bird", info.display_name); +} + +void test_parseNoIntroName_article_already_front(void) { + NoIntroName info; + parseNoIntroName("The Legend of Zelda (USA).nes", &info); + + TEST_ASSERT_EQUAL_STRING("The Legend of Zelda", info.title); + TEST_ASSERT_EQUAL_STRING("The Legend of Zelda", info.display_name); +} + +/////////////////////////////// +// Complex Multi-Tag Tests +/////////////////////////////// + +void test_parseNoIntroName_all_tags(void) { + NoIntroName info; + parseNoIntroName("Final Fantasy, The (Japan, USA) (En,Ja) (v1.1) (Proto).sfc", &info); + + TEST_ASSERT_EQUAL_STRING("Final Fantasy, The", info.title); + TEST_ASSERT_EQUAL_STRING("The Final Fantasy", info.display_name); + TEST_ASSERT_EQUAL_STRING("Japan, USA", info.region); + TEST_ASSERT_EQUAL_STRING("En,Ja", info.language); + TEST_ASSERT_EQUAL_STRING("v1.1", info.version); + TEST_ASSERT_EQUAL_STRING("Proto", info.dev_status); +} + +void test_parseNoIntroName_complex_with_brackets(void) { + NoIntroName info; + parseNoIntroName("Adventure (USA) (v1.0) [!].nes", &info); + + TEST_ASSERT_EQUAL_STRING("Adventure", info.title); + TEST_ASSERT_EQUAL_STRING("USA", info.region); + TEST_ASSERT_EQUAL_STRING("v1.0", info.version); + TEST_ASSERT_EQUAL_STRING("!", info.status); +} + +void test_parseNoIntroName_disc_number(void) { + NoIntroName info; + parseNoIntroName("Legend of Dragoon, The (USA) (Disc 1).bin", &info); + + TEST_ASSERT_EQUAL_STRING("Legend of Dragoon, The", info.title); + TEST_ASSERT_EQUAL_STRING("The Legend of Dragoon", info.display_name); + TEST_ASSERT_EQUAL_STRING("USA", info.region); + TEST_ASSERT_EQUAL_STRING("Disc 1", info.additional); +} + +void test_parseNoIntroName_unlicensed(void) { + NoIntroName info; + parseNoIntroName("Homebrew Game (World) (Unl).nes", &info); + + TEST_ASSERT_EQUAL_STRING("Homebrew Game", info.title); + TEST_ASSERT_EQUAL_STRING("Unl", info.license); +} + +/////////////////////////////// +// Tag Order Independence Tests +/////////////////////////////// + +void test_parseNoIntroName_tags_different_order_1(void) { + NoIntroName info; + parseNoIntroName("Game (USA) (En) (v1.0).nes", &info); + + TEST_ASSERT_EQUAL_STRING("Game", info.title); + TEST_ASSERT_EQUAL_STRING("USA", info.region); + TEST_ASSERT_EQUAL_STRING("En", info.language); + TEST_ASSERT_EQUAL_STRING("v1.0", info.version); +} + +void test_parseNoIntroName_tags_different_order_2(void) { + NoIntroName info; + parseNoIntroName("Game (v1.0) (USA) (En).nes", &info); + + TEST_ASSERT_EQUAL_STRING("Game", info.title); + TEST_ASSERT_EQUAL_STRING("USA", info.region); + TEST_ASSERT_EQUAL_STRING("En", info.language); + TEST_ASSERT_EQUAL_STRING("v1.0", info.version); +} + +void test_parseNoIntroName_tags_different_order_3(void) { + NoIntroName info; + parseNoIntroName("Game (En) (v1.0) (USA).nes", &info); + + TEST_ASSERT_EQUAL_STRING("Game", info.title); + TEST_ASSERT_EQUAL_STRING("USA", info.region); + TEST_ASSERT_EQUAL_STRING("En", info.language); + TEST_ASSERT_EQUAL_STRING("v1.0", info.version); +} + +/////////////////////////////// +// Real ROM Names from FAKESD +/////////////////////////////// + +void test_parseNoIntroName_real_zip_file(void) { + NoIntroName info; + parseNoIntroName("Wario Land 3 (World) (En,Ja).zip", &info); + + TEST_ASSERT_EQUAL_STRING("Wario Land 3", info.title); + TEST_ASSERT_EQUAL_STRING("Wario Land 3", info.display_name); + TEST_ASSERT_EQUAL_STRING("World", info.region); + TEST_ASSERT_EQUAL_STRING("En,Ja", info.language); +} + +void test_parseNoIntroName_real_gb_compatible(void) { + NoIntroName info; + parseNoIntroName("Babe and Friends (USA) (GB Compatible).zip", &info); + + TEST_ASSERT_EQUAL_STRING("Babe and Friends", info.title); + TEST_ASSERT_EQUAL_STRING("USA", info.region); + TEST_ASSERT_EQUAL_STRING("GB Compatible", info.additional); +} + +void test_parseNoIntroName_real_multi_region_zip(void) { + NoIntroName info; + parseNoIntroName("Star Wars - Yoda Stories (USA, Europe) (GB Compatible).zip", &info); + + TEST_ASSERT_EQUAL_STRING("Star Wars - Yoda Stories", info.title); + TEST_ASSERT_EQUAL_STRING("USA, Europe", info.region); +} + +void test_parseNoIntroName_real_five_languages(void) { + NoIntroName info; + parseNoIntroName("Toki Tori (USA, Europe) (En,Ja,Fr,De,Es).zip", &info); + + TEST_ASSERT_EQUAL_STRING("Toki Tori", info.title); + TEST_ASSERT_EQUAL_STRING("USA, Europe", info.region); + TEST_ASSERT_EQUAL_STRING("En,Ja,Fr,De,Es", info.language); +} + +void test_parseNoIntroName_adventure_island(void) { + NoIntroName info; + parseNoIntroName("Adventure Island (USA, Europe).zip", &info); + + TEST_ASSERT_EQUAL_STRING("Adventure Island", info.title); + TEST_ASSERT_EQUAL_STRING("Adventure Island", info.display_name); + TEST_ASSERT_EQUAL_STRING("USA, Europe", info.region); + TEST_ASSERT_EQUAL_STRING("", info.language); +} + +/////////////////////////////// +// Edge Cases +/////////////////////////////// + +void test_parseNoIntroName_empty_string(void) { + NoIntroName info; + parseNoIntroName("", &info); + + TEST_ASSERT_EQUAL_STRING("", info.title); + TEST_ASSERT_EQUAL_STRING("", info.display_name); +} + +void test_parseNoIntroName_only_extension(void) { + NoIntroName info; + parseNoIntroName(".nes", &info); + + // Should handle gracefully + TEST_ASSERT_FALSE(info.has_tags); +} + +void test_parseNoIntroName_with_path(void) { + NoIntroName info; + parseNoIntroName("/mnt/SDCARD/Roms/GB/Tetris (World).gb", &info); + + TEST_ASSERT_EQUAL_STRING("Tetris", info.title); + TEST_ASSERT_EQUAL_STRING("World", info.region); +} + +void test_parseNoIntroName_unmatched_brackets(void) { + NoIntroName info; + parseNoIntroName("Game with (Paren.nes", &info); + + // Should handle gracefully without crashing + TEST_ASSERT_EQUAL_STRING("Game with (Paren", info.title); +} + +/////////////////////////////// +// Test Runner +/////////////////////////////// + +int main(void) { + UNITY_BEGIN(); + + // Basic parsing + RUN_TEST(test_parseNoIntroName_simple_no_tags); + RUN_TEST(test_parseNoIntroName_with_extension); + RUN_TEST(test_parseNoIntroName_multipart_extension); + + // Regions + RUN_TEST(test_parseNoIntroName_single_region); + RUN_TEST(test_parseNoIntroName_multi_region); + RUN_TEST(test_parseNoIntroName_world_region); + RUN_TEST(test_parseNoIntroName_europe_region); + + // Languages + RUN_TEST(test_parseNoIntroName_single_language); + RUN_TEST(test_parseNoIntroName_multi_language); + RUN_TEST(test_parseNoIntroName_three_languages); + + // Versions + RUN_TEST(test_parseNoIntroName_version); + RUN_TEST(test_parseNoIntroName_revision); + RUN_TEST(test_parseNoIntroName_revision_number); + + // Development status + RUN_TEST(test_parseNoIntroName_beta); + RUN_TEST(test_parseNoIntroName_beta_numbered); + RUN_TEST(test_parseNoIntroName_proto); + RUN_TEST(test_parseNoIntroName_sample); + + // Status flags + RUN_TEST(test_parseNoIntroName_bad_dump); + RUN_TEST(test_parseNoIntroName_verified); + + // Articles + RUN_TEST(test_parseNoIntroName_article_the); + RUN_TEST(test_parseNoIntroName_article_a); + RUN_TEST(test_parseNoIntroName_article_an); + RUN_TEST(test_parseNoIntroName_article_already_front); + + // Complex cases + RUN_TEST(test_parseNoIntroName_all_tags); + RUN_TEST(test_parseNoIntroName_complex_with_brackets); + RUN_TEST(test_parseNoIntroName_disc_number); + RUN_TEST(test_parseNoIntroName_unlicensed); + + // Tag order independence + RUN_TEST(test_parseNoIntroName_tags_different_order_1); + RUN_TEST(test_parseNoIntroName_tags_different_order_2); + RUN_TEST(test_parseNoIntroName_tags_different_order_3); + + // Real ROM names + RUN_TEST(test_parseNoIntroName_real_zip_file); + RUN_TEST(test_parseNoIntroName_real_gb_compatible); + RUN_TEST(test_parseNoIntroName_real_multi_region_zip); + RUN_TEST(test_parseNoIntroName_real_five_languages); + RUN_TEST(test_parseNoIntroName_adventure_island); + + // Edge cases + RUN_TEST(test_parseNoIntroName_empty_string); + RUN_TEST(test_parseNoIntroName_only_extension); + RUN_TEST(test_parseNoIntroName_with_path); + RUN_TEST(test_parseNoIntroName_unmatched_brackets); + + return UNITY_END(); +} diff --git a/tests/unit/all/common/test_str_compare.c b/tests/unit/all/common/test_str_compare.c new file mode 100644 index 00000000..3c6844c2 --- /dev/null +++ b/tests/unit/all/common/test_str_compare.c @@ -0,0 +1,251 @@ +/** + * Test suite for workspace/all/common/str_compare.c + * Tests natural sorting (human-friendly alphanumeric ordering) + */ + +#include "../../../../workspace/all/common/str_compare.h" +#include "../../../support/unity/unity.h" +#include + +void setUp(void) {} +void tearDown(void) {} + +/////////////////////////////// +// Basic comparison tests +/////////////////////////////// + +void test_strnatcasecmp_equal_strings(void) { + TEST_ASSERT_EQUAL_INT(0, strnatcasecmp("hello", "hello")); +} + +void test_strnatcasecmp_case_insensitive(void) { + TEST_ASSERT_EQUAL_INT(0, strnatcasecmp("Hello", "hello")); + TEST_ASSERT_EQUAL_INT(0, strnatcasecmp("HELLO", "hello")); + TEST_ASSERT_EQUAL_INT(0, strnatcasecmp("HeLLo", "hEllO")); +} + +void test_strnatcasecmp_alphabetic_order(void) { + TEST_ASSERT_TRUE(strnatcasecmp("apple", "banana") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("banana", "apple") > 0); +} + +void test_strnatcasecmp_empty_strings(void) { + TEST_ASSERT_EQUAL_INT(0, strnatcasecmp("", "")); + TEST_ASSERT_TRUE(strnatcasecmp("", "a") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("a", "") > 0); +} + +void test_strnatcasecmp_null_handling(void) { + TEST_ASSERT_EQUAL_INT(0, strnatcasecmp(NULL, NULL)); + TEST_ASSERT_TRUE(strnatcasecmp(NULL, "a") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("a", NULL) > 0); +} + +/////////////////////////////// +// Natural number sorting +/////////////////////////////// + +void test_strnatcasecmp_single_digit_numbers(void) { + TEST_ASSERT_TRUE(strnatcasecmp("file1", "file2") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("file2", "file1") > 0); + TEST_ASSERT_EQUAL_INT(0, strnatcasecmp("file1", "file1")); +} + +void test_strnatcasecmp_multi_digit_numbers(void) { + // This is the key test - "10" should come AFTER "2" + TEST_ASSERT_TRUE(strnatcasecmp("file2", "file10") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("file10", "file2") > 0); +} + +void test_strnatcasecmp_game_numbering(void) { + // Common game naming patterns + TEST_ASSERT_TRUE(strnatcasecmp("Game 1", "Game 2") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("Game 2", "Game 10") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("Game 9", "Game 10") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("Game 10", "Game 11") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("Game 99", "Game 100") < 0); +} + +void test_strnatcasecmp_version_numbers(void) { + TEST_ASSERT_TRUE(strnatcasecmp("v1.0", "v1.1") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("v1.9", "v1.10") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("v2.0", "v10.0") < 0); +} + +void test_strnatcasecmp_leading_zeros(void) { + // Leading zeros should be skipped + TEST_ASSERT_EQUAL_INT(0, strnatcasecmp("file01", "file1")); + TEST_ASSERT_EQUAL_INT(0, strnatcasecmp("file001", "file1")); + TEST_ASSERT_TRUE(strnatcasecmp("file01", "file2") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("file09", "file10") < 0); +} + +void test_strnatcasecmp_numbers_at_start(void) { + TEST_ASSERT_TRUE(strnatcasecmp("1 Game", "2 Game") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("2 Game", "10 Game") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("10 Game", "20 Game") < 0); +} + +void test_strnatcasecmp_numbers_in_middle(void) { + TEST_ASSERT_TRUE(strnatcasecmp("a1b", "a2b") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("a2b", "a10b") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("test2test", "test10test") < 0); +} + +void test_strnatcasecmp_multiple_number_sequences(void) { + TEST_ASSERT_TRUE(strnatcasecmp("a1b1", "a1b2") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("a1b2", "a1b10") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("a1b10", "a2b1") < 0); +} + +/////////////////////////////// +// ROM naming patterns +/////////////////////////////// + +void test_strnatcasecmp_mario_games(void) { + TEST_ASSERT_TRUE(strnatcasecmp("Super Mario Bros", "Super Mario Bros 2") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("Super Mario Bros 2", "Super Mario Bros 3") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("Super Mario Bros 3", "Super Mario Bros 10") < 0); +} + +void test_strnatcasecmp_final_fantasy(void) { + TEST_ASSERT_TRUE(strnatcasecmp("Final Fantasy", "Final Fantasy 2") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("Final Fantasy 2", "Final Fantasy 3") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("Final Fantasy 9", "Final Fantasy 10") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("Final Fantasy 10", "Final Fantasy 12") < 0); +} + +void test_strnatcasecmp_megaman(void) { + TEST_ASSERT_TRUE(strnatcasecmp("Mega Man", "Mega Man 2") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("Mega Man 2", "Mega Man 3") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("Mega Man 9", "Mega Man 10") < 0); +} + +void test_strnatcasecmp_zelda(void) { + // The Legend of Zelda series (after article processing) + TEST_ASSERT_TRUE(strnatcasecmp("Legend of Zelda", "Legend of Zelda 2") < 0); +} + +/////////////////////////////// +// Article skipping for sorting +/////////////////////////////// + +void test_strnatcasecmp_the_article_skipped(void) { + // "The Legend of Zelda" should sort with "Legend..." not "The..." + TEST_ASSERT_TRUE(strnatcasecmp("The Legend of Zelda", "Mario") < 0); // L < M + TEST_ASSERT_TRUE(strnatcasecmp("The Legend of Zelda", "Asteroids") > 0); // L > A +} + +void test_strnatcasecmp_a_article_skipped(void) { + // "A Link to the Past" should sort with "Link..." not "A..." + TEST_ASSERT_TRUE(strnatcasecmp("A Link to the Past", "Mario") < 0); // L < M + TEST_ASSERT_TRUE(strnatcasecmp("A Link to the Past", "Asteroids") > 0); // L > A +} + +void test_strnatcasecmp_an_article_skipped(void) { + // "An American Tail" should sort with "American..." not "An..." + TEST_ASSERT_TRUE(strnatcasecmp("An American Tail", "Batman") < 0); // A < B + TEST_ASSERT_TRUE(strnatcasecmp("An American Tail", "Aardvark") > 0); // Am > Aa +} + +void test_strnatcasecmp_both_have_articles(void) { + // Both have "The" - should compare the rest + TEST_ASSERT_TRUE(strnatcasecmp("The Addams Family", "The Legend of Zelda") < 0); // A < L + TEST_ASSERT_TRUE(strnatcasecmp("The Legend of Zelda", "The Addams Family") > 0); // L > A +} + +void test_strnatcasecmp_article_case_insensitive(void) { + // Article matching should be case-insensitive + TEST_ASSERT_TRUE(strnatcasecmp("THE Legend of Zelda", "Mario") < 0); // L < M + TEST_ASSERT_TRUE(strnatcasecmp("the legend of zelda", "mario") < 0); // l < m +} + +void test_strnatcasecmp_article_needs_space(void) { + // "Theater" should NOT have "The" stripped (no space after) + TEST_ASSERT_TRUE(strnatcasecmp("Theater", "Super Mario") > 0); // T > S (not stripped) + // "Ant" should NOT have "An" stripped + TEST_ASSERT_TRUE(strnatcasecmp("Ant", "Zoo") < 0); // A < Z (not stripped) +} + +void test_strnatcasecmp_zelda_realistic(void) { + // Realistic Zelda sorting - all should sort together under "L" + TEST_ASSERT_TRUE(strnatcasecmp("The Legend of Zelda", "The Legend of Zelda 2") < 0); + // "Link" > "Legend" because 'i' > 'e' + TEST_ASSERT_TRUE(strnatcasecmp("A Link to the Past", "The Legend of Zelda") > 0); +} + +/////////////////////////////// +// Edge cases +/////////////////////////////// + +void test_strnatcasecmp_only_numbers(void) { + TEST_ASSERT_TRUE(strnatcasecmp("1", "2") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("2", "10") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("10", "100") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("100", "1000") < 0); +} + +void test_strnatcasecmp_mixed_content(void) { + // Numbers should come before letters in ASCII, preserved here + TEST_ASSERT_TRUE(strnatcasecmp("1abc", "abc") < 0); +} + +void test_strnatcasecmp_special_characters(void) { + // Special characters compared by ASCII value + TEST_ASSERT_TRUE(strnatcasecmp("game!", "game#") < 0); + TEST_ASSERT_EQUAL_INT(0, strnatcasecmp("game!", "game!")); +} + +void test_strnatcasecmp_prefix_sorting(void) { + // Shorter string that's a prefix should come first + TEST_ASSERT_TRUE(strnatcasecmp("Game", "Game 2") < 0); + TEST_ASSERT_TRUE(strnatcasecmp("Super", "Super Mario") < 0); +} + +/////////////////////////////// +// Test runner +/////////////////////////////// + +int main(void) { + UNITY_BEGIN(); + + // Basic comparison + RUN_TEST(test_strnatcasecmp_equal_strings); + RUN_TEST(test_strnatcasecmp_case_insensitive); + RUN_TEST(test_strnatcasecmp_alphabetic_order); + RUN_TEST(test_strnatcasecmp_empty_strings); + RUN_TEST(test_strnatcasecmp_null_handling); + + // Natural number sorting + RUN_TEST(test_strnatcasecmp_single_digit_numbers); + RUN_TEST(test_strnatcasecmp_multi_digit_numbers); + RUN_TEST(test_strnatcasecmp_game_numbering); + RUN_TEST(test_strnatcasecmp_version_numbers); + RUN_TEST(test_strnatcasecmp_leading_zeros); + RUN_TEST(test_strnatcasecmp_numbers_at_start); + RUN_TEST(test_strnatcasecmp_numbers_in_middle); + RUN_TEST(test_strnatcasecmp_multiple_number_sequences); + + // ROM naming patterns + RUN_TEST(test_strnatcasecmp_mario_games); + RUN_TEST(test_strnatcasecmp_final_fantasy); + RUN_TEST(test_strnatcasecmp_megaman); + RUN_TEST(test_strnatcasecmp_zelda); + + // Article skipping for sorting + RUN_TEST(test_strnatcasecmp_the_article_skipped); + RUN_TEST(test_strnatcasecmp_a_article_skipped); + RUN_TEST(test_strnatcasecmp_an_article_skipped); + RUN_TEST(test_strnatcasecmp_both_have_articles); + RUN_TEST(test_strnatcasecmp_article_case_insensitive); + RUN_TEST(test_strnatcasecmp_article_needs_space); + RUN_TEST(test_strnatcasecmp_zelda_realistic); + + // Edge cases + RUN_TEST(test_strnatcasecmp_only_numbers); + RUN_TEST(test_strnatcasecmp_mixed_content); + RUN_TEST(test_strnatcasecmp_special_characters); + RUN_TEST(test_strnatcasecmp_prefix_sorting); + + return UNITY_END(); +} diff --git a/tests/unit/all/common/test_utils.c b/tests/unit/all/common/test_utils.c index 4535ff48..324146ae 100644 --- a/tests/unit/all/common/test_utils.c +++ b/tests/unit/all/common/test_utils.c @@ -408,6 +408,137 @@ void test_getEmuName_with_parens(void) { TEST_ASSERT_EQUAL_STRING("GB", out); } +// No-Intro article tests +void test_fixArticle_the(void) { + char name[256] = "Legend of Zelda, The"; + fixArticle(name); + TEST_ASSERT_EQUAL_STRING("The Legend of Zelda", name); +} + +void test_fixArticle_a(void) { + char name[256] = "Link to the Past, A"; + fixArticle(name); + TEST_ASSERT_EQUAL_STRING("A Link to the Past", name); +} + +void test_fixArticle_an(void) { + char name[256] = "American Tail, An"; + fixArticle(name); + TEST_ASSERT_EQUAL_STRING("An American Tail", name); +} + +void test_fixArticle_no_article(void) { + char name[256] = "Super Mario Bros"; + fixArticle(name); + TEST_ASSERT_EQUAL_STRING("Super Mario Bros", name); +} + +void test_getDisplayName_nointro_article(void) { + char out[256]; + getDisplayName("Legend of Zelda, The (USA).nes", out); + TEST_ASSERT_EQUAL_STRING("The Legend of Zelda", out); +} + +// No-Intro: Region tags +void test_getDisplayName_nointro_single_region(void) { + char out[256]; + getDisplayName("Super Metroid (USA).sfc", out); + TEST_ASSERT_EQUAL_STRING("Super Metroid", out); +} + +void test_getDisplayName_nointro_multi_region(void) { + char out[256]; + getDisplayName("Test Rom Name, The (Japan, USA).gb", out); + TEST_ASSERT_EQUAL_STRING("The Test Rom Name", out); +} + +void test_getDisplayName_nointro_world_region(void) { + char out[256]; + getDisplayName("Tetris (World).gb", out); + TEST_ASSERT_EQUAL_STRING("Tetris", out); +} + +// No-Intro: Language tags +void test_getDisplayName_nointro_languages(void) { + char out[256]; + getDisplayName("Super Metroid (Japan, USA) (En,Ja).sfc", out); + TEST_ASSERT_EQUAL_STRING("Super Metroid", out); +} + +void test_getDisplayName_nointro_single_language(void) { + char out[256]; + getDisplayName("Game (Europe) (En).nes", out); + TEST_ASSERT_EQUAL_STRING("Game", out); +} + +// No-Intro: Version tags +void test_getDisplayName_nointro_version(void) { + char out[256]; + getDisplayName("Mario Kart (USA) (v1.2).sfc", out); + TEST_ASSERT_EQUAL_STRING("Mario Kart", out); +} + +void test_getDisplayName_nointro_revision(void) { + char out[256]; + getDisplayName("Pokemon Red (USA) (Rev A).gb", out); + TEST_ASSERT_EQUAL_STRING("Pokemon Red", out); +} + +// No-Intro: Development status tags +void test_getDisplayName_nointro_beta(void) { + char out[256]; + getDisplayName("StarFox (USA) (Beta).sfc", out); + TEST_ASSERT_EQUAL_STRING("StarFox", out); +} + +void test_getDisplayName_nointro_proto(void) { + char out[256]; + getDisplayName("Resident Evil (USA) (Proto).psx", out); + TEST_ASSERT_EQUAL_STRING("Resident Evil", out); +} + +// No-Intro: Multiple tags combined +void test_getDisplayName_nointro_all_tags(void) { + char out[256]; + getDisplayName("Final Fantasy, The (Japan, USA) (En,Ja) (v1.1) (Proto).sfc", out); + TEST_ASSERT_EQUAL_STRING("The Final Fantasy", out); +} + +void test_getDisplayName_nointro_complex_article(void) { + char out[256]; + getDisplayName("Legend of Dragoon, The (USA) (Disc 1).bin", out); + TEST_ASSERT_EQUAL_STRING("The Legend of Dragoon", out); +} + +// No-Intro: Square brackets (status flags) +void test_getDisplayName_nointro_bad_dump(void) { + char out[256]; + getDisplayName("Rare Game (USA) [b].nes", out); + TEST_ASSERT_EQUAL_STRING("Rare Game", out); +} + +void test_getDisplayName_nointro_square_and_round(void) { + char out[256]; + getDisplayName("Adventure (USA) (v1.0) [!].nes", out); + TEST_ASSERT_EQUAL_STRING("Adventure", out); +} + +// No-Intro: Edge cases +void test_getDisplayName_nointro_title_with_parens(void) { + char out[256]; + // Game title itself contains parentheses - should preserve them + // This is a known limitation - we'll strip all tags for now + getDisplayName("Kirby's Fun Pak (Kirby Super Star) (USA).sfc", out); + TEST_ASSERT_EQUAL_STRING("Kirby's Fun Pak", out); +} + +void test_getDisplayName_nointro_article_no_comma(void) { + char out[256]; + // Article is already at the beginning + getDisplayName("The Legend of Zelda (USA).nes", out); + TEST_ASSERT_EQUAL_STRING("The Legend of Zelda", out); +} + /////////////////////////////// // Date/Time Tests /////////////////////////////// @@ -757,6 +888,28 @@ int main(void) { RUN_TEST(test_getDisplayName_doom_extension); RUN_TEST(test_getEmuName_simple); RUN_TEST(test_getEmuName_with_parens); + RUN_TEST(test_fixArticle_the); + RUN_TEST(test_fixArticle_a); + RUN_TEST(test_fixArticle_an); + RUN_TEST(test_fixArticle_no_article); + RUN_TEST(test_getDisplayName_nointro_article); + + // No-Intro naming convention tests + RUN_TEST(test_getDisplayName_nointro_single_region); + RUN_TEST(test_getDisplayName_nointro_multi_region); + RUN_TEST(test_getDisplayName_nointro_world_region); + RUN_TEST(test_getDisplayName_nointro_languages); + RUN_TEST(test_getDisplayName_nointro_single_language); + RUN_TEST(test_getDisplayName_nointro_version); + RUN_TEST(test_getDisplayName_nointro_revision); + RUN_TEST(test_getDisplayName_nointro_beta); + RUN_TEST(test_getDisplayName_nointro_proto); + RUN_TEST(test_getDisplayName_nointro_all_tags); + RUN_TEST(test_getDisplayName_nointro_complex_article); + RUN_TEST(test_getDisplayName_nointro_bad_dump); + RUN_TEST(test_getDisplayName_nointro_square_and_round); + RUN_TEST(test_getDisplayName_nointro_title_with_parens); + RUN_TEST(test_getDisplayName_nointro_article_no_comma); // Leap year RUN_TEST(test_isLeapYear_divisible_by_4); diff --git a/workspace/all/common/build.mk b/workspace/all/common/build.mk index 2f1926dd..f5673509 100644 --- a/workspace/all/common/build.mk +++ b/workspace/all/common/build.mk @@ -68,6 +68,7 @@ INCDIR = -I. -I$(COMMON_DIR)/ -I$(PLATFORM_DIR)/ $(EXTRA_INCDIR) COMMON_SOURCE = \ $(COMMON_DIR)/utils.c \ + $(COMMON_DIR)/nointro_parser.c \ $(COMMON_DIR)/api.c \ $(COMMON_DIR)/log.c \ $(COMMON_DIR)/collections.c \ diff --git a/workspace/all/common/gfx_text.c b/workspace/all/common/gfx_text.c index b6af27ed..fb430b97 100644 --- a/workspace/all/common/gfx_text.c +++ b/workspace/all/common/gfx_text.c @@ -56,7 +56,15 @@ int GFX_truncateText(TTF_Font* ttf_font, const char* in_name, char* out_name, in while (text_width > max_width) { int len = strlen(out_name); - strcpy(&out_name[len - 4], "...\0"); + // Need at least 4 chars to truncate (replace last char with "...") + // If string is too short, just use "..." directly + if (len <= 4) { + strcpy(out_name, "..."); + TTF_SizeUTF8(ttf_font, out_name, &text_width, NULL); + text_width += padding; + break; + } + strcpy(&out_name[len - 4], "..."); TTF_SizeUTF8(ttf_font, out_name, &text_width, NULL); text_width += padding; } diff --git a/workspace/all/common/minui_utils.c b/workspace/all/common/minui_utils.c index 84fd1b7e..ffbdeea2 100644 --- a/workspace/all/common/minui_utils.c +++ b/workspace/all/common/minui_utils.c @@ -15,7 +15,10 @@ * Returns index 1-26 for strings starting with a-z (case-insensitive). * Returns 0 for strings starting with non-letters. * - * @param str String to get index for + * Note: When used for L1/R1 navigation indexing, pass entry->sort_key + * (which has leading articles stripped) to match sort order. + * + * @param str String to get index for (typically a sort_key) * @return Index 0-26 (0=non-letter, 1=A, 2=B, ..., 26=Z) */ int MinUI_getIndexChar(char* str) { diff --git a/workspace/all/common/minui_utils.h b/workspace/all/common/minui_utils.h index 22abb931..cf5ed870 100644 --- a/workspace/all/common/minui_utils.h +++ b/workspace/all/common/minui_utils.h @@ -18,13 +18,15 @@ * Returns 0 for strings starting with non-letters. * * Used for L1/R1 quick navigation in file browser. + * When building alphabetical index, pass entry->sort_key (which has + * leading articles stripped) rather than entry->name to match sort order. * * Example: * "Apple" -> 1 (A) * "Zelda" -> 26 (Z) * "007 GoldenEye" -> 0 (non-letter) * - * @param str String to get index for + * @param str String to get index for (typically sort_key for Entry indexing) * @return Index 0-26 (0=non-letter, 1=A, 2=B, ..., 26=Z) */ int MinUI_getIndexChar(char* str); diff --git a/workspace/all/common/nointro_parser.c b/workspace/all/common/nointro_parser.c new file mode 100644 index 00000000..6142b242 --- /dev/null +++ b/workspace/all/common/nointro_parser.c @@ -0,0 +1,253 @@ +/** + * nointro_parser.c - Parser for No-Intro ROM naming convention + * + * Implementation approach: + * 1. Use tiny-regex-c for pattern matching (lightweight, no malloc) + * 2. Parse tags from right to left (status flags first, then metadata) + * 3. Handle article rearrangement after all tags are stripped + * 4. Preserve title integrity (only strip recognized tag patterns) + * + * Tag parsing order (right to left): + * 1. [status] - Square brackets at end (e.g., [b], [!]) + * 2. (license) - (Unl) for unlicensed + * 3. (special) - Special flags (ST), (MB), etc. + * 4. (additional) - Additional info (Disc 1), (Rumble Version) + * 5. (dev_status) - (Beta), (Proto), (Sample) + * 6. (version) - (v1.0), (Rev A) + * 7. (language) - (En), (En,Ja,Fr) + * 8. (region) - (USA), (Japan, USA), (World) + * + * Regex patterns needed: + * - Region: \([A-Z][a-z]+(?:, [A-Z][a-z]+)*\) + * - Language: \([A-Z][a-z](?:,[A-Z][a-z])*\) + * - Version: \((?:v[0-9.]+|Rev [A-Z0-9]+)\) + * - Dev status: \(Beta(?: [0-9]+)?|Proto|Sample)\) + * - Status: \[[a-z!]\] + */ + +#include "nointro_parser.h" +#include "utils.h" +#include +#include + +void initNoIntroName(NoIntroName* parsed) { + if (!parsed) + return; + + parsed->title[0] = '\0'; + parsed->display_name[0] = '\0'; + parsed->region[0] = '\0'; + parsed->language[0] = '\0'; + parsed->version[0] = '\0'; + parsed->dev_status[0] = '\0'; + parsed->additional[0] = '\0'; + parsed->special[0] = '\0'; + parsed->license[0] = '\0'; + parsed->status[0] = '\0'; + parsed->has_tags = false; +} + +/** + * Removes file extension from filename. + * + * Handles multi-part extensions like .p8.png + * Only removes extensions between 1-4 characters (plus dot) + * + * @param filename Input filename + * @param out Output buffer (must be at least NOINTRO_MAX_TITLE) + */ +static void removeExtension(const char* filename, char* out) { + char* tmp; + const char* start = filename; + + // Extract just the filename if we have a full path + tmp = strrchr(filename, '/'); + if (tmp) { + start = tmp + 1; + } + + // Copy filename (bounds-checked) + strncpy(out, start, NOINTRO_MAX_TITLE - 1); + out[NOINTRO_MAX_TITLE - 1] = '\0'; + + // Remove file extension (only if it comes AFTER all tags) + // Extensions appear at the very end: "Name (tags).ext" + // We need to find the rightmost ')' or ']', then remove extension after it + char* last_paren = strrchr(out, ')'); + char* last_bracket = strrchr(out, ']'); + char* last_tag = (last_paren > last_bracket) ? last_paren : last_bracket; + + // Find last dot (potential extension) + tmp = strrchr(out, '.'); + + // Only remove if the dot is after all tags (or there are no tags) + if (tmp && (!last_tag || tmp > last_tag)) { + int len = strlen(tmp); + // Remove extensions 1-5 chars (covers .gb, .zip, .p8.png, .doom, etc.) + if (len > 1 && len <= 6) { + tmp[0] = '\0'; + // Check for multi-part extensions like .p8.png + tmp = strrchr(out, '.'); + if (tmp && (!last_tag || tmp > last_tag)) { + len = strlen(tmp); + if (len > 1 && len <= 6) { + tmp[0] = '\0'; + } + } + } + } +} + +/** + * Extracts and removes a tag at the specified position. + * + * @param str String to extract from (modified in place) + * @param open Opening character ('(' or '[') + * @param close Closing character (')' or ']') + * @param out Output buffer for extracted content (without brackets) + * @param out_size Size of output buffer + * @return true if tag was found and extracted, false otherwise + */ +static bool extractTag(char* str, char open, char close, char* out, size_t out_size) { + char* close_pos = strrchr(str, close); + if (!close_pos) + return false; + + // Find matching opening bracket + char* open_pos = close_pos - 1; + while (open_pos > str && *open_pos != open) { + open_pos--; + } + + if (*open_pos != open) + return false; + + // Extract content (without brackets) + size_t content_len = close_pos - open_pos - 1; + if (content_len >= out_size) + content_len = out_size - 1; + + // Use memcpy to avoid strncpy warnings with zero-length or compile-time constant lengths + if (content_len > 0) + memcpy(out, open_pos + 1, content_len); + out[content_len] = '\0'; + + // Remove the entire tag from string (including brackets) + // Trim any trailing whitespace before the tag + char* trim_pos = open_pos; + while (trim_pos > str && isspace((unsigned char)*(trim_pos - 1))) { + trim_pos--; + } + *trim_pos = '\0'; + + return true; +} + +/** + * TODO: Implement tag classification using regex patterns. + * + * For now, we'll use a simplified heuristic approach: + * - Check for known patterns in order + * - Use string matching for common cases + * + * This will be replaced with tiny-regex-c implementation. + */ +static void classifyTag(const char* tag, NoIntroName* parsed) { + // Status flags (single character) + if (strlen(tag) == 1) { + strcpy(parsed->status, tag); + parsed->has_tags = true; + return; + } + + // License (Unl) + if (strcmp(tag, "Unl") == 0) { + strcpy(parsed->license, tag); + parsed->has_tags = true; + return; + } + + // Development status + if (strstr(tag, "Beta") || strstr(tag, "Proto") || strstr(tag, "Sample")) { + strcpy(parsed->dev_status, tag); + parsed->has_tags = true; + return; + } + + // Version (starts with 'v' followed by digit, or 'Rev') + if ((tag[0] == 'v' && strlen(tag) > 1 && isdigit((unsigned char)tag[1])) || + prefixMatch("Rev ", (char*)tag)) { + strcpy(parsed->version, tag); + parsed->has_tags = true; + return; + } + + // Language (contains comma-separated two-letter codes) + // e.g., "En", "En,Ja", "Fr,De,Es" + if (strlen(tag) == 2 || (strlen(tag) > 2 && strchr(tag, ','))) { + bool looks_like_lang = true; + const char* p = tag; + while (*p) { + if (!(isupper(*p) && islower(*(p + 1)))) { + if (*p != ',') { + looks_like_lang = false; + break; + } + } + p += (*p == ',') ? 1 : 2; + } + if (looks_like_lang && parsed->language[0] == '\0') { + strcpy(parsed->language, tag); + parsed->has_tags = true; + return; + } + } + + // Region (if not yet set and contains known region names) + // Common regions: USA, Japan, Europe, World, Asia, Korea, etc. + if (parsed->region[0] == '\0') { + if (strstr(tag, "USA") || strstr(tag, "Japan") || strstr(tag, "Europe") || + strstr(tag, "World") || strstr(tag, "Asia") || strstr(tag, "Korea") || + strstr(tag, "China") || strstr(tag, "Australia") || strstr(tag, "Brazil") || + strstr(tag, "Canada") || strstr(tag, "France") || strstr(tag, "Germany") || + strstr(tag, "Spain") || strstr(tag, "Italy")) { + strcpy(parsed->region, tag); + parsed->has_tags = true; + return; + } + } + + // Default: additional info (Disc 1, Rumble Version, etc.) + if (parsed->additional[0] == '\0') { + strcpy(parsed->additional, tag); + parsed->has_tags = true; + } +} + +void parseNoIntroName(const char* filename, NoIntroName* parsed) { + char work[NOINTRO_MAX_TITLE]; + char tag[NOINTRO_MAX_FIELD]; + + initNoIntroName(parsed); + + // Remove file extension + removeExtension(filename, work); + + // Extract tags from right to left + // First, status flags in square brackets + while (extractTag(work, '[', ']', tag, sizeof(tag))) { + classifyTag(tag, parsed); + } + + // Then, metadata in parentheses + while (extractTag(work, '(', ')', tag, sizeof(tag))) { + classifyTag(tag, parsed); + } + + // What's left is the title + strcpy(parsed->title, work); + + // Create display name by moving article to front + strcpy(parsed->display_name, work); + fixArticle(parsed->display_name); +} diff --git a/workspace/all/common/nointro_parser.h b/workspace/all/common/nointro_parser.h new file mode 100644 index 00000000..56d2ab8a --- /dev/null +++ b/workspace/all/common/nointro_parser.h @@ -0,0 +1,93 @@ +/** + * nointro_parser.h - Parser for No-Intro ROM naming convention + * + * Parses ROM filenames following the No-Intro naming standard: + * https://wiki.no-intro.org/index.php?title=Naming_Convention + * + * Example filename: + * "Legend of Zelda, The (USA) (En,Ja) (v1.2) (Beta).nes" + * + * Parsed fields: + * - title: "Legend of Zelda, The" + * - display_name: "The Legend of Zelda" (article moved to front) + * - regions: "USA" + * - languages: "En,Ja" + * - version: "v1.2" + * - dev_status: "Beta" + * - etc. + */ + +#ifndef NOINTRO_PARSER_H +#define NOINTRO_PARSER_H + +#include + +/** + * Maximum length for each parsed field. + * Most fields are short (regions, languages), but titles can be long. + */ +#define NOINTRO_MAX_TITLE 128 +#define NOINTRO_MAX_FIELD 64 + +/** + * Parsed No-Intro ROM name structure. + * + * All fields are null-terminated strings. Empty fields are set to "". + */ +typedef struct { + char title[NOINTRO_MAX_TITLE]; // Raw title (may have ", The" suffix) + char display_name[NOINTRO_MAX_TITLE]; // Display-ready name (article moved) + char region[NOINTRO_MAX_FIELD]; // e.g., "USA", "Japan, USA", "World" + char language[NOINTRO_MAX_FIELD]; // e.g., "En", "En,Ja" + char version[NOINTRO_MAX_FIELD]; // e.g., "v1.2", "Rev A" + char dev_status[NOINTRO_MAX_FIELD]; // e.g., "Beta", "Proto", "Sample" + char additional[NOINTRO_MAX_FIELD]; // e.g., "Rumble Version" + char special[NOINTRO_MAX_FIELD]; // e.g., "ST", "MB" + char license[NOINTRO_MAX_FIELD]; // e.g., "Unl" (unlicensed) + char status[NOINTRO_MAX_FIELD]; // e.g., "b" (bad dump), "!" (verified) + bool has_tags; // True if any tags were found +} NoIntroName; + +/** + * Parses a No-Intro format filename into structured fields. + * + * This function handles the complete No-Intro naming convention including: + * - Region tags: (USA), (Japan, USA), (World), (Europe) + * - Language tags: (En), (En,Ja), (Fr,De,Es) + * - Version tags: (v1.0), (Rev A), (Rev 1) + * - Development status: (Beta), (Proto), (Sample) + * - Additional info: (Rumble Version), (Disc 1) + * - Special flags: (ST), (MB), (NP) + * - License: (Unl) for unlicensed + * - Status flags: [b] bad dump, [!] verified, etc. + * + * Article handling: + * - "Name, The" -> display_name = "The Name" + * - "Name, A" -> display_name = "A Name" + * - "Name, An" -> display_name = "An Name" + * - "The Name" -> display_name = "The Name" (no change) + * + * @param filename Input filename (with or without extension) + * @param parsed Output structure (will be initialized and populated) + * + * @note File extension is automatically stripped before parsing + * @note All parentheses and brackets after the title are treated as tags + * @note The function is permissive - it won't fail on malformed input + * + * Example: + * NoIntroName info; + * parseNoIntroName("Super Metroid (Japan, USA) (En,Ja).sfc", &info); + * // info.display_name = "Super Metroid" + * // info.region = "Japan, USA" + * // info.language = "En,Ja" + */ +void parseNoIntroName(const char* filename, NoIntroName* parsed); + +/** + * Initializes a NoIntroName structure with empty values. + * + * @param parsed Structure to initialize + */ +void initNoIntroName(NoIntroName* parsed); + +#endif // NOINTRO_PARSER_H diff --git a/workspace/all/common/str_compare.c b/workspace/all/common/str_compare.c new file mode 100644 index 00000000..5f8ff82a --- /dev/null +++ b/workspace/all/common/str_compare.c @@ -0,0 +1,119 @@ +/** + * str_compare.c - String comparison utilities + * + * Provides natural sorting (human-friendly alphanumeric ordering). + */ + +#include "str_compare.h" +#include +#include + +// Articles to skip for sorting (order matters: "An " before "A ") +static const char* articles[] = {"The ", "An ", "A ", NULL}; + +/** + * Skips leading article ("The ", "A ", "An ") for sorting purposes. + * + * No-Intro convention moves articles to end for sorting, so + * "The Legend of Zelda" sorts under "L", not "T". + * + * @param s String to check + * @return Pointer past the article, or original pointer if no article + */ +const char* skip_article(const char* s) { + if (!s) + return s; + + for (int i = 0; articles[i]; i++) { + const char* article = articles[i]; + const char* p = s; + + // Case-insensitive prefix match + while (*article && tolower((unsigned char)*p) == tolower((unsigned char)*article)) { + p++; + article++; + } + + // If we matched the whole article, skip it + if (!*article) + return p; + } + + return s; +} + +/** + * Natural string comparison (case-insensitive). + * + * Algorithm: + * 1. Skip leading articles ("The ", "A ", "An ") for sorting + * 2. Skip leading zeros in numeric sequences + * 3. Compare digit sequences by length first (longer = larger) + * 4. If same length, compare digit by digit + * 5. Non-numeric characters compared case-insensitively + */ +int strnatcasecmp(const char* s1, const char* s2) { + if (!s1 && !s2) + return 0; + if (!s1) + return -1; + if (!s2) + return 1; + + // Skip leading articles for sorting + s1 = skip_article(s1); + s2 = skip_article(s2); + + while (*s1 && *s2) { + // Both are digits - compare as numbers + if (isdigit((unsigned char)*s1) && isdigit((unsigned char)*s2)) { + // Skip leading zeros + while (*s1 == '0') + s1++; + while (*s2 == '0') + s2++; + + // Count digit lengths + const char* n1 = s1; + const char* n2 = s2; + while (isdigit((unsigned char)*n1)) + n1++; + while (isdigit((unsigned char)*n2)) + n2++; + + int len1 = n1 - s1; + int len2 = n2 - s2; + + // Longer number is greater + if (len1 != len2) + return len1 - len2; + + // Same length - compare digit by digit + while (s1 < n1) { + if (*s1 != *s2) + return *s1 - *s2; + s1++; + s2++; + } + // Numbers are equal, continue with rest of string + continue; + } + + // Compare characters case-insensitively + int c1 = tolower((unsigned char)*s1); + int c2 = tolower((unsigned char)*s2); + + if (c1 != c2) + return c1 - c2; + + s1++; + s2++; + } + + // Handle end of strings + if (*s1) + return 1; + if (*s2) + return -1; + return 0; +} diff --git a/workspace/all/common/str_compare.h b/workspace/all/common/str_compare.h new file mode 100644 index 00000000..8fe445b9 --- /dev/null +++ b/workspace/all/common/str_compare.h @@ -0,0 +1,44 @@ +/** + * str_compare.h - String comparison utilities + * + * Provides natural sorting (human-friendly alphanumeric ordering) + * and other string comparison functions. + */ + +#ifndef STR_COMPARE_H +#define STR_COMPARE_H + +/** + * Skips leading article ("The ", "A ", "An ") for sorting purposes. + * + * No-Intro convention moves articles to end for sorting, so + * "The Legend of Zelda" sorts under "L", not "T". + * + * @param s String to check + * @return Pointer past the article, or original pointer if no article + */ +const char* skip_article(const char* s); + +/** + * Natural string comparison (case-insensitive). + * + * Compares strings in a human-friendly way where numeric sequences + * are compared by their numeric value rather than lexicographically. + * + * Also skips leading articles ("The ", "A ", "An ") so that + * "The Legend of Zelda" sorts under "L", not "T". This matches + * the No-Intro naming convention. + * + * Examples: + * "Game 2" < "Game 10" (unlike strcmp where "Game 10" < "Game 2") + * "a1b" < "a2b" < "a10b" + * "The Legend of Zelda" sorts with "Legend..." not "The..." + * "A Link to the Past" sorts with "Link..." not "A..." + * + * @param s1 First string to compare + * @param s2 Second string to compare + * @return Negative if s1 < s2, 0 if equal, positive if s1 > s2 + */ +int strnatcasecmp(const char* s1, const char* s2); + +#endif // STR_COMPARE_H diff --git a/workspace/all/common/utils.c b/workspace/all/common/utils.c index 9e736a34..3767a69c 100644 --- a/workspace/all/common/utils.c +++ b/workspace/all/common/utils.c @@ -10,6 +10,7 @@ #include "utils.h" #include "defines.h" #include "log.h" +#include "nointro_parser.h" #include #include #include @@ -385,27 +386,72 @@ void putInt(const char* path, int value) { // Name processing utilities /////////////////////////////// +/** + * Helper to move a trailing article to the front of a name. + * + * @param name String to transform (modified in place) + * @param suffix The suffix to look for (e.g., ", The") + * @param prefix The prefix to prepend (e.g., "The ") + * @return 1 if transformed, 0 if no match + */ +static int moveArticle(char* name, const char* suffix, const char* prefix) { + if (!suffixMatch((char*)suffix, name)) + return 0; + + int len = strlen(name); + int suffix_len = strlen(suffix); + int prefix_len = strlen(prefix); + int base_len = len - suffix_len; + + // Shift base name right, prepend article + memmove(name + prefix_len, name, base_len); + memcpy(name, prefix, prefix_len); + name[base_len + prefix_len] = '\0'; + return 1; +} + +/** + * Moves trailing article to the front of a name. + * + * Handles No-Intro convention where articles are moved to the end + * for sorting purposes: "Legend of Zelda, The" -> "The Legend of Zelda" + * + * Supported articles: The, A, An (case-insensitive matching) + * + * @param name Name to transform (modified in place, same length or shorter) + */ +void fixArticle(char* name) { + if (!name || !*name) + return; + + // Try each article pattern (order matters: ", An" before ", A") + if (moveArticle(name, ", The", "The ")) + return; + if (moveArticle(name, ", An", "An ")) + return; + if (moveArticle(name, ", A", "A ")) + return; +} + /** * Cleans a ROM or app path for display in the UI. * - * Performs multiple transformations: + * Uses the No-Intro parser for ROM names, which handles: * 1. Extracts filename from full path * 2. Removes file extensions (including multi-part like .p8.png) - * 3. Strips region codes and metadata in parentheses/brackets - * Example: "Super Mario (USA) (v1.2).nes" -> "Super Mario" - * 4. Removes trailing whitespace + * 3. Strips No-Intro tags: region (USA), language (En,Ja), version (v1.2), etc. + * 4. Moves articles to front: "Name, The" -> "The Name" * 5. Special handling: strips platform suffix from Tools paths * * @param in_name Input path (may be full path or just filename) * @param out_name Output buffer for cleaned name (min 256 bytes) * - * @note If all content is removed, the previous valid name is restored + * @note Uses nointro_parser for comprehensive ROM name handling */ void getDisplayName(const char* in_name, char* out_name) { - char* tmp; char work_name[256]; + char* tmp; strcpy(work_name, in_name); - strcpy(out_name, in_name); // Special case: hide platform suffix from Tools paths if (suffixMatch("/" PLATFORM, work_name)) { @@ -414,40 +460,10 @@ void getDisplayName(const char* in_name, char* out_name) { tmp[0] = '\0'; } - // Extract just the filename if we have a full path - tmp = strrchr(work_name, '/'); - if (tmp) - strcpy(out_name, tmp + 1); - - // Remove all file extensions (handles multi-part like .p8.png) - // Only removes extensions between 1-4 characters (plus dot) - while ((tmp = strrchr(out_name, '.')) != NULL) { - int len = strlen(tmp); - if (len > 2 && len <= 5) { - tmp[0] = '\0'; // Extended to 5 for .doom files - } else { - break; - } - } - - // Remove trailing metadata in parentheses or brackets - // Example: "Game (USA) [!]" -> "Game" - strcpy(work_name, out_name); - while ((tmp = strrchr(out_name, '(')) != NULL || (tmp = strrchr(out_name, '[')) != NULL) { - if (tmp == out_name) - break; // Don't remove if name would be empty - tmp[0] = '\0'; - } - - // Safety check: restore previous name if we removed everything - if (out_name[0] == '\0') - strcpy(out_name, work_name); - - // Remove trailing whitespace - tmp = out_name + strlen(out_name) - 1; - while (tmp > out_name && isspace((unsigned char)*tmp)) - tmp--; - tmp[1] = '\0'; + // Use No-Intro parser to get clean display name + NoIntroName parsed; + parseNoIntroName(work_name, &parsed); + strcpy(out_name, parsed.display_name); } /** diff --git a/workspace/all/common/utils.h b/workspace/all/common/utils.h index 3a0f73d2..61e627f0 100644 --- a/workspace/all/common/utils.h +++ b/workspace/all/common/utils.h @@ -222,6 +222,18 @@ int getInt(const char* path); // Name processing utilities /////////////////////////////// +/** + * Moves trailing article to the front of a name. + * + * Handles No-Intro convention where articles are moved to the end + * for sorting purposes: "Legend of Zelda, The" -> "The Legend of Zelda" + * + * Supported articles: The, A, An (case-insensitive matching) + * + * @param name Name to transform (modified in place) + */ +void fixArticle(char* name); + /** * Cleans a ROM or app path for display in the UI. * @@ -231,7 +243,9 @@ int getInt(const char* path); * 3. Strips region codes and metadata in parentheses/brackets * Example: "Super Mario (USA) (v1.2).nes" -> "Super Mario" * 4. Removes trailing whitespace - * 5. Special handling: strips platform suffix from Tools paths + * 5. Moves trailing articles to front (No-Intro convention) + * Example: "Legend of Zelda, The" -> "The Legend of Zelda" + * 6. Special handling: strips platform suffix from Tools paths * * @param in_name Input path (may be full path or just filename) * @param out_name Output buffer for cleaned name (min 256 bytes) diff --git a/workspace/all/minarch/makefile b/workspace/all/minarch/makefile index bbee6386..e76a02fa 100644 --- a/workspace/all/minarch/makefile +++ b/workspace/all/minarch/makefile @@ -21,7 +21,7 @@ SDL?=SDL TARGET = minarch INCDIR = -I. -I./libretro-common/include/ -I../common/ -I../../$(PLATFORM)/platform/ -SOURCE = $(TARGET).c ../common/scaler.c ../common/utils.c ../common/api.c ../common/log.c ../common/collections.c ../common/pad.c ../common/gfx_text.c ../common/minui_file_utils.c ../../$(PLATFORM)/platform/platform.c +SOURCE = $(TARGET).c ../common/scaler.c ../common/utils.c ../common/nointro_parser.c ../common/api.c ../common/log.c ../common/collections.c ../common/pad.c ../common/gfx_text.c ../common/minui_file_utils.c ../../$(PLATFORM)/platform/platform.c HEADERS = $(wildcard ../common/*.h) $(wildcard ../../$(PLATFORM)/platform/*.h) CC = $(CROSS_COMPILE)gcc diff --git a/workspace/all/minui/makefile b/workspace/all/minui/makefile index 85dc8aae..fd0f7a6e 100644 --- a/workspace/all/minui/makefile +++ b/workspace/all/minui/makefile @@ -21,7 +21,7 @@ SDL?=SDL TARGET = minui INCDIR = -I. -I../common/ -I../../$(PLATFORM)/platform/ -SOURCE = $(TARGET).c ../common/scaler.c ../common/utils.c ../common/api.c ../common/log.c ../common/collections.c ../common/pad.c ../common/gfx_text.c ../../$(PLATFORM)/platform/platform.c +SOURCE = $(TARGET).c ../common/scaler.c ../common/utils.c ../common/nointro_parser.c ../common/api.c ../common/log.c ../common/collections.c ../common/pad.c ../common/gfx_text.c ../common/str_compare.c ../../$(PLATFORM)/platform/platform.c HEADERS = $(wildcard ../common/*.h) $(wildcard ../../$(PLATFORM)/platform/*.h) CC = $(CROSS_COMPILE)gcc diff --git a/workspace/all/minui/minui.c b/workspace/all/minui/minui.c index f487442f..24269ef6 100644 --- a/workspace/all/minui/minui.c +++ b/workspace/all/minui/minui.c @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -42,6 +43,7 @@ #include "api.h" #include "collections.h" #include "defines.h" +#include "str_compare.h" #include "utils.h" /////////////////////////////// @@ -49,6 +51,183 @@ // This makes them reusable across all components and easier to test. /////////////////////////////// +/////////////////////////////// +// List View Configuration +// +// Tunable parameters for the list view rendering. +// All values are easily adjustable for tweaking the UI layout. +/////////////////////////////// + +// Thumbnail layout (percentages of screen width) +#define THUMB_TEXT_WIDTH_PERCENT 60 // Text area width when thumbnail shown (unselected items) +#define THUMB_SELECTED_WIDTH_PERCENT 100 // Selected item text width when thumbnail shown +#define THUMB_MAX_WIDTH_PERCENT 40 // Maximum thumbnail width + +// Thumbnail animation +#define THUMB_FADE_DURATION_MS 100 // Duration of fade-in animation in milliseconds +#define THUMB_FADE_FRAME_MS 16 // Assumed frame time (60fps = ~16ms) +#define THUMB_ALPHA_MAX 255 // Fully opaque +#define THUMB_ALPHA_MIN 0 // Fully transparent + +/////////////////////////////// +// Async thumbnail loader +// +// Loads thumbnails in a background thread to prevent UI stutter during scrolling. +// Design: Single worker thread with request superseding (new requests cancel pending). +// Thread-safe handoff via mutex-protected result surface. +/////////////////////////////// + +// Thumbnail loader state +static pthread_t thumb_thread; +static int thumb_thread_valid; // Whether thread was successfully created +static pthread_mutex_t thumb_mutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t thumb_cond = PTHREAD_COND_INITIALIZER; + +// Request state (protected by thumb_mutex) +static char thumb_request_path[MAX_PATH]; // Path to load (empty = no request) +static int thumb_request_width; // Max width for scaling +static int thumb_request_height; // Max height for scaling +static int thumb_shutdown; // Signal thread to exit + +// Result state (protected by thumb_mutex) +static SDL_Surface* thumb_result; // Loaded surface (NULL if no thumbnail) +static char thumb_result_path[MAX_PATH]; // Path that was loaded + +/** + * Background thread function for loading thumbnails. + * Waits for requests, loads and scales images, posts results. + */ +static void* thumb_loader_thread(void* arg) { + (void)arg; + LOG_debug("Thumbnail thread started"); + + char path[MAX_PATH]; + int max_w, max_h; + while (1) { + // Wait for a request + pthread_mutex_lock(&thumb_mutex); + while (thumb_request_path[0] == '\0' && !thumb_shutdown) { + pthread_cond_wait(&thumb_cond, &thumb_mutex); + } + + if (thumb_shutdown) { + pthread_mutex_unlock(&thumb_mutex); + break; + } + + // Copy request parameters + strcpy(path, thumb_request_path); + max_w = thumb_request_width; + max_h = thumb_request_height; + thumb_request_path[0] = '\0'; // Clear request + pthread_mutex_unlock(&thumb_mutex); + + // Load and scale (slow operations, done without lock) + // Note: caller already verified file exists before requesting + SDL_Surface* loaded = NULL; + SDL_Surface* orig = IMG_Load(path); + if (orig) { + loaded = GFX_scaleToFit(orig, max_w, max_h); + if (loaded != orig) + SDL_FreeSurface(orig); + } + + // Post result + pthread_mutex_lock(&thumb_mutex); + // Check if request was superseded while we were loading + if (thumb_request_path[0] == '\0') { + // No new request - post our result + if (thumb_result) + SDL_FreeSurface(thumb_result); + thumb_result = loaded; + strcpy(thumb_result_path, path); + } else { + // Request was superseded - discard our result + if (loaded) + SDL_FreeSurface(loaded); + } + pthread_mutex_unlock(&thumb_mutex); + } + + return NULL; +} + +/** + * Starts the thumbnail loader thread. + * Call once at startup. + */ +static void ThumbLoader_init(void) { + thumb_request_path[0] = '\0'; + thumb_result_path[0] = '\0'; + thumb_result = NULL; + thumb_shutdown = 0; + thumb_thread_valid = 0; + int rc = pthread_create(&thumb_thread, NULL, thumb_loader_thread, NULL); + if (rc != 0) { + LOG_error("Failed to create thumbnail thread: %d", rc); + } else { + thumb_thread_valid = 1; + } +} + +/** + * Stops the thumbnail loader thread and frees resources. + * Call once at shutdown. + */ +static void ThumbLoader_quit(void) { + if (thumb_thread_valid) { + pthread_mutex_lock(&thumb_mutex); + thumb_shutdown = 1; + pthread_cond_signal(&thumb_cond); + pthread_mutex_unlock(&thumb_mutex); + + pthread_join(thumb_thread, NULL); + } + + if (thumb_result) { + SDL_FreeSurface(thumb_result); + thumb_result = NULL; + } +} + +/** + * Requests a thumbnail to be loaded asynchronously. + * Supersedes any pending request. Returns immediately. + * + * @param path Path to image file + * @param max_w Maximum width after scaling + * @param max_h Maximum height after scaling + */ +static void ThumbLoader_request(const char* path, int max_w, int max_h) { + pthread_mutex_lock(&thumb_mutex); + strcpy(thumb_request_path, path); + thumb_request_width = max_w; + thumb_request_height = max_h; + pthread_cond_signal(&thumb_cond); + pthread_mutex_unlock(&thumb_mutex); +} + +/** + * Checks if a thumbnail is ready and retrieves it. + * Non-blocking - returns NULL if not ready or path doesn't match. + * + * @param path Path that was requested + * @return Surface if ready and path matches (caller takes ownership), NULL otherwise + */ +static SDL_Surface* ThumbLoader_get(const char* path) { + SDL_Surface* result = NULL; + + pthread_mutex_lock(&thumb_mutex); + if (thumb_result && exactMatch(thumb_result_path, path)) { + result = thumb_result; + thumb_result = NULL; + thumb_result_path[0] = '\0'; + } + pthread_mutex_unlock(&thumb_mutex); + + return result; +} + /////////////////////////////// // File browser entries /////////////////////////////// @@ -71,11 +250,46 @@ enum EntryType { typedef struct Entry { char* path; // Full path to file/folder char* name; // Cleaned display name (may be aliased via map.txt) + char* sort_key; // Sorting key (name with leading article skipped) char* unique; // Disambiguating text when multiple entries have same name int type; // ENTRY_DIR, ENTRY_PAK, or ENTRY_ROM int alpha; // Index into parent Directory's alphas array for L1/R1 navigation } Entry; +/** + * Sets an entry's display name and computes its sort key. + * + * The sort key is the name with any leading article ("The ", "A ", "An ") + * stripped, ensuring sorting and alphabetical indexing are consistent. + * + * @param self Entry to update + * @param name New display name (will be copied) + * @return 1 on success, 0 on allocation failure + */ +static int Entry_setName(Entry* self, const char* name) { + char* new_name = strdup(name); + if (!new_name) + return 0; + + // Compute sort key by skipping leading article + const char* key_start = skip_article(name); + char* new_sort_key = strdup(key_start); + if (!new_sort_key) { + free(new_name); + return 0; + } + + // Free old values and assign new ones + if (self->name) + free(self->name); + if (self->sort_key) + free(self->sort_key); + + self->name = new_name; + self->sort_key = new_sort_key; + return 1; +} + /** * Creates a new entry from a path. * @@ -99,8 +313,10 @@ static Entry* Entry_new(char* path, int type) { free(self); return NULL; } - self->name = strdup(display_name); - if (!self->name) { + // Initialize to NULL before Entry_setName + self->name = NULL; + self->sort_key = NULL; + if (!Entry_setName(self, display_name)) { free(self->path); free(self); return NULL; @@ -119,6 +335,7 @@ static Entry* Entry_new(char* path, int type) { static void Entry_free(Entry* self) { free(self->path); free(self->name); + free(self->sort_key); if (self->unique) free(self->unique); free(self); @@ -141,7 +358,11 @@ static int EntryArray_indexOf(Array* self, char* path) { } /** - * Comparison function for qsort - sorts entries alphabetically by name. + * Comparison function for qsort - sorts entries using natural sort. + * + * Uses sort_key for comparison, which has leading articles stripped. + * Natural sort orders numeric sequences by value, not lexicographically. + * Example: "Game 2" < "Game 10" (unlike strcmp where "Game 10" < "Game 2") * * @param a First entry pointer (Entry**) * @param b Second entry pointer (Entry**) @@ -150,7 +371,7 @@ static int EntryArray_indexOf(Array* self, char* path) { static int EntryArray_sortEntry(const void* a, const void* b) { Entry* item1 = *(Entry**)a; Entry* item2 = *(Entry**)b; - return strcasecmp(item1->name, item2->name); + return strnatcasecmp(item1->sort_key, item2->sort_key); } /** @@ -207,13 +428,14 @@ static IntArray* IntArray_new(void) { /** * Appends an integer to the array. + * Silently drops if array is full. * * @param self Array to modify * @param i Value to append - * - * @warning Does not check capacity - caller must ensure count < INT_ARRAY_MAX */ static void IntArray_push(IntArray* self, int i) { + if (self->count >= INT_ARRAY_MAX) + return; self->items[self->count++] = i; } @@ -248,16 +470,17 @@ typedef struct Directory { } Directory; /** - * Gets the alphabetical index for a string. + * Gets the alphabetical index for a sort key. * * Used to group entries by first letter for L1/R1 shoulder button navigation. + * Should be called with entry->sort_key (not entry->name) to match sort order. * - * @param str String to index + * @param sort_key Sort key string (articles already stripped) * @return 0 for non-alphabetic, 1-26 for A-Z (case-insensitive) */ -static int getIndexChar(char* str) { +static int getIndexChar(char* sort_key) { char i = 0; - char c = tolower(str[0]); + char c = tolower(sort_key[0]); if (c >= 'a' && c <= 'z') i = (c - 'a') + 1; return i; @@ -352,11 +575,8 @@ static void Directory_index(Directory* self) { char* filename = strrchr(entry->path, '/') + 1; char* alias = Hash_get(map, filename); if (alias) { - char* new_name = strdup(alias); - if (!new_name) + if (!Entry_setName(entry, alias)) continue; - free(entry->name); - entry->name = new_name; resort = 1; // Check if any alias starts with '.' (hidden) if (!filter && hide(entry->name)) @@ -389,22 +609,12 @@ static void Directory_index(Directory* self) { } // Detect duplicates and build alphabetical index + // Note: aliases were already applied above, no need to re-apply here Entry* prior = NULL; int alpha = -1; int index = 0; for (int i = 0; i < self->entries->count; i++) { Entry* entry = self->entries->items[i]; - if (map) { - char* filename = strrchr(entry->path, '/') + 1; - char* alias = Hash_get(map, filename); - if (alias) { - char* new_name = strdup(alias); - if (new_name) { - free(entry->name); - entry->name = new_name; - } - } - } // Detect duplicate display names if (prior != NULL && exactMatch(prior->name, entry->name)) { @@ -452,8 +662,9 @@ static void Directory_index(Directory* self) { } // Build alphabetical index for L1/R1 navigation + // Uses sort_key which has articles stripped, matching sort order if (!skip_index) { - int a = getIndexChar(entry->name); + int a = getIndexChar(entry->sort_key); if (a != alpha) { index = self->alphas->count; IntArray_push(self->alphas, i); @@ -1106,12 +1317,8 @@ static Array* getRoot(void) { char* filename = strrchr(entry->path, '/') + 1; char* alias = Hash_get(map, filename); if (alias) { - char* new_name = strdup(alias); - if (new_name) { - free(entry->name); - entry->name = new_name; + if (Entry_setName(entry, alias)) resort = 1; - } } } if (resort) @@ -1186,11 +1393,7 @@ static Array* getRecents(void) { if (!entry) continue; if (recent->alias) { - char* new_name = strdup(recent->alias); - if (new_name) { - free(entry->name); - entry->name = new_name; - } + Entry_setName(entry, recent->alias); } Array_push(entries, entry); } @@ -1268,11 +1471,9 @@ static Array* getDiscs(char* path) { Entry* entry = Entry_new(disc_path, ENTRY_ROM); if (!entry) continue; - free(entry->name); char name[16]; sprintf(name, "Disc %i", disc); - entry->name = strdup(name); - if (!entry->name) { + if (!Entry_setName(entry, name)) { Entry_free(entry); continue; } @@ -1984,23 +2185,28 @@ int main(int argc, char* argv[]) { simple_mode = exists(SIMPLE_MODE_PATH); LOG_info("Starting MinUI on %s", PLATFORM); + + LOG_debug("InitSettings"); InitSettings(); + LOG_debug("GFX_init"); SDL_Surface* screen = GFX_init(MODE_MAIN); - // LOG_info("- graphics init: %lu", SDL_GetTicks() - main_begin); + LOG_debug("PAD_init"); PAD_init(); - // LOG_info("- input init: %lu", SDL_GetTicks() - main_begin); + LOG_debug("PWR_init"); PWR_init(); if (!HAS_POWER_BUTTON && !simple_mode) PWR_disableSleep(); - // LOG_info("- power init: %lu", SDL_GetTicks() - main_begin); SDL_Surface* version = NULL; + LOG_debug("ThumbLoader_init"); + ThumbLoader_init(); + + LOG_debug("Menu_init"); Menu_init(); - // LOG_info("- menu init: %lu", SDL_GetTicks() - main_begin); // Reduce CPU speed for menu browsing (saves power and heat) PWR_setCPUSpeed(CPU_SPEED_MENU); @@ -2012,7 +2218,14 @@ int main(int argc, char* argv[]) { int show_setting = 0; // 1=brightness, 2=volume overlay int was_online = PLAT_isOnline(); - // LOG_info("- loop start: %lu", SDL_GetTicks() - main_begin); + // Async thumbnail loading state + SDL_Surface* cached_thumb = NULL; // Currently displayed thumbnail + char cached_thumb_path[MAX_PATH] = {0}; // Path of cached thumbnail + Entry* last_rendered_entry = NULL; // Last entry we rendered (for change detection) + int thumb_exists = 0; // Whether thumbnail exists for current entry + int thumb_alpha = THUMB_ALPHA_MAX; // Current fade alpha, starts full for instant display + + LOG_debug("Entering main loop"); while (!quit) { GFX_startFrame(); unsigned long now = SDL_GetTicks(); @@ -2155,7 +2368,6 @@ int main(int argc, char* argv[]) { Entry_open(top->entries->items[top->selected]); total = top->entries->count; dirty = 1; - if (total > 0) readyResume(top->entries->items[top->selected]); } else if (PAD_justPressed(BTN_B) && stack->count > 1) { @@ -2167,75 +2379,88 @@ int main(int argc, char* argv[]) { } } - // Rendering - if (dirty) { - GFX_clear(screen); - - int ox = 0; // Initialize to avoid uninitialized warning - int oy; - - // Thumbnail support with caching: - // For an entry named "NAME.EXT", check for /.res/NAME.EXT.png - // Image is scaled to fit within available space and cached for performance - static char cached_thumb_path[MAX_PATH] = {0}; - static SDL_Surface* cached_thumb = NULL; + // Thumbnail handling - all logic in one place + // Detect when selected entry changes and update thumbnail state + Entry* current_entry = (total > 0) ? top->entries->items[top->selected] : NULL; + if (current_entry != last_rendered_entry) { + // Selection changed - reset thumbnail state + if (cached_thumb) { + SDL_FreeSurface(cached_thumb); + cached_thumb = NULL; + } + cached_thumb_path[0] = '\0'; + thumb_exists = 0; + + if (current_entry && current_entry->path && !show_version) { + char* last_slash = strrchr(current_entry->path, '/'); + if (last_slash && last_slash[1] != '\0') { + // Build thumbnail path: /dir/.res/filename.png + int dir_len = (int)(last_slash - current_entry->path); + if (dir_len > 0 && dir_len < MAX_PATH - 32) { // Leave room for /.res/name.png + snprintf(cached_thumb_path, MAX_PATH, "%.*s/.res/%s.png", dir_len, + current_entry->path, last_slash + 1); + thumb_exists = exists(cached_thumb_path); + + // Request async load if thumbnail exists + if (thumb_exists) { + int padding = DP(ui.edge_padding); + int max_width = + (ui.screen_width_px * THUMB_MAX_WIDTH_PERCENT) / 100 - padding; + int max_height = ui.screen_height_px - (padding * 2); + if (max_width > 0 && max_height > 0) { + ThumbLoader_request(cached_thumb_path, max_width, max_height); + } + } + } + } + } + last_rendered_entry = current_entry; + } - int had_thumb = 0; - if (!show_version && total > 0) { - Entry* entry = top->entries->items[top->selected]; - char res_path[MAX_PATH]; + // Check if thumbnail is actually loaded and ready to display + int showing_thumb = (!show_version && total > 0 && cached_thumb && cached_thumb->w > 0 && + cached_thumb->h > 0); - char res_root[MAX_PATH]; - strcpy(res_root, entry->path); + // Poll for async thumbnail load completion + if (thumb_exists && !cached_thumb && cached_thumb_path[0]) { + SDL_Surface* loaded = ThumbLoader_get(cached_thumb_path); + if (loaded) { + cached_thumb = loaded; + thumb_alpha = THUMB_ALPHA_MIN; // Start fade from transparent + dirty = 1; + } + } - char tmp_path[MAX_PATH]; - strcpy(tmp_path, entry->path); - char* res_name = strrchr(tmp_path, '/') + 1; + // Animate thumbnail fade-in + if (cached_thumb && thumb_alpha < THUMB_ALPHA_MAX) { + int fade_step = (THUMB_ALPHA_MAX * THUMB_FADE_FRAME_MS) / THUMB_FADE_DURATION_MS; + thumb_alpha += fade_step; + if (thumb_alpha > THUMB_ALPHA_MAX) + thumb_alpha = THUMB_ALPHA_MAX; + dirty = 1; + } - char* tmp = strrchr(res_root, '/'); - tmp[0] = '\0'; + // Rendering + if (dirty) { + GFX_clear(screen); - sprintf(res_path, "%s/.res/%s.png", res_root, res_name); - LOG_debug("res_path: %s", res_path); - if (exists(res_path)) { - had_thumb = 1; - SDL_Surface* thumb = NULL; + int oy; - // Check if we have this thumbnail cached - if (cached_thumb && strcmp(res_path, cached_thumb_path) == 0) { - // Use cached thumbnail - thumb = cached_thumb; - } else { - // Load and scale new thumbnail - SDL_Surface* thumb_orig = IMG_Load(res_path); - - // Scale to fit within available space (50% of width, full height minus padding) - int padding = DP(ui.edge_padding); - int max_width = (ui.screen_width_px / 2) - padding; - int max_height = ui.screen_height_px - (padding * 2); - thumb = GFX_scaleToFit(thumb_orig, max_width, max_height); - - // Free original if different from scaled - if (thumb != thumb_orig) - SDL_FreeSurface(thumb_orig); - - // Free old cached thumbnail if exists - if (cached_thumb) - SDL_FreeSurface(cached_thumb); - - // Cache the new thumbnail - cached_thumb = thumb; - strcpy(cached_thumb_path, res_path); - } - // Position on right side with padding, vertically centered - int padding = DP(ui.edge_padding); - ox = ui.screen_width_px - thumb->w - padding; - oy = (ui.screen_height_px - thumb->h) / 2; - SDL_BlitSurface(thumb, NULL, screen, &(SDL_Rect){ox, oy, 0, 0}); - } + // Display cached thumbnail if available (right-aligned with padding) + if (showing_thumb) { + int padding = DP(ui.edge_padding); + int ox = ui.screen_width_px - cached_thumb->w - padding; + oy = (ui.screen_height_px - cached_thumb->h) / 2; + // Clamp alpha to valid range for SDL compatibility + int safe_alpha = (thumb_alpha < 0) ? 0 : ((thumb_alpha > 255) ? 255 : thumb_alpha); + SDLX_SetAlpha(cached_thumb, SDL_SRCALPHA, safe_alpha); + SDL_BlitSurface(cached_thumb, NULL, screen, &(SDL_Rect){ox, oy, 0, 0}); } + // Text area width when thumbnail is showing (unselected items) + int text_area_width = (ui.screen_width_px * THUMB_TEXT_WIDTH_PERCENT) / 100; + int ow = GFX_blitHardwareGroup(screen, show_setting); if (show_version) { @@ -2335,12 +2560,23 @@ int main(int argc, char* argv[]) { char* entry_name = entry->name; char* entry_unique = entry->unique; // Calculate available width in pixels - // ox is in pixels (thumbnail offset), screen width converted from DP to pixels - int available_width = - (had_thumb && j != selected_row ? ox : DP(ui.screen_width)) - - DP(ui.edge_padding * 2); - if (i == top->start && !(had_thumb && j != selected_row)) - available_width -= ow; // + // Use fixed widths when thumbnail is showing (prevents text reflow) + int available_width; + if (showing_thumb) { + if (j == selected_row) { + // Selected item gets more width + available_width = + (ui.screen_width_px * THUMB_SELECTED_WIDTH_PERCENT) / 100 - + DP(ui.edge_padding * 2); + } else { + // Unselected items constrained to text area + available_width = text_area_width - DP(ui.edge_padding * 2); + } + } else { + available_width = DP(ui.screen_width) - DP(ui.edge_padding * 2); + if (i == top->start) + available_width -= ow; + } SDL_Color text_color = COLOR_WHITE; @@ -2449,10 +2685,13 @@ int main(int argc, char* argv[]) { if (version) SDL_FreeSurface(version); + if (cached_thumb) + SDL_FreeSurface(cached_thumb); + ThumbLoader_quit(); Menu_quit(); PWR_quit(); PAD_quit(); GFX_quit(); QuitSettings(); -} \ No newline at end of file +}