diff --git a/README.md b/README.md index a293baa346..0789107d27 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,42 @@ +A fork of NextUI to improve usability for myself and make the OS easier to use for my parents. + +**This repo is a hobby project, it is not actively maintained.** + +If you want to use NextUI for your own handheld, get it from [Love Retro](https://github.com/LoveRetro/NextUI). + +## AI Disclaimer + +In the interest of full transparency: this fork was developed using AI tooling, specifically Claude Code. + +I've been a professional software developer since 2020 and AI agents are just part of my workflow. I have never developed C or C++ professionally (though I do like to use C for hobby projects) so while I've reviewed every code change, I am limited by my lack of experience, esp for C-specific things like safe memory management. (If you care, my primary languages are C# for backend/desktop and TypeScript for frontend.) + +I have done my best to keep PRs and commits small for reviewability should anyone want to cherry pick features out of this fork. + +## Context + +I got my parents an ANBERNIC RG 40XXH for Christmas so they could play their childhood games, but I knew immediately I was not going to be using the stock OS. After trying a few options I landed on using NextUI because it was focused and booted in and out of games extremely fast. It still felt open and breakable though, so I started this personal fork to lock it down and remove some footguns that I shot myself with in the past using NextUI on my own ANBERNIC RG SP. + +## Changes + +Everything was developed as atomic features that could be toggled via the settings. I did my best to develop the features in one PR per feature-setting to make cherry-picking easier if other folks want to take some of these. Every PR is tagged with what feature it touched for easier navigation. + +In order of smallest to largest: + +| Feature | Setting | Description | Reason | Limitations | +|-|-|-|-|-| +| Screen margin | Appearance / Screen margin | Adjusts the margin between the edge of the screen and widgets like the battery and input prompts. | Reclaims space on low resolution devices | Overrides both low res and high res / external displays with the same value when by default they are different. | +| Quick menu title | Appearance / Show Quick Menu title | Shows a title for each quick setting button in the top left of the screen. | Remove ambiguity in what the icon-only buttons do in the quick menu. | | +| Quick menu tiles | Appearch / Show X in Quick Menu | A series of options to disable the tiles that appear in the quick menu, specifically recents, games, and tools. | Recents and games as options don't make sense for my needs and at that point it was just tools so I cut it too for a cleaner look. | | +| All games list | Appearance / All games view | Skips the system menu and just shows all games in a single alphabatized list. | With a small games library (10-12 games) the system menu was more a burden than anything. | Recently played doesn't have anywhere to live so only exists in the quick menu. | +| Resume with A | System / Resume with A | Swaps the A/X behavior so resume is mapped to A and open is mapped to X | A is the default way someone would try to launch a game so this effectively lets someone make resuming their default action. | | +| Save & Quit | System / Allow Save & Quit from menu | Adds the Save & Quit option to the in-game menu if the game is targeting a save slot already. | Remembering a hotkey is more annoying than using the menu. | | +| Grid view | Appearance / Content layout + Appearance / Grid rows | Show the list in a horizontal grid of icon buttons, rendering the game title in the top left corner. | A horizontal grid is more familiar to people trained on smartphones. | Entries that don't have an icon defined use their first letter as their icon and those icons have to be provided manually. | + +## Testing + +All testing has been done on device with the H700 powered ANBERNIC RG 40XXH. I don't have any devices running other NextUI supported architectures to test on them. + +
diff --git a/workspace/all/common/api.c b/workspace/all/common/api.c index e107225da5..ac29152421 100644 --- a/workspace/all/common/api.c +++ b/workspace/all/common/api.c @@ -362,12 +362,25 @@ FALLBACK_IMPLEMENTATION void PLAT_getGPUUsage() perf.gpu_usage = 0.0; } +static char system_font_path[MAX_PATH] = ""; + +const char *GFX_getSystemFontPath(void) +{ + return system_font_path; +} + int GFX_loadSystemFont(const char *fontPath) { // Load/Reload fonts if (!TTF_WasInit()) TTF_Init(); + if (fontPath && fontPath != system_font_path) + { + strncpy(system_font_path, fontPath, sizeof(system_font_path) - 1); + system_font_path[sizeof(system_font_path) - 1] = '\0'; + } + TTF_CloseFont(font.large); TTF_CloseFont(font.medium); TTF_CloseFont(font.small); @@ -2356,18 +2369,25 @@ SDL_Surface* GFX_createScreenFormatSurface(int width, int height) ); } +int GFX_getScreenMargin(void) +{ + int margin = CFG_getScreenMargin(); + return SCALE1(margin < 0 ? PADDING : margin); +} + int GFX_blitHardwareGroup(SDL_Surface *dst, int show_setting) { int ox; int oy; int ow = 0; + int margin = GFX_getScreenMargin(); if (show_setting && !GetHDMI()) { // Use the helper function to render the indicator at the standard position ow = SCALE1(PILL_SIZE + SETTINGS_WIDTH + 10 + 4); - ox = dst->w - SCALE1(PADDING) - ow; - oy = SCALE1(PADDING); + ox = dst->w - margin - ow; + oy = margin; GFX_blitHardwareIndicator(dst, ox, oy, (IndicatorType)show_setting); } else @@ -2384,8 +2404,8 @@ int GFX_blitHardwareGroup(SDL_Surface *dst, int show_setting) if (!show_bt && !show_wifi && !show_clock) { ow = SCALE1(PILL_SIZE); - ox = dst->w - SCALE1(PADDING) - ow; - oy = SCALE1(PADDING); + ox = dst->w - margin - ow; + oy = margin; GFX_blitPillColor(ASSET_WHITE_PILL, dst, &(SDL_Rect){ox, oy, ow, SCALE1(PILL_SIZE)}, THEME_COLOR2, RGB_WHITE); @@ -2435,8 +2455,8 @@ int GFX_blitHardwareGroup(SDL_Surface *dst, int show_setting) ow += clock_width + SCALE1(BUTTON_MARGIN); } - ox = dst->w - SCALE1(PADDING) - ow; - oy = SCALE1(PADDING); + ox = dst->w - margin - ow; + oy = margin; GFX_blitPillColor(ASSET_WHITE_PILL, dst, &(SDL_Rect){ox, oy, ow, SCALE1(PILL_SIZE)}, THEME_COLOR2, RGB_WHITE); ox += SCALE1(BUTTON_MARGIN); @@ -2523,8 +2543,9 @@ int GFX_blitButtonGroup(char **pairs, int primary, SDL_Surface *dst, int align_r int w = 0; // individual button dimension int h = 0; // hints index ow = 0; // full pill width - ox = align_right ? dst->w - SCALE1(PADDING) : SCALE1(PADDING); - oy = dst->h - SCALE1(PADDING + PILL_SIZE); + int margin = GFX_getScreenMargin(); + ox = align_right ? dst->w - margin : margin; + oy = dst->h - margin - SCALE1(PILL_SIZE); for (int i = 0; i < 2; i++) { diff --git a/workspace/all/common/api.h b/workspace/all/common/api.h index e7b5d6808c..e8a1b71137 100644 --- a/workspace/all/common/api.h +++ b/workspace/all/common/api.h @@ -401,7 +401,10 @@ int GFX_getButtonWidth(char* hint, char* button); void GFX_blitButton(char* hint, char*button, SDL_Surface* dst, SDL_Rect* dst_rect); void GFX_blitMessage(TTF_Font* font, char* msg, SDL_Surface* dst, SDL_Rect* dst_rect); -int GFX_blitHardwareGroup(SDL_Surface* dst, int show_setting); +int GFX_blitHardwareGroup(SDL_Surface* dst, int show_setting); // returns the status pill's width +// Scaled distance between the screen edges and edge-anchored UI (status pill, +// button hints, list title): the "Screen margin" setting, PADDING by default. +int GFX_getScreenMargin(void); void GFX_blitHardwareHints(SDL_Surface* dst, int show_setting); void GFX_blitTopCurtain(SDL_Surface* dst); void GFX_blitBottomCurtain(SDL_Surface* dst); @@ -449,6 +452,8 @@ void GFX_ApplyRoundedCorners16(SDL_Surface* surface, SDL_Rect* rect, int radius) void GFX_ApplyRoundedCorners_4444(SDL_Surface* surface, SDL_Rect* rect, int radius); // for both ARGB8888 and RGBA8888 void GFX_ApplyRoundedCorners_8888(SDL_Surface* surface, SDL_Rect* rect, int radius); +// Path of the currently loaded UI font file ("" before GFX_init), for opening it at other sizes +const char* GFX_getSystemFontPath(void); /////////////////////////////// typedef struct SND_Frame { diff --git a/workspace/all/common/config.c b/workspace/all/common/config.c index aea4cb4967..68fbc1c727 100644 --- a/workspace/all/common/config.c +++ b/workspace/all/common/config.c @@ -60,7 +60,16 @@ void CFG_defaults(NextUISettings *cfg) .showGameArt = CFG_DEFAULT_SHOWGAMEART, .gameSwitcherScaling = CFG_DEFAULT_GAMESWITCHERSCALING, .defaultView = CFG_DEFAULT_VIEW, + .contentLayout = CFG_DEFAULT_CONTENTLAYOUT, + .gridRows = CFG_DEFAULT_GRIDROWS, + .screenMargin = CFG_DEFAULT_SCREENMARGIN, + .resumeWithA = CFG_DEFAULT_RESUMEWITHA, + .allGamesView = CFG_DEFAULT_ALLGAMESVIEW, .showQuickSwitcherUi = CFG_DEFAULT_SHOWQUICKWITCHERUI, + .showQuickMenuTitle = CFG_DEFAULT_SHOWQUICKMENUTITLE, + .quickMenuRecents = CFG_DEFAULT_QUICKMENURECENTS, + .quickMenuGames = CFG_DEFAULT_QUICKMENUGAMES, + .quickMenuTools = CFG_DEFAULT_QUICKMENUTOOLS, .gameSwitcherCurtain = CFG_DEFAULT_GAMESWITCHER_CURTAIN, .muteLeds = CFG_DEFAULT_MUTELEDS, @@ -76,6 +85,7 @@ void CFG_defaults(NextUISettings *cfg) .romsUseFolderBackground = CFG_DEFAULT_ROMSUSEFOLDERBACKGROUND, .saveFormat = CFG_DEFAULT_SAVEFORMAT, .stateFormat = CFG_DEFAULT_STATEFORMAT, + .saveQuitMenu = CFG_DEFAULT_SAVEQUITMENU, .useExtractedFileName = CFG_DEFAULT_EXTRACTEDFILENAME, .ntp = CFG_DEFAULT_NTP, @@ -336,6 +346,11 @@ void CFG_init(FontLoad_callback_t cb, ColorSet_callback_t ccb) CFG_setStateFormat(temp_value); continue; } + if (sscanf(line, "saveQuitMenu=%i", &temp_value) == 1) + { + CFG_setSaveQuitMenu((bool)temp_value); + continue; + } if (sscanf(line, "useExtractedFileName=%i", &temp_value) == 1) { CFG_setUseExtractedFileName((bool)temp_value); @@ -382,11 +397,56 @@ void CFG_init(FontLoad_callback_t cb, ColorSet_callback_t ccb) CFG_setDefaultView(temp_value); continue; } + if (sscanf(line, "contentLayout=%i", &temp_value) == 1) + { + CFG_setContentLayout(temp_value); + continue; + } + if (sscanf(line, "gridRows=%i", &temp_value) == 1) + { + CFG_setGridRows(temp_value); + continue; + } + if (sscanf(line, "resumeWithA=%i", &temp_value) == 1) + { + CFG_setResumeWithA((bool)temp_value); + continue; + } + if (sscanf(line, "screenMargin=%i", &temp_value) == 1) + { + CFG_setScreenMargin(temp_value); + continue; + } + if (sscanf(line, "allGamesView=%i", &temp_value) == 1) + { + CFG_setAllGamesView((bool)temp_value); + continue; + } if (sscanf(line, "quickSwitcherUi=%i", &temp_value) == 1) { CFG_setShowQuickswitcherUI(temp_value); continue; } + if (sscanf(line, "quickMenuTitle=%i", &temp_value) == 1) + { + CFG_setShowQuickMenuTitle((bool)temp_value); + continue; + } + if (sscanf(line, "quickMenuRecents=%i", &temp_value) == 1) + { + CFG_setQuickMenuRecents((bool)temp_value); + continue; + } + if (sscanf(line, "quickMenuGames=%i", &temp_value) == 1) + { + CFG_setQuickMenuGames((bool)temp_value); + continue; + } + if (sscanf(line, "quickMenuTools=%i", &temp_value) == 1) + { + CFG_setQuickMenuTools((bool)temp_value); + continue; + } if (sscanf(line, "wifiDiagnostics=%i", &temp_value) == 1) { CFG_setWifiDiagnostics(temp_value); @@ -914,6 +974,17 @@ void CFG_setStateFormat(int f) CFG_sync(); } +bool CFG_getSaveQuitMenu(void) +{ + return settings.saveQuitMenu; +} + +void CFG_setSaveQuitMenu(bool on) +{ + settings.saveQuitMenu = on; + CFG_sync(); +} + bool CFG_getUseExtractedFileName(void) { return settings.useExtractedFileName; @@ -987,6 +1058,61 @@ void CFG_setDefaultView(int view) CFG_sync(); } +int CFG_getContentLayout(void) +{ + return settings.contentLayout; +} + +void CFG_setContentLayout(int layout) +{ + settings.contentLayout = (layout == CONTENT_LAYOUT_GRID) ? CONTENT_LAYOUT_GRID : CONTENT_LAYOUT_LIST; + CFG_sync(); +} + +int CFG_getGridRows(void) +{ + return settings.gridRows; +} + +void CFG_setGridRows(int rows) +{ + settings.gridRows = clamp(rows, GRID_ROWS_MIN, GRID_ROWS_MAX); + CFG_sync(); +} + +int CFG_getScreenMargin(void) +{ + return settings.screenMargin; +} + +void CFG_setScreenMargin(int margin) +{ + settings.screenMargin = (margin < 0) ? SCREEN_MARGIN_DEFAULT : clamp(margin, 0, SCREEN_MARGIN_MAX); + CFG_sync(); +} + +bool CFG_getResumeWithA(void) +{ + return settings.resumeWithA; +} + +void CFG_setResumeWithA(bool on) +{ + settings.resumeWithA = on; + CFG_sync(); +} + +bool CFG_getAllGamesView(void) +{ + return settings.allGamesView; +} + +void CFG_setAllGamesView(bool on) +{ + settings.allGamesView = on; + CFG_sync(); +} + bool CFG_getShowQuickswitcherUI(void) { return settings.showQuickSwitcherUi; @@ -998,6 +1124,50 @@ void CFG_setShowQuickswitcherUI(bool on) CFG_sync(); } +bool CFG_getShowQuickMenuTitle(void) +{ + return settings.showQuickMenuTitle; +} + +void CFG_setShowQuickMenuTitle(bool on) +{ + settings.showQuickMenuTitle = on; + CFG_sync(); +} + +bool CFG_getQuickMenuRecents(void) +{ + return settings.quickMenuRecents; +} + +void CFG_setQuickMenuRecents(bool on) +{ + settings.quickMenuRecents = on; + CFG_sync(); +} + +bool CFG_getQuickMenuGames(void) +{ + return settings.quickMenuGames; +} + +void CFG_setQuickMenuGames(bool on) +{ + settings.quickMenuGames = on; + CFG_sync(); +} + +bool CFG_getQuickMenuTools(void) +{ + return settings.quickMenuTools; +} + +void CFG_setQuickMenuTools(bool on) +{ + settings.quickMenuTools = on; + CFG_sync(); +} + bool CFG_getWifiDiagnostics(void) { return settings.wifiDiagnostics; @@ -1450,6 +1620,10 @@ void CFG_get(const char *key, char *value) { sprintf(value, "%i", CFG_getStateFormat()); } + else if (strcmp(key, "saveQuitMenu") == 0) + { + sprintf(value, "%i", (int)(CFG_getSaveQuitMenu())); + } else if (strcmp(key, "useExtractedFileName") == 0) { sprintf(value, "%i", CFG_getUseExtractedFileName()); @@ -1482,10 +1656,46 @@ void CFG_get(const char *key, char *value) { sprintf(value, "%i", (int)(CFG_getDefaultView())); } + else if (strcmp(key, "contentLayout") == 0) + { + sprintf(value, "%i", CFG_getContentLayout()); + } + else if (strcmp(key, "gridRows") == 0) + { + sprintf(value, "%i", CFG_getGridRows()); + } + else if (strcmp(key, "screenMargin") == 0) + { + sprintf(value, "%i", CFG_getScreenMargin()); + } + else if (strcmp(key, "resumeWithA") == 0) + { + sprintf(value, "%i", (int)(CFG_getResumeWithA())); + } + else if (strcmp(key, "allGamesView") == 0) + { + sprintf(value, "%i", (int)(CFG_getAllGamesView())); + } else if (strcmp(key, "quickSwitcherUi") == 0) { sprintf(value, "%i", (int)(CFG_getShowQuickswitcherUI())); } + else if (strcmp(key, "quickMenuTitle") == 0) + { + sprintf(value, "%i", (int)(CFG_getShowQuickMenuTitle())); + } + else if (strcmp(key, "quickMenuRecents") == 0) + { + sprintf(value, "%i", (int)(CFG_getQuickMenuRecents())); + } + else if (strcmp(key, "quickMenuGames") == 0) + { + sprintf(value, "%i", (int)(CFG_getQuickMenuGames())); + } + else if (strcmp(key, "quickMenuTools") == 0) + { + sprintf(value, "%i", (int)(CFG_getQuickMenuTools())); + } else if (strcmp(key, "wifiDiagnostics") == 0) { sprintf(value, "%i", (int)(CFG_getWifiDiagnostics())); @@ -1653,6 +1863,7 @@ void CFG_sync(void) fprintf(file, "romfolderbg=%i\n", settings.romsUseFolderBackground); fprintf(file, "saveFormat=%i\n", settings.saveFormat); fprintf(file, "stateFormat=%i\n", settings.stateFormat); + fprintf(file, "saveQuitMenu=%i\n", settings.saveQuitMenu); fprintf(file, "useExtractedFileName=%i\n", settings.useExtractedFileName); fprintf(file, "muteLeds=%i\n", settings.muteLeds); fprintf(file, "fn1action=%s\n", settings.fnAction[0]); @@ -1661,7 +1872,16 @@ void CFG_sync(void) fprintf(file, "artWidth=%i\n", (int)(settings.gameArtWidth * 100)); fprintf(file, "wifi=%i\n", settings.wifi); fprintf(file, "defaultView=%i\n", settings.defaultView); + fprintf(file, "contentLayout=%i\n", settings.contentLayout); + fprintf(file, "gridRows=%i\n", settings.gridRows); + fprintf(file, "screenMargin=%i\n", settings.screenMargin); + fprintf(file, "resumeWithA=%i\n", settings.resumeWithA); + fprintf(file, "allGamesView=%i\n", settings.allGamesView); fprintf(file, "quickSwitcherUi=%i\n", settings.showQuickSwitcherUi); + fprintf(file, "quickMenuTitle=%i\n", settings.showQuickMenuTitle); + fprintf(file, "quickMenuRecents=%i\n", settings.quickMenuRecents); + fprintf(file, "quickMenuGames=%i\n", settings.quickMenuGames); + fprintf(file, "quickMenuTools=%i\n", settings.quickMenuTools); fprintf(file, "wifiDiagnostics=%i\n", settings.wifiDiagnostics); fprintf(file, "bluetooth=%i\n", settings.bluetooth); fprintf(file, "btDiagnostics=%i\n", settings.bluetoothDiagnostics); @@ -1728,6 +1948,7 @@ void CFG_print(void) printf("\t\"romfolderbg\": %i,\n", settings.romsUseFolderBackground); printf("\t\"saveFormat\": %i,\n", settings.saveFormat); printf("\t\"stateFormat\": %i,\n", settings.stateFormat); + printf("\t\"saveQuitMenu\": %i,\n", settings.saveQuitMenu); printf("\t\"useExtractedFileName\": %i,\n", settings.useExtractedFileName); printf("\t\"muteLeds\": %i,\n", settings.muteLeds); printf("\t\"fn1action\": \"%s\",\n", settings.fnAction[0]); @@ -1736,7 +1957,16 @@ void CFG_print(void) printf("\t\"artWidth\": %i,\n", (int)(settings.gameArtWidth * 100)); printf("\t\"wifi\": %i,\n", settings.wifi); printf("\t\"defaultView\": %i,\n", settings.defaultView); + printf("\t\"contentLayout\": %i,\n", settings.contentLayout); + printf("\t\"gridRows\": %i,\n", settings.gridRows); + printf("\t\"screenMargin\": %i,\n", settings.screenMargin); + printf("\t\"resumeWithA\": %i,\n", settings.resumeWithA); + printf("\t\"allGamesView\": %i,\n", settings.allGamesView); printf("\t\"quickSwitcherUi\": %i,\n", settings.showQuickSwitcherUi); + printf("\t\"quickMenuTitle\": %i,\n", settings.showQuickMenuTitle); + printf("\t\"quickMenuRecents\": %i,\n", settings.quickMenuRecents); + printf("\t\"quickMenuGames\": %i,\n", settings.quickMenuGames); + printf("\t\"quickMenuTools\": %i,\n", settings.quickMenuTools); printf("\t\"wifiDiagnostics\": %i,\n", settings.wifiDiagnostics); printf("\t\"bluetooth\": %i,\n", settings.bluetooth); printf("\t\"btDiagnostics\": %i,\n", settings.bluetoothDiagnostics); diff --git a/workspace/all/common/config.h b/workspace/all/common/config.h index bad3f5fc00..6a7650980d 100644 --- a/workspace/all/common/config.h +++ b/workspace/all/common/config.h @@ -55,6 +55,14 @@ enum { SCREEN_OFF }; +// How the game list and system list are laid out +enum { + CONTENT_LAYOUT_LIST, + CONTENT_LAYOUT_GRID, +}; +#define GRID_ROWS_MIN 1 +#define GRID_ROWS_MAX 4 + // Achievement sort order options enum { RA_SORT_UNLOCKED_FIRST, @@ -139,7 +147,16 @@ typedef struct bool showFolderNamesAtRoot; bool romsUseFolderBackground; bool showQuickSwitcherUi; + bool showQuickMenuTitle; // name the selected Quick Menu item in the top-left pill + bool quickMenuRecents; // Quick Menu top row shows Recents (when there are any) + bool quickMenuGames; // Quick Menu top row shows Games + bool quickMenuTools; // Quick Menu top row shows Tools (when there are any) int defaultView; + int contentLayout; // CONTENT_LAYOUT_* + int gridRows; // GRID_ROWS_MIN..GRID_ROWS_MAX + int screenMargin; // unscaled px between screen edges and edge UI, SCREEN_MARGIN_DEFAULT = PADDING + bool allGamesView; // root shows every game (A-Z) instead of the system list + bool resumeWithA; // A resumes a game's last save state, X starts it fresh // Mute switch bool muteLeds; @@ -157,6 +174,7 @@ typedef struct // Emulator int saveFormat; int stateFormat; + bool saveQuitMenu; // in-game menu offers Save & Quit into the session's slot bool useExtractedFileName; // Haptic @@ -235,13 +253,27 @@ typedef struct #define CFG_DEFAULT_ROMSUSEFOLDERBACKGROUND true #define CFG_DEFAULT_SAVEFORMAT SAVE_FORMAT_SAV #define CFG_DEFAULT_STATEFORMAT STATE_FORMAT_SAV +#define CFG_DEFAULT_SAVEQUITMENU false #define CFG_DEFAULT_EXTRACTEDFILENAME false #define CFG_DEFAULT_MUTELEDS false #define CFG_DEFAULT_FN_ACTION "" // unassigned #define CFG_DEFAULT_GAMEARTWIDTH 0.45 #define CFG_DEFAULT_WIFI false #define CFG_DEFAULT_VIEW SCREEN_GAMELIST +#define CFG_DEFAULT_CONTENTLAYOUT CONTENT_LAYOUT_LIST +#define CFG_DEFAULT_GRIDROWS 2 +// Screen margin: PADDING until changed (or reset). PADDING can depend on the +// active display at runtime (eg. h700 HDMI/720p), so the default is a sentinel. +#define SCREEN_MARGIN_DEFAULT -1 +#define SCREEN_MARGIN_MAX 20 +#define CFG_DEFAULT_SCREENMARGIN SCREEN_MARGIN_DEFAULT +#define CFG_DEFAULT_ALLGAMESVIEW false +#define CFG_DEFAULT_RESUMEWITHA false #define CFG_DEFAULT_SHOWQUICKWITCHERUI true +#define CFG_DEFAULT_SHOWQUICKMENUTITLE true +#define CFG_DEFAULT_QUICKMENURECENTS true +#define CFG_DEFAULT_QUICKMENUGAMES true +#define CFG_DEFAULT_QUICKMENUTOOLS true #define CFG_DEFAULT_WIFI_DIAG false #define CFG_DEFAULT_SHOWTOOLS true #define CFG_DEFAULT_BLUETOOTH false @@ -380,6 +412,10 @@ void CFG_setSaveFormat(int); // 1 - .state. (compressed rzip) int CFG_getStateFormat(void); void CFG_setStateFormat(int); +// Show Save & Quit (above Quit) in the in-game menu once the session has a save +// slot: the slot it resumed from, or one saved/loaded during play. +bool CFG_getSaveQuitMenu(void); +void CFG_setSaveQuitMenu(bool on); // use extracted file name instead of archive name (for cores that do not support archives natively) bool CFG_getUseExtractedFileName(void); void CFG_setUseExtractedFileName(bool); @@ -407,9 +443,36 @@ void CFG_setWifi(bool on); // Default view on boot int CFG_getDefaultView(void); void CFG_setDefaultView(int view); +// Game/system list layout: CONTENT_LAYOUT_LIST or CONTENT_LAYOUT_GRID +int CFG_getContentLayout(void); +void CFG_setContentLayout(int layout); +// Number of tile rows in grid layout (GRID_ROWS_MIN..GRID_ROWS_MAX) +int CFG_getGridRows(void); +void CFG_setGridRows(int rows); +// Screen margin in unscaled px (0..SCREEN_MARGIN_MAX), or SCREEN_MARGIN_DEFAULT +// for the platform's PADDING. Use GFX_getScreenMargin() for the scaled value. +int CFG_getScreenMargin(void); +void CFG_setScreenMargin(int margin); +// Swap the game list's A and X: A resumes the last save state (when there is one) +// and X starts the game fresh, instead of A open / X resume. +bool CFG_getResumeWithA(void); +void CFG_setResumeWithA(bool on); +// Skip the system list and show every game on one page, sorted A-Z +bool CFG_getAllGamesView(void); +void CFG_setAllGamesView(bool on); // Quick switcher UI painting on/off bool CFG_getShowQuickswitcherUI(void); void CFG_setShowQuickswitcherUI(bool on); +// Quick Menu title pill (selected item's name) on/off +bool CFG_getShowQuickMenuTitle(void); +void CFG_setShowQuickMenuTitle(bool on); +// Quick Menu top row entries on/off (Recents, Games, Tools) +bool CFG_getQuickMenuRecents(void); +void CFG_setQuickMenuRecents(bool on); +bool CFG_getQuickMenuGames(void); +void CFG_setQuickMenuGames(bool on); +bool CFG_getQuickMenuTools(void); +void CFG_setQuickMenuTools(bool on); // WiFi diagnostic logging on/off bool CFG_getWifiDiagnostics(void); void CFG_setWifiDiagnostics(bool on); diff --git a/workspace/all/minarch/ma_menu.c b/workspace/all/minarch/ma_menu.c index 85ca4341aa..d2f7f9558f 100644 --- a/workspace/all/minarch/ma_menu.c +++ b/workspace/all/minarch/ma_menu.c @@ -113,7 +113,7 @@ void MSG_quit(void) { /////////////////////////////////////// -#define MENU_ITEM_COUNT 5 +#define MENU_ITEM_COUNT 6 #define MENU_SLOT_COUNT 8 enum { @@ -121,6 +121,7 @@ enum { ITEM_SAVE, ITEM_LOAD, ITEM_OPTS, + ITEM_SAVE_QUIT, // only shown when Menu_itemVisible() says so ITEM_QUIT, }; @@ -162,10 +163,29 @@ static struct { [ITEM_SAVE] = "Save", [ITEM_LOAD] = "Load", [ITEM_OPTS] = "Options", + [ITEM_SAVE_QUIT] = "Save & Quit", [ITEM_QUIT] = "Quit", } }; +// Save & Quit is opt-in (Settings > System) and only offered once this session +// has a slot to save into: one it resumed from, or saved/loaded during play. +// Hidden while save states are blocked (RetroAchievements hardcore mode). +static int Menu_itemVisible(int item) { + if (item == ITEM_SAVE_QUIT) + return CFG_getSaveQuitMenu() && session_slot >= 0 && session_slot < USER_STATE_SLOTS && !RA_isHardcoreModeActive(); + return 1; +} + +// Steps selected by dir (+1/-1) to the next visible item, wrapping around. +static int Menu_stepItem(int selected, int dir) { + for (int n = 0; n < MENU_ITEM_COUNT; n++) { + selected = (selected + dir + MENU_ITEM_COUNT) % MENU_ITEM_COUNT; + if (Menu_itemVisible(selected)) break; + } + return selected; +} + void Menu_init(void) { menu.overlay = SDL_CreateRGBSurfaceWithFormat(SDL_SWSURFACE, DEVICE_WIDTH,DEVICE_HEIGHT, @@ -1688,7 +1708,7 @@ void Menu_screenshot(void) { Notification_push(NOTIFICATION_SETTING, "Screenshot saved", NULL); } } -void Menu_saveState(void) { +int Menu_saveState(void) { // LOG_info("Menu_saveState\n"); Menu_updateState(); @@ -1718,7 +1738,8 @@ void Menu_saveState(void) { state_slot = menu.slot; putInt(menu.slot_path, menu.slot); int success = State_write(); - + if (success) session_slot = menu.slot; + // Show notification if enabled if (CFG_getNotifyManualSave()) { char msg[NOTIFICATION_MAX_MESSAGE]; @@ -1726,6 +1747,7 @@ void Menu_saveState(void) { snprintf(msg, sizeof(msg), success ? "State Saved - Slot %d" : "Save Failed - Slot %d", menu.slot + 1); Notification_push(NOTIFICATION_SAVE_STATE, msg, NULL); } + return success; } void Menu_loadState(void) { Menu_updateState(); @@ -1748,8 +1770,9 @@ void Menu_loadState(void) { state_slot = menu.slot; putInt(menu.slot_path, menu.slot); int success = State_read(); + if (success) session_slot = menu.slot; Rewind_on_state_change(); - + // Show notification if enabled if (CFG_getNotifyLoad()) { char msg[NOTIFICATION_MAX_MESSAGE]; @@ -1837,13 +1860,11 @@ void Menu_loop(void) { PAD_poll(); if (PAD_justPressed(BTN_UP)) { - selected -= 1; - if (selected<0) selected += MENU_ITEM_COUNT; + selected = Menu_stepItem(selected, -1); dirty = 1; } else if (PAD_justPressed(BTN_DOWN)) { - selected += 1; - if (selected>=MENU_ITEM_COUNT) selected -= MENU_ITEM_COUNT; + selected = Menu_stepItem(selected, 1); dirty = 1; } else if (PAD_justPressed(BTN_LEFT)) { @@ -1931,6 +1952,17 @@ void Menu_loop(void) { } } break; + case ITEM_SAVE_QUIT: + // save into this session's slot (not whichever slot Save/Load is + // currently browsing), and only quit once that worked + menu.slot = session_slot; + if (Menu_saveState()) { + status = STATUS_QUIT; + show_menu = 0; + quit = 1; + } + else dirty = 1; + break; case ITEM_QUIT: status = STATUS_QUIT; show_menu = 0; @@ -1979,48 +2011,63 @@ void Menu_loop(void) { GFX_blitButtonGroup((char*[]){ "B","BACK", "A","OKAY", NULL }, 1, screen, 1); // list - oy = (((DEVICE_HEIGHT / FIXED_SCALE) - PADDING * 2) - (MENU_ITEM_COUNT * PILL_SIZE)) / 2; - for (int i=0; i max_list_h) row_h = MAX(1, max_list_h / visible_items); + oy = (list_h - (visible_items * row_h)) / 2; + + for (int i=0, row=0; i1 && i==ITEM_CONT) { + if (menu.total_discs>1 && i==ITEM_CONT) { GFX_blitPillDark(ASSET_WHITE_PILL, screen, &(SDL_Rect){ SCALE1(PADDING), - SCALE1(oy + PADDING), + pill_y, screen->w - SCALE1(PADDING * 2), SCALE1(PILL_SIZE) }); text = TTF_RenderUTF8_Blended(font.large, disc_name, text_color); SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){ screen->w - SCALE1(PADDING + BUTTON_PADDING) - text->w, - SCALE1(oy + PADDING + 4) + pill_y + SCALE1(4) }); SDL_FreeSurface(text); } - + TTF_SizeUTF8(font.large, item, &ow, NULL); ow += SCALE1(BUTTON_PADDING*2); - + // pill GFX_blitPillDark(ASSET_WHITE_PILL, screen, &(SDL_Rect){ SCALE1(PADDING), - SCALE1(oy + PADDING + (i * PILL_SIZE)), + pill_y, ow, SCALE1(PILL_SIZE) }); } - - + + // text text = TTF_RenderUTF8_Blended(font.large, item, text_color); SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){ SCALE1(PADDING + BUTTON_PADDING), - SCALE1(oy + PADDING + (i * PILL_SIZE) + 4) + pill_y + SCALE1(4) }); SDL_FreeSurface(text); } diff --git a/workspace/all/minarch/ma_menu.h b/workspace/all/minarch/ma_menu.h index 749a81b663..51c7f28340 100644 --- a/workspace/all/minarch/ma_menu.h +++ b/workspace/all/minarch/ma_menu.h @@ -8,7 +8,7 @@ void Menu_beforeSleep(void); void Menu_afterSleep(void); int Menu_options(MenuList* list); void Menu_screenshot(void); -void Menu_saveState(void); +int Menu_saveState(void); // returns 1 if the state was written void Menu_loadState(void); void OptionSaveChanges_updateDesc(void); void OptionAchievements_updateDesc(void); diff --git a/workspace/all/minarch/ma_saves.c b/workspace/all/minarch/ma_saves.c index 8fe053e012..d277506e8a 100644 --- a/workspace/all/minarch/ma_saves.c +++ b/workspace/all/minarch/ma_saves.c @@ -157,6 +157,7 @@ void RTC_write(void) { /////////////////////////////////////// int state_slot = 0; +int session_slot = -1; void State_getPath(char* filename) { char work_name[MAX_PATH]; @@ -378,9 +379,13 @@ void State_resume(void) { if (!exists(RESUME_SLOT_PATH)) return; int last_state_slot = state_slot; - state_slot = getInt(RESUME_SLOT_PATH); + int resume_slot = getInt(RESUME_SLOT_PATH); + state_slot = resume_slot; unlink(RESUME_SLOT_PATH); - State_read(); + // resuming a user slot (not the hidden default or auto-resume slot) makes it + // this session's slot, eg. the target of the menu's Save & Quit + if (State_read() && resume_slot >= 0 && resume_slot < USER_STATE_SLOTS) + session_slot = resume_slot; state_slot = last_state_slot; Rewind_on_state_change(); } diff --git a/workspace/all/minarch/ma_saves.h b/workspace/all/minarch/ma_saves.h index 0d8882dc72..830adf91a5 100644 --- a/workspace/all/minarch/ma_saves.h +++ b/workspace/all/minarch/ma_saves.h @@ -2,6 +2,13 @@ extern int state_slot; +// User-facing state slots 1-8 are internal 0-7; 8 is the hidden default state +// and AUTO_RESUME_SLOT (9) the auto save. +#define USER_STATE_SLOTS 8 +// The user slot this session resumed from, or last saved/loaded successfully, +// -1 if none yet. Target of the in-game menu's Save & Quit. +extern int session_slot; + void SRAM_read(void); void SRAM_write(void); void RTC_read(void); diff --git a/workspace/all/nextui/nextui.c b/workspace/all/nextui/nextui.c index 90c5673902..4a67f0db15 100644 --- a/workspace/all/nextui/nextui.c +++ b/workspace/all/nextui/nextui.c @@ -215,8 +215,14 @@ typedef struct Directory { int selected; int start; int end; + int grid_col; // first visible column in grid layout } Directory; +// "All games" root: when enabled, the root directory lists every game of every +// system (A-Z) instead of the system list. all_games_root is set by getRoot() +// each time the root is built. +static int all_games_root = 0; + static int getIndexChar(char* str) { char i = 0; char c = tolower(str[0]); @@ -243,6 +249,26 @@ static void Directory_index(Directory* self) { int is_collection = prefixMatch(COLLECTIONS_PATH, self->path); int skip_index = exactMatch(FAUX_RECENT_PATH, self->path) || is_collection; // not alphabetized + if (all_games_root && exactMatch(self->path, SDCARD_PATH)) { + // map.txt aliases were already applied per system by getAllGames(), and + // duplicate names across systems are intentionally left untagged. + int alpha = -1; + int index = 0; + for (int i = 0; i < self->entries->count; i++) { + Entry* entry = self->entries->items[i]; + int a = getIndexChar(entry->name); + // a merged list can contain more than INT_ARRAY_MAX index changes + // (eg. symbols sorting after letters), keep the remainder in the last group + if (a != alpha && self->alphas->count < INT_ARRAY_MAX) { + index = self->alphas->count; + IntArray_push(self->alphas, i); + alpha = a; + } + entry->alpha = index; + } + return; + } + Hash* map = NULL; char map_path[256]; sprintf(map_path, "%s/map.txt", is_collection ? COLLECTIONS_PATH : self->path); @@ -384,6 +410,9 @@ static Directory* Directory_new(char* path, int selected) { } self->alphas = IntArray_new(); self->selected = selected; + self->start = 0; + self->end = 0; + self->grid_col = 0; Directory_index(self); return self; } @@ -699,12 +728,30 @@ static int hasRoms(char* dir_name) { return has; } +static void getToolsPath(char* out, size_t out_size) { + snprintf(out, out_size, "%s/Tools/%s", SDCARD_PATH, PLATFORM); +} + +static int isToolsEntry(Entry* entry) { + char tools_path[256]; + getToolsPath(tools_path, sizeof(tools_path)); + return exactMatch(entry->path, tools_path); +} + static int hasTools(void) { char tools_path[256]; - snprintf(tools_path, sizeof(tools_path), "%s/Tools/%s", SDCARD_PATH, PLATFORM); + getToolsPath(tools_path, sizeof(tools_path)); return exists(tools_path); } +// The Tools folder entry for a root page, when it should be listed there. +static void pushToolsEntry(Array* root) { + if (!hasTools() || !CFG_getShowTools() || simple_mode) return; + char tools_path[256]; + getToolsPath(tools_path, sizeof(tools_path)); + Array_push(root, Entry_new(tools_path, ENTRY_DIR)); +} + static Array* getRoms() { Array* entries = Array_new(); @@ -807,26 +854,39 @@ static Array* getCollections(void) static Array* getQuickEntries(void) { Array* entries = Array_new(); + // Recents, Games and Tools can each be hidden in Settings > Appearance; the + // top row may then be empty (see the Quick Menu's qm_row handling) + // We assume Menu_init was already called and populated this - if (recents && recents->count) + if (recents && recents->count && CFG_getQuickMenuRecents()) Array_push(entries, Entry_newNamed(FAUX_RECENT_PATH, ENTRY_DIR, "Recents")); if (hasCollections()) Array_push(entries, Entry_new(COLLECTIONS_PATH, ENTRY_DIR)); // Not sure we need this, its just a button press away (B) - Array_push(entries, Entry_newNamed(ROMS_PATH, ENTRY_DIR, "Games")); + if (CFG_getQuickMenuGames()) + Array_push(entries, Entry_newNamed(ROMS_PATH, ENTRY_DIR, "Games")); // Add tools if applicable - if (hasTools() && !simple_mode) { + if (hasTools() && !simple_mode && CFG_getQuickMenuTools()) { char tools_path[256]; - snprintf(tools_path, sizeof(tools_path), "%s/Tools/%s", SDCARD_PATH, PLATFORM); + getToolsPath(tools_path, sizeof(tools_path)); Array_push(entries, Entry_new(tools_path, ENTRY_DIR)); } return entries; } +// Display name for a Quick Menu item, shown in the title pill. Toggles are +// identified by their internal names (see toggleQuick), so only relabel here. +static const char* QuickMenu_label(Entry* item) { + if (!item) return ""; + if (!strcmp(item->name, "Wifi")) return "Wi-Fi"; + if (!strcmp(item->name, "Poweroff")) return "Power off"; + return item->name; +} + static Array* getQuickToggles(void) { Array *entries = Array_new(); @@ -851,7 +911,89 @@ static Array* getQuickToggles(void) { return entries; } +// Applies /map.txt display-name aliases to entries the same way +// Directory_index() does for a single folder: renames, drops entries whose +// alias marks them hidden, and resorts if anything was renamed. +static Array* applyDirMap(Array* entries, const char* dir_path) { + char map_path[256]; + snprintf(map_path, sizeof(map_path), "%s/map.txt", dir_path); + if (!exists(map_path)) return entries; + + FILE* file = fopen(map_path, "r"); + if (!file) return entries; + + Hash* map = Hash_new(); + char line[256]; + while (fgets(line, sizeof(line), file) != NULL) { + normalizeNewline(line); + trimTrailingNewlines(line); + if (strlen(line) == 0) continue; + + char* tmp = strchr(line, '\t'); + if (tmp) { + tmp[0] = '\0'; + Hash_set(map, line, strdup(tmp + 1)); + } + } + fclose(file); + + int resort = 0; + Array* kept = Array_new(); + for (int i = 0; i < entries->count; i++) { + Entry* entry = entries->items[i]; + char* filename = strrchr(entry->path, '/') + 1; + char* alias = Hash_get(map, filename); + if (alias) { + free(entry->name); + entry->name = strdup(alias); + resort = 1; + } + if (hide(entry->name)) Entry_free(entry); + else Array_push(kept, entry); + } + Array_free(entries); + Hash_free(map); + + if (resort) EntryArray_sort(kept); + return kept; +} + +// Every game of every visible system, sorted A-Z, for the "All games" root page. +// Systems come from getRoms() so hidden/empty systems and collation of sibling +// folders match the regular system list. +// Subfolders inside a system (multi-disc games, user folders) stay as folders. +static Array* getAllGames(void) { + Array* all = Array_new(); + Array* systems = getRoms(); + + for (int i = 0; i < systems->count; i++) { + Entry* system = systems->items[i]; + Array* games = applyDirMap(getEntries(system->path), system->path); + Array_yoink(all, games); + } + EntryArray_free(systems); + + EntryArray_sort(all); + return all; +} + static Array* getRoot(void) { + if (CFG_getAllGamesView()) { + // hasRecents() (re)loads the recents list the Quick Menu and game switcher + // rely on, and consumes a pending disc change, so it must still run here + hasRecents(); + Array* all = getAllGames(); + if (all->count > 0) { + all_games_root = 1; + // after the A-Z games, same as the end of the system list + pushToolsEntry(all); + return all; + } + // no games anywhere: fall back to the regular root so Tools etc. stay reachable + EntryArray_free(all); + } + all_games_root = 0; + Array* root = Array_new(); if (hasRecents() && CFG_getShowRecents()) @@ -873,11 +1015,7 @@ static Array* getRoot(void) { Array_yoink(root, entries); // Add tools if applicable - if (hasTools() && CFG_getShowTools() && !simple_mode) { - char tools_path[256]; - snprintf(tools_path, sizeof(tools_path), "%s/Tools/%s", SDCARD_PATH, PLATFORM); - Array_push(root, Entry_new(tools_path, ENTRY_DIR)); - } + pushToolsEntry(root); return root; } @@ -1585,6 +1723,31 @@ static void loadLast(void) { // call after loading root directory char full_path[256]; strcpy(full_path, last_path); + // "All games" root: games sit directly at the root, so select the game (or the + // game's folder, eg. multi-disc) instead of walking Roms//... + if (all_games_root && stack->count == 1 && prefixMatch(ROMS_PATH "/", full_path)) { + for (int i = 0; i < top->entries->count; i++) { + Entry* entry = top->entries->items[i]; + size_t len = strlen(entry->path); + if (exactMatch(entry->path, full_path) || (prefixMatch(entry->path, full_path) && full_path[len] == '/')) { + top->selected = i; + if (i >= top->end) { + top->start = i; + top->end = top->start + MAIN_ROW_COUNT; + if (top->end > top->entries->count) { + top->end = top->entries->count; + top->start = top->end - MAIN_ROW_COUNT; + if (top->start < 0) top->start = 0; + } + } + break; + } + } + if (top->selected >= 0 && top->selected < top->entries->count) + readyResume(top->entries->items[top->selected]); + return; + } + char* tmp; char filename[256]; tmp = strrchr(last_path, '/'); @@ -1598,6 +1761,17 @@ static void loadLast(void) { // call after loading root directory last_path[(slash-last_path)] = '\0'; } + // "All games" root has no Recents/Collections entries (they live in the Quick + // Menu), so open that top-level folder directly and restore within it. + if (all_games_root && stack->count == 1 && last->count > 0) { + char* first = last->items[last->count - 1]; + if (exactMatch(first, FAUX_RECENT_PATH) || exactMatch(first, COLLECTIONS_PATH)) { + char* path = Array_pop(last); + openDirectory(path, 0); + free(path); + } + } + while (last->count>0) { char* path = Array_pop(last); if (!exactMatch(path, ROMS_PATH)) { // romsDir is effectively root as far as restoring state after a game @@ -1776,6 +1950,418 @@ static inline int getAnimationDraw(void) { return SDL_AtomicGet(&animationDrawAt static inline void setNeedDraw(int v) { SDL_AtomicSet(&needDrawAtomic, v); } static inline int getNeedDraw(void) { return SDL_AtomicGet(&needDrawAtomic); } +/////////////////////////////////////// +// Grid layout: tiles laid out column-major (top to bottom, then the next column) +// so the content scrolls horizontally. Up/Down step through tiles in order +// (wrapping into the neighbouring column at a column's ends), Left/Right move +// between columns. Used for every game list screen when the content layout +// setting is "Grid"; the list renderer is untouched otherwise. + +typedef struct GridLayout { + int rows; + int cols; // visible columns + int tile_w; + int tile_h; + int gap; + int x; // left edge of the first visible column + int y; // top edge of the first row +} GridLayout; + +static int Grid_active(void) { + return CFG_getContentLayout() == CONTENT_LAYOUT_GRID; +} + +static void Grid_getLayout(GridLayout* g) { + // leave the top row for the title pill and hardware status, the bottom row for button hints + int margin = GFX_getScreenMargin(); + int top = margin + SCALE1(PILL_SIZE + PADDING); + int bottom = screen->h - margin - SCALE1(PILL_SIZE + PADDING); + int area_w = screen->w - margin * 2; + int area_h = bottom - top; + + g->gap = SCALE1(BUTTON_MARGIN * 2); + g->rows = clamp(CFG_getGridRows(), GRID_ROWS_MIN, GRID_ROWS_MAX); + g->tile_h = (area_h - g->gap * (g->rows - 1)) / g->rows; + g->tile_w = g->tile_h; + + // keep at least a few columns visible so horizontal scrolling reads as a grid, + // tiles become portrait (box art shaped) when rows are tall + const int min_cols = 3; + int max_tile_w = (area_w - g->gap * (min_cols - 1)) / min_cols; + if (g->tile_w > max_tile_w) g->tile_w = max_tile_w; + + g->cols = (area_w + g->gap) / (g->tile_w + g->gap); + if (g->cols < 1) g->cols = 1; + + int used_w = g->cols * g->tile_w + (g->cols - 1) * g->gap; + g->x = (screen->w - used_w) / 2; + g->y = top; +} + +// Returns the new selection after moving by (dx, dy) tiles. Up/Down step through +// the tiles in order, so the top of a column continues to the bottom of the +// previous one and the bottom continues to the top of the next. wrap allows +// jumping from the first tile/column to the last (and back), only used on a fresh +// press so holding the d-pad stops at the edges, same as the list. +static int Grid_move(int selected, int total, int rows, int dx, int dy, int wrap) { + if (total <= 0) return 0; + int row = selected % rows; + int col = selected / rows; + int last_col = (total - 1) / rows; + + if (dy < 0) { + if (selected > 0) return selected - 1; + return wrap ? total - 1 : selected; + } + if (dy > 0) { + if (selected + 1 < total) return selected + 1; + return wrap ? 0 : selected; + } + + if (dx > 0) { + if (col < last_col) return MIN(selected + rows, total - 1); + return wrap ? MIN(row, total - 1) : selected; + } + if (dx < 0) { + if (col > 0) return selected - rows; + return wrap ? MIN(last_col * rows + row, total - 1) : selected; + } + return selected; +} + +// Keeps the selected tile's column on screen. +static void Grid_scrollTo(Directory* dir, const GridLayout* g) { + int total_cols = (dir->entries->count + g->rows - 1) / g->rows; + int col = dir->selected / g->rows; + if (col < dir->grid_col) dir->grid_col = col; + if (col >= dir->grid_col + g->cols) dir->grid_col = col - g->cols + 1; + dir->grid_col = clamp(dir->grid_col, 0, MAX(0, total_cols - g->cols)); +} + +// Tile art uses the same files as game art: /.media/.png. +// Folders (eg. systems) fall back to their own .media/bg.png. +static int Grid_findArt(Entry* entry, char* out, size_t out_size) { + char path_copy[MAX_PATH]; + strncpy(path_copy, entry->path, sizeof(path_copy) - 1); + path_copy[sizeof(path_copy) - 1] = '\0'; + + char* name = strrchr(path_copy, '/'); + if (!name) return 0; + *name = '\0'; + name++; + + char base[MAX_PATH]; + strncpy(base, name, sizeof(base) - 1); + base[sizeof(base) - 1] = '\0'; + if (entry->type != ENTRY_DIR) { + char* dot = strrchr(base, '.'); + if (dot) *dot = '\0'; + } + + snprintf(out, out_size, "%s/.media/%s.png", path_copy, base); + if (exists(out)) return 1; + + if (entry->type == ENTRY_DIR) { + snprintf(out, out_size, "%s/.media/bg.png", entry->path); + if (exists(out)) return 1; + } + return 0; +} + +// Tile corner radius: the "Game art corner radius" setting, so tiles match game art +// elsewhere in the UI, limited on small tiles so they don't turn into circles. +static int Grid_cornerRadius(int w, int h) { + return MAX(0, MIN(SCALE1(CFG_getThumbnailRadius()), MIN(w, h) / 4)); +} + +// Fills rect on screen with color and rounds its corners (pixels outside the +// radius become transparent, letting the background layer show through). +static void Grid_fillRounded(SDL_Rect* rect, Uint32 color, int radius) { + SDL_FillRect(screen, rect, color); + if (radius <= 0) return; + + // GFX_ApplyRoundedCorners_8888 writes pixels directly, keep it on-surface + SDL_Rect clipped; + if (!SDL_IntersectRect(rect, &(SDL_Rect){ 0, 0, screen->w, screen->h }, &clipped)) return; + if (clipped.w != rect->w || clipped.h != rect->h) return; // partially off-screen, leave square + GFX_ApplyRoundedCorners_8888(screen, rect, radius); +} + +static SDL_Surface* Grid_loadArt(const char* path, int max_w, int max_h) { + SDL_Surface* image = IMG_Load(path); + if (!image) return NULL; + SDL_Surface* src = SDL_ConvertSurfaceFormat(image, screen->format->format, 0); + SDL_FreeSurface(image); + if (!src) return NULL; + + double scale = MIN((double)max_w / src->w, (double)max_h / src->h); + int w = MAX(1, (int)(src->w * scale)); + int h = MAX(1, (int)(src->h * scale)); + + SDL_Surface* dst = SDL_CreateRGBSurfaceWithFormat(0, w, h, screen->format->BitsPerPixel, screen->format->format); + if (dst) { + SDL_FillRect(dst, NULL, 0); + SDL_SetSurfaceBlendMode(src, SDL_BLENDMODE_NONE); +#if SDL_VERSION_ATLEAST(2, 0, 16) + if (SDL_SoftStretchLinear(src, NULL, dst, NULL) != 0) +#endif + SDL_BlitScaled(src, NULL, dst, NULL); + + int radius = MIN(Grid_cornerRadius(max_w, max_h), MIN(w, h) / 2); + if (radius > 0) + GFX_ApplyRoundedCorners_8888(dst, &(SDL_Rect){0, 0, w, h}, radius); + SDL_SetSurfaceBlendMode(dst, SDL_BLENDMODE_BLEND); + } + SDL_FreeSurface(src); + return dst; +} + +// Small LRU cache of scaled tile art keyed by entry path and tile size. Entries +// without art are cached too (surface == NULL) so they aren't probed every frame. +#define GRID_ART_CACHE_SIZE 48 +typedef struct GridArt { + char key[MAX_PATH]; + int w; + int h; + SDL_Surface* surface; + unsigned int used; +} GridArt; +static GridArt grid_art[GRID_ART_CACHE_SIZE]; +static unsigned int grid_art_clock = 0; + +static SDL_Surface* Grid_getArt(Entry* entry, int max_w, int max_h) { + int victim = -1; + for (int i = 0; i < GRID_ART_CACHE_SIZE; i++) { + GridArt* slot = &grid_art[i]; + if (!slot->key[0]) { + if (victim < 0 || grid_art[victim].key[0]) victim = i; // prefer an empty slot + continue; + } + if (slot->w == max_w && slot->h == max_h && exactMatch(slot->key, entry->path)) { + slot->used = ++grid_art_clock; + return slot->surface; + } + if (victim < 0 || (grid_art[victim].key[0] && slot->used < grid_art[victim].used)) victim = i; + } + + GridArt* slot = &grid_art[victim]; + if (slot->surface) SDL_FreeSurface(slot->surface); + slot->surface = NULL; + + char art_path[MAX_PATH]; + if (Grid_findArt(entry, art_path, sizeof(art_path))) + slot->surface = Grid_loadArt(art_path, max_w, max_h); + + strncpy(slot->key, entry->path, sizeof(slot->key) - 1); + slot->key[sizeof(slot->key) - 1] = '\0'; + slot->w = max_w; + slot->h = max_h; + slot->used = ++grid_art_clock; + return slot->surface; +} + +// The Tools tile (system list and all games view) shows the Quick Menu's Tools +// icon instead of a letter when it has no art of its own. Loaded from the largest +// asset and scaled down to the tile, kept while the size is unchanged. +static SDL_Surface* grid_tools_icon = NULL; +static int grid_tools_icon_px = 0; + +static SDL_Surface* Grid_getToolsIcon(int px) { + if (grid_tools_icon_px == px) return grid_tools_icon; + if (grid_tools_icon) SDL_FreeSurface(grid_tools_icon); + grid_tools_icon = NULL; + grid_tools_icon_px = px; + + SDL_Surface* image = IMG_Load(SDCARD_PATH "/.system/res/Tools@4x.png"); + if (!image) return NULL; + SDL_Surface* src = SDL_ConvertSurfaceFormat(image, screen->format->format, 0); + SDL_FreeSurface(image); + if (!src) return NULL; + + SDL_Surface* dst = SDL_CreateRGBSurfaceWithFormat(0, px, px, screen->format->BitsPerPixel, screen->format->format); + if (dst) { + SDL_FillRect(dst, NULL, 0); + SDL_SetSurfaceBlendMode(src, SDL_BLENDMODE_NONE); +#if SDL_VERSION_ATLEAST(2, 0, 16) + if (SDL_SoftStretchLinear(src, NULL, dst, NULL) != 0) +#endif + SDL_BlitScaled(src, NULL, dst, NULL); + SDL_SetSurfaceBlendMode(dst, SDL_BLENDMODE_BLEND); + } + SDL_FreeSurface(src); + grid_tools_icon = dst; + return grid_tools_icon; +} + +// Tiles without art show the first letter of the name, rendered with the UI font +// opened at a size that suits the tile (kept open while the tile size is unchanged). +static TTF_Font* grid_letter_font = NULL; +static int grid_letter_font_px = 0; + +static TTF_Font* Grid_getLetterFont(int px) { + if (grid_letter_font && grid_letter_font_px == px) return grid_letter_font; + + const char* path = GFX_getSystemFontPath(); + TTF_Font* opened = (path && path[0]) ? TTF_OpenFont(path, px) : NULL; + if (!opened) return font.large; + TTF_SetFontStyle(opened, CFG_getFontStyle()); + + if (grid_letter_font) TTF_CloseFont(grid_letter_font); + grid_letter_font = opened; + grid_letter_font_px = px; + return grid_letter_font; +} + +// First letter or digit of name (skipping leading punctuation like "[" or "'"), +// uppercased. Non-ASCII characters are copied whole as UTF-8. out needs 5 bytes. +static void Grid_getInitial(const char* name, char* out) { + const unsigned char* pick = NULL; + for (const unsigned char* c = (const unsigned char*)name; *c; c++) { + if (*c >= 0xC0 || isalnum(*c)) { pick = c; break; } + } + if (!pick) pick = (const unsigned char*)name; + if (!pick[0]) { strcpy(out, "?"); return; } + + int len = 1; + if (pick[0] >= 0xF0) len = 4; + else if (pick[0] >= 0xE0) len = 3; + else if (pick[0] >= 0xC0) len = 2; + + int i = 0; + for (; i < len && pick[i]; i++) out[i] = pick[i]; + out[i] = '\0'; + if (len == 1) out[0] = toupper((unsigned char)out[0]); +} + +static void Grid_freeArt(void) { + for (int i = 0; i < GRID_ART_CACHE_SIZE; i++) { + if (grid_art[i].surface) SDL_FreeSurface(grid_art[i].surface); + grid_art[i].surface = NULL; + grid_art[i].key[0] = '\0'; + } + if (grid_letter_font) TTF_CloseFont(grid_letter_font); + grid_letter_font = NULL; + grid_letter_font_px = 0; + if (grid_tools_icon) SDL_FreeSurface(grid_tools_icon); + grid_tools_icon = NULL; + grid_tools_icon_px = 0; +} + +// Draws label in a pill in the top-left corner, eg. the selected grid tile or +// Quick Menu item. It sits the screen margin away from the edges, like the status +// pill and button hints, and always keeps one margin of space before the status +// pill on the right (ow is that pill's width, from GFX_blitHardwareGroup). +static void drawTitlePill(const char* label, int ow) { + if (!label || !label[0]) return; + + int margin = GFX_getScreenMargin(); + char display_name[256]; + int max_width = MAX(0, screen->w - margin * 3 - ow); + SDL_LockMutex(fontMutex); + int text_width = GFX_getTextWidth(font.large, label, display_name, max_width, SCALE1(BUTTON_PADDING * 2)); + int pill_width = MIN(max_width, text_width); + if (pill_width > 0) + GFX_blitPillDark(ASSET_WHITE_PILL, screen, &(SDL_Rect){ margin, margin, pill_width, SCALE1(PILL_SIZE) }); + SDL_Surface* text = TTF_RenderUTF8_Blended(font.large, display_name, uintToColour(THEME_COLOR5_255)); + SDL_UnlockMutex(fontMutex); + int text_w = pill_width - SCALE1(BUTTON_PADDING * 2); + if (text && text_w > 0) { + int text_offset_y = (SCALE1(PILL_SIZE) - text->h + 1) >> 1; + SDL_BlitSurface(text, &(SDL_Rect){ 0, 0, text_w, text->h }, screen, + &(SDL_Rect){ margin + SCALE1(BUTTON_PADDING), margin + text_offset_y }); + } + if (text) SDL_FreeSurface(text); +} + +// Draws the visible tiles of dir onto screen, plus a title pill with the selected +// entry's name in the top row (when names are shown). ow is the width taken by +// the hardware status group in the top-right corner. +static void Grid_render(Directory* dir, int show_names, int ow) { + GridLayout g; + Grid_getLayout(&g); + Grid_scrollTo(dir, &g); + + int total = dir->entries->count; + int first = dir->grid_col * g.rows; + int last = MIN(total, first + g.cols * g.rows); + int border = SCALE1(2); + + SDL_Color text_color = uintToColour(THEME_COLOR4_255); + SDL_Color selected_text_color = uintToColour(THEME_COLOR5_255); + SDL_Color tile_rgb = uintToColour(THEME_COLOR3_255); + Uint32 tile_fill = SDL_MapRGBA(screen->format, tile_rgb.r, tile_rgb.g, tile_rgb.b, 0xC0); + + // art-less tiles show a single large initial; no wrapping or "..." truncation, + // which GFX_truncateText can't do safely in very narrow tiles + SDL_LockMutex(fontMutex); + TTF_Font* letter_font = Grid_getLetterFont(MAX(1, MIN(g.tile_w, g.tile_h) * 55 / 100)); + SDL_UnlockMutex(fontMutex); + + for (int i = first; i < last; i++) { + Entry* entry = dir->entries->items[i]; + int col = i / g.rows - dir->grid_col; + int row = i % g.rows; + int is_selected = (i == dir->selected); + SDL_Rect tile = { + g.x + col * (g.tile_w + g.gap), + g.y + row * (g.tile_h + g.gap), + g.tile_w, + g.tile_h + }; + + int radius = Grid_cornerRadius(tile.w, tile.h); + SDL_Surface* art = Grid_getArt(entry, tile.w, tile.h); + if (art) { + SDL_Rect art_rect = { tile.x + (tile.w - art->w) / 2, tile.y + (tile.h - art->h) / 2, art->w, art->h }; + if (is_selected) { + // frame follows the art's rounded corners, offset by the border width + int art_radius = MIN(radius, MIN(art->w, art->h) / 2); + SDL_Rect frame = { art_rect.x - border, art_rect.y - border, art_rect.w + border * 2, art_rect.h + border * 2 }; + Grid_fillRounded(&frame, THEME_COLOR1, art_radius > 0 ? art_radius + border : 0); + } + SDL_BlitSurface(art, NULL, screen, &art_rect); + continue; + } + + // no art: a plain tile with the name's first letter (or the Tools icon) + Grid_fillRounded(&tile, is_selected ? THEME_COLOR1 : tile_fill, radius); + + if (isToolsEntry(entry)) { + // same share of the tile as the Quick Menu gives its icons (48 of 72) + SDL_Surface* icon = Grid_getToolsIcon(MAX(1, MIN(tile.w, tile.h) * 2 / 3)); + if (icon) { + SDL_Rect dst = { tile.x + (tile.w - icon->w) / 2, tile.y + (tile.h - icon->h) / 2, 0, 0 }; + GFX_blitSurfaceColor(icon, NULL, screen, &dst, is_selected ? THEME_COLOR5 : THEME_COLOR4); + continue; + } + } + + char* label = entry->name; + trimSortingMeta(&label); + char initial[5]; + Grid_getInitial(label, initial); + + SDL_LockMutex(fontMutex); + SDL_Surface* letter = TTF_RenderUTF8_Blended(letter_font, initial, is_selected ? selected_text_color : text_color); + SDL_UnlockMutex(fontMutex); + if (letter) { + // clip to the tile in case a glyph renders larger than expected + SDL_Rect src = { MAX(0, (letter->w - tile.w) / 2), MAX(0, (letter->h - tile.h) / 2), MIN(letter->w, tile.w), MIN(letter->h, tile.h) }; + SDL_Rect dst = { tile.x + (tile.w - src.w) / 2, tile.y + (tile.h - src.h) / 2, 0, 0 }; + SDL_BlitSurface(letter, &src, screen, &dst); + SDL_FreeSurface(letter); + } + } + + // title pill with the selected entry's full name + if (show_names && dir->selected >= 0 && dir->selected < total) { + Entry* entry = dir->entries->items[dir->selected]; + char* label = entry->unique ? entry->unique : entry->name; + trimSortingMeta(&label); + drawTitlePill(label, ow); + } +} + static void updatePillTextSurface(const char* entry_name, int move_w, SDL_Color text_color) { int crop_w = move_w - SCALE1(BUTTON_PADDING * 2); if (crop_w <= 0) return; @@ -2270,7 +2856,10 @@ int main (int argc, char *argv[]) { // start my threaded image loader :D initImageLoaderPool(); Menu_init(); - int qm_row = 0; + // the top row can be empty when its entries are hidden in Settings, then the + // bottom row (always has Reboot/Poweroff) is the only one + int qm_top_row = quick->count > 0 ? 0 : 1; + int qm_row = qm_top_row; int qm_col = 0; int qm_slot = 0; int qm_shift = 0; @@ -2444,7 +3033,7 @@ int main (int argc, char *argv[]) { } } else if(PAD_justPressed(BTN_UP)) { - if(qm_row == 1) { + if(qm_row == 1 && quick->count > 0) { qm_row = 0; qm_col = qm_slot + qm_shift; dirty = 1; @@ -2497,7 +3086,7 @@ int main (int argc, char *argv[]) { if (PAD_tappedMenu(now)) { currentScreen = SCREEN_QUICKMENU; qm_col = 0; - qm_row = 0; + qm_row = qm_top_row; qm_shift = 0; qm_slot = 0; dirty = 1; @@ -2509,6 +3098,18 @@ int main (int argc, char *argv[]) { switcher_selected = 0; dirty = 1; } + else if (total>0 && Grid_active()) { + GridLayout grid; + Grid_getLayout(&grid); + if (PAD_justRepeated(BTN_UP)) + selected = Grid_move(selected, total, grid.rows, 0, -1, PAD_justPressed(BTN_UP)); + else if (PAD_justRepeated(BTN_DOWN)) + selected = Grid_move(selected, total, grid.rows, 0, 1, PAD_justPressed(BTN_DOWN)); + else if (PAD_justRepeated(BTN_LEFT)) + selected = Grid_move(selected, total, grid.rows, -1, 0, PAD_justPressed(BTN_LEFT)); + else if (PAD_justRepeated(BTN_RIGHT)) + selected = Grid_move(selected, total, grid.rows, 1, 0, PAD_justPressed(BTN_RIGHT)); + } else if (total>0) { if (PAD_justRepeated(BTN_UP)) { if (selected==0 && !PAD_justPressed(BTN_UP)) { @@ -2611,13 +3212,19 @@ int main (int argc, char *argv[]) { if (dirty && total>0) readyResume(entry); - if (total>0 && can_resume && PAD_justReleased(BTN_RESUME)) { + // "Resume with A" swaps the two buttons, but only when there is something + // to resume; otherwise A opens as usual + int resume_with_a = can_resume && CFG_getResumeWithA(); + int resume_pressed = resume_with_a ? PAD_justPressed(BTN_A) : PAD_justReleased(BTN_RESUME); + int open_pressed = resume_with_a ? PAD_justReleased(BTN_RESUME) : PAD_justPressed(BTN_A); + + if (total>0 && can_resume && resume_pressed) { should_resume = 1; Entry_open(entry); dirty = 1; } - else if (total>0 && PAD_justPressed(BTN_A)) { + else if (total>0 && open_pressed) { Entry_open(entry); if(entry->type == ENTRY_DIR) { animationdirection = SLIDE_LEFT; @@ -2697,6 +3304,10 @@ int main (int argc, char *argv[]) { GFX_blitButtonGroup((char*[]){ "B","BACK", "A","OPEN", NULL }, 1, screen, 1); if(CFG_getShowQuickswitcherUI()) { + // the bottom row is icons only, so name the selected item in the top row + if (CFG_getShowQuickMenuTitle()) + drawTitlePill(QuickMenu_label(current), ow); + #define MENU_ITEM_SIZE 72 // item size, top line #define MENU_MARGIN_Y 32 // space between main UI elements and quick menu #define MENU_MARGIN_X 40 // space between main UI elements and quick menu @@ -2774,6 +3385,9 @@ int main (int argc, char *argv[]) { ox = SCALE1(PADDING + MENU_MARGIN_X); ox += (screen->w - SCALE1(PADDING + MENU_MARGIN_X + MENU_MARGIN_X + PADDING) - SCALE1(quickActions->count * PILL_SIZE) - SCALE1((quickActions->count - 1) * MENU_TOGGLE_MARGIN))/2; oy = SCALE1(PADDING + PILL_SIZE + BUTTON_MARGIN + MENU_MARGIN_Y + MENU_LINE_MARGIN) + item_size + item_extra_y / 2; + // no cards in the top row (all hidden in Settings): center the buttons on screen + if (quick->count == 0) + oy = (screen->h - SCALE1(PILL_SIZE)) / 2; for (int c = 0; c < quickActions->count; c++) { SDL_Rect item_rect = {ox, oy, SCALE1(PILL_SIZE), SCALE1(PILL_SIZE)}; Entry *item = quickActions->items[c]; @@ -2995,7 +3609,8 @@ int main (int argc, char *argv[]) { static int lastType = -1; // this is only a choice on the root folder - list_show_entry_names = stack->count > 1 || CFG_getShowFolderNamesAtRoot(); + // (the "All games" root lists games, not folders, so names always show) + list_show_entry_names = stack->count > 1 || CFG_getShowFolderNamesAtRoot() || all_games_root; // load folder background char defaultBgPath[512]; @@ -3027,8 +3642,17 @@ int main (int argc, char *argv[]) { // Safeguard: If no background is available, still render the text to leave the user a way out list_show_entry_names = true; } + // grid tiles show their own art, and the selection pill/scroll text layers are list-only + int grid_mode = Grid_active(); + int grid_show_names = list_show_entry_names; + if (grid_mode) { + list_show_entry_names = false; + had_thumb = 0; + ox = screen->w; + } + // load game thumbnails - if (total > 0) { + if (total > 0 && !grid_mode) { if(CFG_getShowGameArt()) { char thumbpath[1024]; snprintf(thumbpath, sizeof(thumbpath), "%s/.media/%s.png", rompath, res_copy); @@ -3048,8 +3672,11 @@ int main (int argc, char *argv[]) { } // buttons + // with "Resume with A" and something to resume, A and X trade labels + int resume_with_a = can_resume && CFG_getResumeWithA(); + char* a_label = resume_with_a ? "RESUME" : "OPEN"; if (show_setting && !GetHDMI()) GFX_blitHardwareHints(screen, show_setting); - else if (can_resume) GFX_blitButtonGroup((char*[]){ "X","RESUME", NULL }, 0, screen, 0); + else if (can_resume) GFX_blitButtonGroup((char*[]){ "X", resume_with_a ? "OPEN" : "RESUME", NULL }, 0, screen, 0); else GFX_blitButtonGroup((char*[]){ BTN_SLEEP==BTN_POWER?"POWER":"MENU", BTN_SLEEP==BTN_POWER||simple_mode?"SLEEP":"INFO", @@ -3062,10 +3689,10 @@ int main (int argc, char *argv[]) { } else { if (stack->count>1) { - GFX_blitButtonGroup((char*[]){ "B","BACK", "A","OPEN", NULL }, 1, screen, 1); + GFX_blitButtonGroup((char*[]){ "B","BACK", "A",a_label, NULL }, 1, screen, 1); } else { - GFX_blitButtonGroup((char*[]){ "A","OPEN", NULL }, 0, screen, 1); + GFX_blitButtonGroup((char*[]){ "A",a_label, NULL }, 0, screen, 1); } } @@ -3075,7 +3702,11 @@ int main (int argc, char *argv[]) { previousY = previous_row * PILL_SIZE; targetY = selected_row * PILL_SIZE; SDL_Color text_color = uintToColour(THEME_COLOR4_255); // list text color - for (int i = top->start, j = 0; i < top->end; i++, j++) { + if (grid_mode) { + Grid_render(top, grid_show_names, ow); + is_scrolling = 0; + } + for (int i = top->start, j = 0; !grid_mode && i < top->end; i++, j++) { Entry* entry = top->entries->items[i]; char* entry_name = entry->name; char* entry_unique = entry->unique; @@ -3083,8 +3714,9 @@ int main (int argc, char *argv[]) { bool row_is_selected = (j == selected_row); bool row_is_top = (i == top->start); bool row_has_moved = (previous_row != selected_row || previous_depth != stack->count); + // a larger screen margin moves the status pill further in, keep clear of it if (row_is_top && !(had_thumb)) - available_width -= ow; + available_width -= ow + MAX(0, GFX_getScreenMargin() - SCALE1(PADDING)); trimSortingMeta(&entry_name); if (entry_unique) // Only render if a unique name exists @@ -3338,7 +3970,8 @@ int main (int argc, char *argv[]) { } int available_width = (had_thumb ? ox + SCALE1(BUTTON_MARGIN) : screen->w - SCALE1(BUTTON_MARGIN)) - SCALE1(PADDING * 2); - if (top->selected == top->start && !had_thumb) available_width -= ow; + if (top->selected == top->start && !had_thumb) + available_width -= ow + MAX(0, GFX_getScreenMargin() - SCALE1(PADDING)); SDL_Color text_color = uintToColour(THEME_COLOR5_255); @@ -3430,6 +4063,7 @@ int main (int argc, char *argv[]) { if(blackBG) SDL_FreeSurface(blackBG); if (folderbgbmp) SDL_FreeSurface(folderbgbmp); if (thumbbmp) SDL_FreeSurface(thumbbmp); + Grid_freeArt(); QuitSettings(); } diff --git a/workspace/all/settings/menu.cpp b/workspace/all/settings/menu.cpp index b17a0cabb9..ec9f6d6e92 100644 --- a/workspace/all/settings/menu.cpp +++ b/workspace/all/settings/menu.cpp @@ -312,9 +312,71 @@ MenuList::MenuList(MenuItemType type, const std::string &descp, std::vector