From 2ab9e77014204c8bb5b87b58cb4169dcc18d40d6 Mon Sep 17 00:00:00 2001 From: Pete Clark Date: Sat, 8 Aug 2026 15:42:24 +0000 Subject: [PATCH 1/2] feat: global rom search in the quick menu A new quick menu entry opens an on-screen keyboard over a live result list, in the spirit of the stock TrimUI OS. It indexes every rom under Roms/ for which an emulator pak exists, honouring the same hidden-file and map.txt alias rules the browser uses, and treating a multi-disc folder as one game rather than as a pile of discs. The index is built once, on the first visit. The two halves of the screen hand focus back and forth: walking off the top of the keyboard moves into the results, which then take the keyboard's space so a long list can be browsed full-screen, and walking off the bottom of the results gives it back. A launches, X resumes a save state, and Y opens the same context menu the game lists use, so a game can be favorited straight out of a search. Games that exist under two systems are tagged with the emulator they belong to, exactly like a directory listing does. Adds a Search icon to the shared res folder. Refs LoveRetro/NextUI#110, LoveRetro/NextUI#589 --- README.md | 1 + skeleton/SYSTEM/res/Search@1x.png | Bin 0 -> 441 bytes skeleton/SYSTEM/res/Search@2x.png | Bin 0 -> 781 bytes skeleton/SYSTEM/res/Search@3x.png | Bin 0 -> 1192 bytes skeleton/SYSTEM/res/Search@4x.png | Bin 0 -> 1598 bytes workspace/all/common/config.h | 3 +- workspace/all/nextui/nextui.c | 501 ++++++++++++++++++++++++++++-- 7 files changed, 481 insertions(+), 24 deletions(-) create mode 100644 skeleton/SYSTEM/res/Search@1x.png create mode 100644 skeleton/SYSTEM/res/Search@2x.png create mode 100644 skeleton/SYSTEM/res/Search@3x.png create mode 100644 skeleton/SYSTEM/res/Search@4x.png diff --git a/README.md b/README.md index 220076661..e31cf178e 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ For other devices we recommend checking out [MinUI](https://github.com/shauninma - Battery Monitoring including history graph and time left prediction - Scrolling animated titles for longer game names - Favorites, added and removed from a context menu (Y) on any listed game +- Global ROM search in the quick menu, with an on-screen keyboard and live results - Updated and optimized build emulation cores - Rumble strength fixed and is now variable as used by the games - FBNeo Arcade screen rotation diff --git a/skeleton/SYSTEM/res/Search@1x.png b/skeleton/SYSTEM/res/Search@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..761b936af3098c1e4584601d69adcf1570943635 GIT binary patch literal 441 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTCg=CK)Uj~LMH3o);76yi2K%s^g z3=E|}g|8AA7_4S6Fo+k-*%fF5lweEpc6VX;4}uH!E}sk(VK4FYb!C6W!NJ8X|8~iO zCI$vZeNPw1kc@k8XL$1+R^V}Ae)V^~Pk3*~l!oJS$&1s%LR)0^|C08~etRR*^j4#| z!mY;r>^~;FIb-E`lC5Fex_>sJY^(`i?#$=q;s|1X(|A$BY$?OM%jZ&}nKmSatiEr^ z?6E(+hhfXP?C#*NcFz>F=YIa1AKszPa3w&}rd&EnnW63Ti=tW-#y`QRR|kFcEmkAkcAorw;?i5zT8>vTPG| hnKr~VWaRRHU@c)bN~{W+xCj`U44$rjF6*2UngHL$rpf>S literal 0 HcmV?d00001 diff --git a/skeleton/SYSTEM/res/Search@2x.png b/skeleton/SYSTEM/res/Search@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..2452d31a325bbee67c8786ff20d796e964b38d96 GIT binary patch literal 781 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 literal 0 HcmV?d00001 diff --git a/workspace/all/common/config.h b/workspace/all/common/config.h index 1c0b333b1..2c9b12df1 100644 --- a/workspace/all/common/config.h +++ b/workspace/all/common/config.h @@ -50,9 +50,10 @@ enum { SCREEN_GAMELIST, SCREEN_GAMESWITCHER, SCREEN_QUICKMENU, - // a view that is only reachable from another screen, never a default view + // views that are only reachable from another screen, never a default view // (keep after the ones above, defaultView persists these as plain ints) SCREEN_CONTEXTMENU, + SCREEN_SEARCH, // meta SCREEN_GAME, SCREEN_OFF diff --git a/workspace/all/nextui/nextui.c b/workspace/all/nextui/nextui.c index ae151c05a..926b6a1a6 100644 --- a/workspace/all/nextui/nextui.c +++ b/workspace/all/nextui/nextui.c @@ -484,6 +484,10 @@ static int restore_end = 0; static int startgame = 0; /////////////////////////////////////// +// name of the quick menu entry that opens SCREEN_SEARCH. doubles as the icon +// basename in RES_PATH, same as the directory entries beside it +#define QUICK_SEARCH_NAME "Search" + #define MAX_RECENTS 24 // a multiple of all menu rows static void saveRecents(void) { char tmp_path[MAX_PATH]; @@ -938,6 +942,10 @@ static Array* getQuickEntries(void) { Array_push(entries, Entry_new(tools_path, ENTRY_DIR)); } + // global rom search. ENTRY_DIP because it opens a screen of its own rather + // than a directory, see the SCREEN_QUICKMENU input handling + Array_push(entries, Entry_new(QUICK_SEARCH_NAME, ENTRY_DIP)); + return entries; } @@ -1805,6 +1813,8 @@ static void QuickMenu_quit(void) { EntryArray_free(quickActions); } +static void Search_quit(void); + static void Menu_init(void) { stack = Array_new(); // array of open Directories recents = Array_new(); @@ -1821,6 +1831,7 @@ static void Menu_quit(void) { DirectoryArray_free(stack); QuickMenu_quit(); + Search_quit(); } /////////////////////////////////////// @@ -2559,6 +2570,231 @@ static void applyContextAction(void) { /////////////////////////////////////// +#define SEARCH_QUERY_MAX 32 +#define SEARCH_INDEX_MAX 8192 // a card with more roms than this is not a search problem +#define SEARCH_MAX_DEPTH 4 +#define SEARCH_KEY_ROWS 4 +#define SEARCH_KEY_COLS 10 +#define SEARCH_KEY_W 26 // all before scale +#define SEARCH_KEY_H 16 +#define SEARCH_KEY_GAP 2 + +static const char* search_keys[SEARCH_KEY_ROWS] = { + "1234567890", + "qwertyuiop", + "asdfghjkl-", + "zxcvbnm,.'", +}; + +enum { + SEARCH_FOCUS_KEYBOARD, + SEARCH_FOCUS_RESULTS, +}; + +static Array* search_index = NULL; // EntryArray, owns its entries +static Array* search_results = NULL; // borrowed pointers into search_index +static char search_query[SEARCH_QUERY_MAX+1] = ""; +static int search_selected = 0; +static int search_start = 0; +static int search_focus = SEARCH_FOCUS_KEYBOARD; +static int search_key_row = 0; +static int search_key_col = 0; + +// Walks a system folder the way the browser does - same hidden-file rules, same +// map.txt aliases - but recursively, and treating a multi-disc folder as one game. +static void addSearchEntries(Array* entries, char* path, int depth) { + if (depth>SEARCH_MAX_DEPTH) return; + + DIR* dh = opendir(path); + if (!dh) return; + + Hash* map = NULL; + char map_path[256]; + snprintf(map_path, sizeof(map_path), "%s/map.txt", path); + if (exists(map_path)) { + FILE* file = fopen(map_path, "r"); + if (file) { + map = Hash_new(); + char line[256]; + while (fgets(line,256,file)!=NULL) { + normalizeNewline(line); + trimTrailingNewlines(line); + if (strlen(line)==0) continue; // skip empty lines + + char* tmp = strchr(line, '\t'); + if (tmp) { + tmp[0] = '\0'; + Hash_set(map, line, tmp+1); + } + } + fclose(file); + } + } + + struct dirent* dp; + char full_path[256]; + while ((dp = readdir(dh)) != NULL) { + if (entries->count>=SEARCH_INDEX_MAX) break; + if (hide(dp->d_name)) continue; + + snprintf(full_path, sizeof(full_path), "%s/%s", path, dp->d_name); + int is_dir = dp->d_type==DT_DIR || (dp->d_type==DT_UNKNOWN && isDirectory(full_path)); + if (is_dir && suffixMatch(".pak", dp->d_name)) continue; + + if (is_dir && !isGameDir(full_path)) { // just a folder, keep looking inside it + addSearchEntries(entries, full_path, depth+1); + continue; + } + + Entry* entry = Entry_new(full_path, is_dir ? ENTRY_DIR : ENTRY_ROM); + char* alias = map ? Hash_get(map, dp->d_name) : NULL; + if (alias) { + free(entry->name); + entry->name = strdup(alias); + } + if (hide(entry->name)) { // map.txt can hide an entry by renaming it + Entry_free(entry); + continue; + } + Array_push(entries, entry); + } + closedir(dh); + + if (map) Hash_free(map); +} + +// Built once per launch, on the first visit to the search screen. +static void buildSearchIndex(void) { + if (search_index) return; + + search_index = Array_new(); + + DIR* dh = opendir(ROMS_PATH); + if (dh) { + struct dirent* dp; + char full_path[256]; + while ((dp = readdir(dh)) != NULL) { + if (hide(dp->d_name)) continue; + if (!hasRoms(dp->d_name)) continue; // nothing to launch it with anyway + + snprintf(full_path, sizeof(full_path), "%s/%s", ROMS_PATH, dp->d_name); + addSearchEntries(search_index, full_path, 0); + } + closedir(dh); + } + + EntryArray_sort(search_index); + + // the same game routinely exists under two systems, so tag collisions with + // the emulator they belong to, exactly like a directory listing does + Entry* prior = NULL; + for (int i=0; icount; i++) { + Entry* entry = search_index->items[i]; + if (prior && exactMatch(prior->name, entry->name)) { + char unique[MAX_PATH]; + if (!prior->unique) { + getUniqueName(prior, unique); + prior->unique = strdup(unique); + } + getUniqueName(entry, unique); + entry->unique = strdup(unique); + } + prior = entry; + } +} + +static void updateSearchResults(void) { + if (!search_results) search_results = Array_new(); + search_results->count = 0; // entries belong to search_index, nothing to free + + if (search_index && search_query[0]) { + for (int i=0; icount; i++) { + Entry* entry = search_index->items[i]; + if (containsString(entry->name, search_query)) Array_push(search_results, entry); + } + } + + // the result set changed out from under it, so the old position means nothing + search_selected = 0; + search_start = 0; +} + +static int searchKeyboardHeight(void) { + return SCALE1(SEARCH_KEY_ROWS * SEARCH_KEY_H + (SEARCH_KEY_ROWS-1) * SEARCH_KEY_GAP); +} +static int searchListTop(void) { + return SCALE1(PADDING + PILL_SIZE + BUTTON_MARGIN); // below the query pill +} +// The keyboard gives up its space once the results have focus, so the list can +// use the whole screen for browsing and just a preview while typing. +static int searchListRows(void) { + int bottom = screen->h - SCALE1(PADDING + PILL_SIZE + BUTTON_MARGIN); + if (search_focus==SEARCH_FOCUS_KEYBOARD) bottom -= searchKeyboardHeight() + SCALE1(BUTTON_MARGIN); + + int rows = (bottom - searchListTop()) / SCALE1(PILL_SIZE); + return rows<1 ? 1 : rows; +} +static void searchScrollTo(int selected) { + int total = search_results ? search_results->count : 0; + if (total<=0) { + search_selected = 0; + search_start = 0; + return; + } + + int rows = searchListRows(); + search_selected = clamp(selected, 0, total-1); + if (search_selected=search_start+rows) search_start = search_selected - rows + 1; + if (search_start>total-rows) search_start = total - rows; + if (search_start<0) search_start = 0; +} + +static void openSearch(void) { + buildSearchIndex(); + search_query[0] = '\0'; + search_focus = SEARCH_FOCUS_KEYBOARD; + search_key_row = 0; + search_key_col = 0; + updateSearchResults(); +} +static void searchAppend(char c) { + int len = strlen(search_query); + if (len>=SEARCH_QUERY_MAX) return; + + search_query[len] = c; + search_query[len+1] = '\0'; + updateSearchResults(); +} +static void searchBackspace(void) { + int len = strlen(search_query); + if (len==0) return; + + search_query[len-1] = '\0'; + updateSearchResults(); +} +static Entry* searchSelectedEntry(void) { + if (!search_results || search_selected<0 || search_selected>=search_results->count) return NULL; + return search_results->items[search_selected]; +} +// So X still resumes a save state straight out of the results. +static void searchReadyResume(void) { + Entry* entry = searchSelectedEntry(); + if (entry) readyResume(entry); + else { + can_resume = 0; + has_preview = 0; + } +} +static void Search_quit(void) { + if (search_results) Array_free(search_results); + if (search_index) EntryArray_free(search_index); + search_results = NULL; + search_index = NULL; +} + +/////////////////////////////////////// + // Draws `text` vertically centered in `rect`, clipped to its width. static void blitRowText(const char* text, SDL_Rect* rect, SDL_Color color) { SDL_LockMutex(fontMutex); @@ -2633,6 +2869,117 @@ static void drawContextMenu(int ow) { GFX_blitButtonGroup((char*[]){ "B","CANCEL", "A","SELECT", NULL }, 1, screen, 1); } +static void drawSearch(int ow) { + int total = search_results ? search_results->count : 0; + int rows = searchListRows(); + + // query pill, with the match count parked at its right edge + { + char count[16] = ""; + int count_w = 0; + int count_h = 0; + if (search_query[0]) { + snprintf(count, sizeof(count), "%i", total); + GFX_sizeText(font.tiny, count, SCALE1(FONT_TINY), &count_w, &count_h); + count_w += SCALE1(BUTTON_MARGIN); + } + + int pill_width = screen->w - SCALE1(PADDING*2) - ow - SCALE1(BUTTON_MARGIN); + SDL_Rect pill_rect = { SCALE1(PADDING), SCALE1(PADDING), pill_width, SCALE1(PILL_SIZE) }; + GFX_blitPillLight(ASSET_WHITE_PILL, screen, &pill_rect); + + // a caret so an empty query still reads as an input, not a label + char query[SEARCH_QUERY_MAX+2]; + snprintf(query, sizeof(query), "%s|", search_query); + + char display_query[SEARCH_QUERY_MAX+2]; + GFX_truncateText(font.large, query, display_query, pill_width - count_w, SCALE1(BUTTON_PADDING*2)); + + SDL_Rect text_rect = { + pill_rect.x + SCALE1(BUTTON_PADDING), + pill_rect.y, + pill_width - SCALE1(BUTTON_PADDING*2) - count_w, + pill_rect.h + }; + blitRowText(display_query, &text_rect, uintToColour(THEME_COLOR6_255)); + + if (count_w) { + SDL_Rect count_rect = { + pill_rect.x + pill_width - SCALE1(BUTTON_PADDING) - (count_w - SCALE1(BUTTON_MARGIN)), + pill_rect.y + (pill_rect.h - count_h) / 2, + count_w, + count_h + }; + GFX_blitText(font.tiny, count, SCALE1(FONT_TINY), uintToColour(THEME_COLOR6_255), screen, &count_rect); + } + } + + // results + int list_top = searchListTop(); + if (total==0) { + SDL_Rect message_rect = { 0, list_top, screen->w, rows * SCALE1(PILL_SIZE) }; + GFX_blitMessage(font.large, search_query[0] ? "No matches" : "Type to search", screen, &message_rect); + } + else { + for (int i=search_start, j=0; iitems[i]; + char* entry_name = entry->unique ? entry->unique : entry->name; + trimSortingMeta(&entry_name); + + int available_width = screen->w - SCALE1(BUTTON_MARGIN*2); + char display_name[256]; + int text_width = GFX_truncateText(font.large, entry_name, display_name, available_width, SCALE1(BUTTON_PADDING*2)); + int max_width = MIN(available_width, text_width); + + SDL_Rect row_rect = { SCALE1(BUTTON_MARGIN), list_top + j * SCALE1(PILL_SIZE), max_width, SCALE1(PILL_SIZE) }; + int is_selected = (search_focus==SEARCH_FOCUS_RESULTS && i==search_selected); + if (is_selected) GFX_blitPillDark(ASSET_WHITE_PILL, screen, &row_rect); + + SDL_Rect text_rect = { + row_rect.x + SCALE1(BUTTON_PADDING), + row_rect.y, + max_width - SCALE1(BUTTON_PADDING*2), + row_rect.h + }; + blitRowText(display_name, &text_rect, uintToColour(is_selected ? THEME_COLOR5_255 : THEME_COLOR4_255)); + } + } + + // keyboard + if (search_focus==SEARCH_FOCUS_KEYBOARD) { + int key_width = SCALE1(SEARCH_KEY_W); + int key_height = SCALE1(SEARCH_KEY_H); + int key_pitch_x = SCALE1(SEARCH_KEY_W + SEARCH_KEY_GAP); + int key_pitch_y = SCALE1(SEARCH_KEY_H + SEARCH_KEY_GAP); + int keyboard_x = (screen->w - (SEARCH_KEY_COLS * key_pitch_x - SCALE1(SEARCH_KEY_GAP))) / 2; + int keyboard_y = screen->h - SCALE1(PADDING + PILL_SIZE + BUTTON_MARGIN) - searchKeyboardHeight(); + + for (int r=0; ritems[qm_col] : quickActions->items[qm_col]; - if(selected->type != ENTRY_DIP) { - currentScreen = SCREEN_GAMELIST; - total = top->entries->count; - // prevent restoring list state, game list screen currently isnt our nav origin - top->selected = 0; - top->start = 0; - top->end = top->start + MAIN_ROW_COUNT; - restore_depth = -1; - restore_relative = -1; - restore_selected = 0; - restore_start = 0; - restore_end = 0; + if (qm_row == 0 && exactMatch(selected->name, QUICK_SEARCH_NAME)) { + // a screen of its own rather than something to open or toggle + openSearch(); + currentScreen = SCREEN_SEARCH; + } + else { + if(selected->type != ENTRY_DIP) { + currentScreen = SCREEN_GAMELIST; + total = top->entries->count; + // prevent restoring list state, game list screen currently isnt our nav origin + top->selected = 0; + top->start = 0; + top->end = top->start + MAIN_ROW_COUNT; + restore_depth = -1; + restore_relative = -1; + restore_selected = 0; + restore_start = 0; + restore_end = 0; + } + Entry_open(selected); + if (top->entries->count > 0) readyResume(top->entries->items[top->selected]); } - Entry_open(selected); - if (top->entries->count > 0) readyResume(top->entries->items[top->selected]); dirty = 1; } else if (PAD_justPressed(BTN_RIGHT)) { @@ -2869,11 +3223,111 @@ int main (int argc, char *argv[]) { currentScreen = context_return_screen; total = top->entries->count; - if (total>0) readyResume(top->entries->items[top->selected]); + if (currentScreen==SCREEN_SEARCH) searchReadyResume(); + else if (total>0) readyResume(top->entries->items[top->selected]); folderbgchanged = 1; // the list underneath may have changed shape dirty = 1; } } + else if (currentScreen == SCREEN_SEARCH) { + int results = search_results ? search_results->count : 0; + + if (PAD_tappedMenu(now)) { + currentScreen = SCREEN_GAMELIST; + folderbgchanged = 1; // The background painting code is a clusterfuck, just force a repaint here + dirty = 1; + } + else if (search_focus==SEARCH_FOCUS_KEYBOARD) { + if (PAD_justPressed(BTN_B)) { + currentScreen = SCREEN_QUICKMENU; + folderbgchanged = 1; + dirty = 1; + } + else if (PAD_justPressed(BTN_A)) { + searchAppend(search_keys[search_key_row][search_key_col]); + dirty = 1; + } + else if (PAD_justRepeated(BTN_X)) { + searchBackspace(); + dirty = 1; + } + else if (PAD_justPressed(BTN_Y)) { + searchAppend(' '); + dirty = 1; + } + else if (PAD_justRepeated(BTN_LEFT)) { + search_key_col -= 1; + if (search_key_col<0) search_key_col = SEARCH_KEY_COLS-1; + dirty = 1; + } + else if (PAD_justRepeated(BTN_RIGHT)) { + search_key_col += 1; + if (search_key_col>=SEARCH_KEY_COLS) search_key_col = 0; + dirty = 1; + } + else if (PAD_justRepeated(BTN_DOWN)) { + search_key_row += 1; + if (search_key_row>=SEARCH_KEY_ROWS) search_key_row = 0; + dirty = 1; + } + else if (PAD_justRepeated(BTN_UP)) { + // the results sit directly above the keyboard, so walking off + // the top row moves into them rather than wrapping around + if (search_key_row==0 && results>0) { + search_focus = SEARCH_FOCUS_RESULTS; + searchScrollTo(search_selected); + searchReadyResume(); + } + else { + search_key_row -= 1; + if (search_key_row<0) search_key_row = SEARCH_KEY_ROWS-1; + } + dirty = 1; + } + } + else { // SEARCH_FOCUS_RESULTS + if (PAD_justPressed(BTN_B)) { + search_focus = SEARCH_FOCUS_KEYBOARD; + searchScrollTo(search_selected); + dirty = 1; + } + else if (results>0 && can_resume && PAD_justReleased(BTN_RESUME) && searchSelectedEntry()) { + should_resume = 1; + Entry_open(searchSelectedEntry()); + dirty = 1; + } + else if (results>0 && PAD_justPressed(BTN_A) && searchSelectedEntry()) { + Entry_open(searchSelectedEntry()); + dirty = 1; + } + else if (results>0 && PAD_justPressed(BTN_Y)) { + if (openContextMenu(searchSelectedEntry(), SCREEN_SEARCH, 0)) + currentScreen = SCREEN_CONTEXTMENU; + dirty = 1; + } + else if (PAD_justRepeated(BTN_DOWN)) { + if (search_selected>=results-1) search_focus = SEARCH_FOCUS_KEYBOARD; + else searchScrollTo(search_selected+1); + searchReadyResume(); + dirty = 1; + } + else if (PAD_justRepeated(BTN_UP)) { + searchScrollTo(search_selected-1); + searchReadyResume(); + dirty = 1; + } + else if (PAD_justRepeated(BTN_LEFT)) { + searchScrollTo(search_selected - searchListRows()); + searchReadyResume(); + dirty = 1; + } + else if (PAD_justRepeated(BTN_RIGHT)) { + searchScrollTo(search_selected + searchListRows()); + searchReadyResume(); + dirty = 1; + } + } + } else if(currentScreen == SCREEN_GAMESWITCHER) { if (PAD_justPressed(BTN_B) || PAD_tappedSelect(now)) { currentScreen = SCREEN_GAMELIST; @@ -3258,15 +3712,16 @@ int main (int argc, char *argv[]) { GFX_flipHidden(); GFX_animateSurfaceOpacity(tmpOldScreen,0,0,screen->w,screen->h,255,0,CFG_getMenuTransitions() ? 150:20,LAYER_BACKGROUND); } - else if(currentScreen == SCREEN_CONTEXTMENU) { - // keeps whatever background was already loaded and just drops the - // game art, so the panel underneath reads clearly - if(lastScreen != SCREEN_CONTEXTMENU) + else if(currentScreen == SCREEN_CONTEXTMENU || currentScreen == SCREEN_SEARCH) { + // both keep whatever background was already loaded and just drop + // the game art, so the panel/list underneath reads clearly + if(lastScreen != SCREEN_CONTEXTMENU && lastScreen != SCREEN_SEARCH) GFX_clearLayers(LAYER_THUMBNAIL); - drawContextMenu(ow); + if (currentScreen == SCREEN_CONTEXTMENU) drawContextMenu(ow); + else drawSearch(ow); - lastScreen = SCREEN_CONTEXTMENU; + lastScreen = currentScreen; } else if(currentScreen == SCREEN_GAMESWITCHER) { GFX_clearLayers(LAYER_ALL); @@ -3658,7 +4113,7 @@ int main (int argc, char *argv[]) { animationdirection = ANIM_NONE; } - if(lastScreen == SCREEN_QUICKMENU || lastScreen == SCREEN_CONTEXTMENU) { + if(lastScreen == SCREEN_QUICKMENU || lastScreen == SCREEN_CONTEXTMENU || lastScreen == SCREEN_SEARCH) { SDL_LockMutex(bgMutex); if(folderbgchanged) { if(folderbgbmp) @@ -3771,7 +4226,7 @@ int main (int argc, char *argv[]) { setAnimationDraw(0); } SDL_UnlockMutex(animMutex); - if (currentScreen != SCREEN_GAMESWITCHER && currentScreen != SCREEN_QUICKMENU && currentScreen != SCREEN_CONTEXTMENU) { + if (currentScreen != SCREEN_GAMESWITCHER && currentScreen != SCREEN_QUICKMENU && currentScreen != SCREEN_CONTEXTMENU && currentScreen != SCREEN_SEARCH) { if(is_scrolling && pillanimdone && currentAnimQueueSize < 1) { int ow = GFX_blitHardwareGroup(screen, show_setting); Entry* entry = top->entries->items[top->selected]; From 0e63b257024c06eba00344efabe657819a9985dd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 12:48:11 +0000 Subject: [PATCH 2/2] feat(search): carry the last query over, selected, and give it a cursor Search opened on a blank field every time, so running a variant of the search you just ran meant retyping the whole thing on an on-screen keyboard. Reopening now starts on the previous query with the whole thing selected, the way a desktop text field behaves: START runs it again as-is, and typing replaces it outright. Moving into the results, by START or by walking up off the keyboard, accepts the query and drops the selection. The query also outlives the process, in /tmp alongside last.txt. That is the case that matters - launching a game is what exits nextui, so without it the carried-over query would almost never be there when you came back. Transient on purpose: a reboot starts clean. The caret is a real cursor now rather than a fixed end-of-line marker. L/R move it a character at a time, typing inserts there and delete takes the character before it, so a typo in the middle of a query no longer means backspacing over everything after it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NVCcQ2X3F6F4pDb8ykQR33 --- README.md | 2 +- workspace/all/common/defines.h | 4 + workspace/all/nextui/nextui.c | 131 ++++++++++++++++++++++++++++++--- 3 files changed, 124 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e31cf178e..59f54e100 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ For other devices we recommend checking out [MinUI](https://github.com/shauninma - Battery Monitoring including history graph and time left prediction - Scrolling animated titles for longer game names - Favorites, added and removed from a context menu (Y) on any listed game -- Global ROM search in the quick menu, with an on-screen keyboard and live results +- Global ROM search in the quick menu, with an on-screen keyboard and live results. Reopening it offers your last query, selected, so START runs it again and typing replaces it; L/R move the cursor within the text - Updated and optimized build emulation cores - Rumble strength fixed and is now variable as used by the games - FBNeo Arcade screen rotation diff --git a/workspace/all/common/defines.h b/workspace/all/common/defines.h index 64ab4aabf..62a5ef030 100644 --- a/workspace/all/common/defines.h +++ b/workspace/all/common/defines.h @@ -34,6 +34,10 @@ #define COLLECTIONS_PATH SDCARD_PATH "/Collections" #define LAST_PATH "/tmp/last.txt" // transient +// the last search query, so reopening search offers it again. transient on +// purpose: it should survive launching a game (which exits nextui) but not a +// reboot, where a query from a previous session is just clutter +#define SEARCH_QUERY_PATH "/tmp/search.txt" #define CHANGE_DISC_PATH "/tmp/change_disc.txt" #define RESUME_SLOT_PATH "/tmp/resume_slot.txt" #define NOUI_PATH "/tmp/noui" diff --git a/workspace/all/nextui/nextui.c b/workspace/all/nextui/nextui.c index f3145dae8..7ed2a8106 100644 --- a/workspace/all/nextui/nextui.c +++ b/workspace/all/nextui/nextui.c @@ -1813,6 +1813,7 @@ static void QuickMenu_quit(void) { EntryArray_free(quickActions); } +static void Search_init(void); static void Search_quit(void); static void Menu_init(void) { @@ -1824,6 +1825,7 @@ static void Menu_init(void) { loadLast(); // restore state when available QuickMenu_init(); // needs Menu_init + Search_init(); } static void Menu_quit(void) { RecentArray_free(recents); @@ -2594,6 +2596,8 @@ enum { static Array* search_index = NULL; // EntryArray, owns its entries static Array* search_results = NULL; // borrowed pointers into search_index static char search_query[SEARCH_QUERY_MAX+1] = ""; +static int search_cursor = 0; // insertion point in search_query, 0..strlen +static int search_query_all = 0; // query carried over from last time, selected: the next key typed replaces all of it static int search_selected = 0; static int search_start = 0; static int search_focus = SEARCH_FOCUS_KEYBOARD; @@ -2752,27 +2756,57 @@ static void searchScrollTo(int selected) { static void openSearch(void) { buildSearchIndex(); - search_query[0] = '\0'; + // Reopening starts on the last query rather than a blank field, selected whole + // the way a desktop text field is: it is either the search you want to run + // again, or the text you are about to type over, and both are one button away. + search_query_all = search_query[0]!='\0'; + search_cursor = strlen(search_query); search_focus = SEARCH_FOCUS_KEYBOARD; search_key_row = 0; search_key_col = 0; - updateSearchResults(); + updateSearchResults(); // so the carried-over query already has its results +} +static void searchClear(void) { + search_query[0] = '\0'; + search_cursor = 0; + search_query_all = 0; } static void searchAppend(char c) { + if (search_query_all) searchClear(); // typing replaces the selection, not appends to it + int len = strlen(search_query); if (len>=SEARCH_QUERY_MAX) return; - search_query[len] = c; - search_query[len+1] = '\0'; + memmove(search_query+search_cursor+1, search_query+search_cursor, len-search_cursor+1); // +1 for the terminator + search_query[search_cursor] = c; + search_cursor += 1; updateSearchResults(); } static void searchBackspace(void) { - int len = strlen(search_query); - if (len==0) return; + if (search_query_all) { // delete takes the whole selection, same as typing over it + searchClear(); + updateSearchResults(); + return; + } + if (search_cursor<=0) return; - search_query[len-1] = '\0'; + int len = strlen(search_query); + memmove(search_query+search_cursor-1, search_query+search_cursor, len-search_cursor+1); + search_cursor -= 1; updateSearchResults(); } +// Moving the cursor collapses the selection to the end it moved towards instead +// of clearing the query - the text is kept, it just stops being selected. +static void searchMoveCursor(int dir) { + int len = strlen(search_query); + if (search_query_all) { + search_query_all = 0; + search_cursor = dir<0 ? 0 : len; + return; + } + + search_cursor = clamp(search_cursor + dir, 0, len); +} static Entry* searchSelectedEntry(void) { if (!search_results || search_selected<0 || search_selected>=search_results->count) return NULL; return search_results->items[search_selected]; @@ -2786,7 +2820,21 @@ static void searchReadyResume(void) { has_preview = 0; } } +// The index is rebuilt per launch, but the query outlives the process so that +// searching, launching a game and coming back still offers the last search - +// which is the whole point, since launching a game is what exits nextui. +static void Search_init(void) { + if (!exists(SEARCH_QUERY_PATH)) return; + + getFile(SEARCH_QUERY_PATH, search_query, sizeof(search_query)); + for (char* c=search_query; *c; c++) { + if (*c<' ') { *c = '\0'; break; } // a stray newline would draw as a glyph in the pill + } + search_cursor = strlen(search_query); +} static void Search_quit(void) { + putFile(SEARCH_QUERY_PATH, search_query); + if (search_results) Array_free(search_results); if (search_index) EntryArray_free(search_index); search_results = NULL; @@ -2888,9 +2936,13 @@ static void drawSearch(int ow) { SDL_Rect pill_rect = { SCALE1(PADDING), SCALE1(PADDING), pill_width, SCALE1(PILL_SIZE) }; GFX_blitPillLight(ASSET_WHITE_PILL, screen, &pill_rect); - // a caret so an empty query still reads as an input, not a label + // A caret, so an empty query still reads as an input and not a label. It is + // spliced into the string rather than blitted separately so it lands on a + // glyph boundary without measuring the text twice. A selected query shows no + // caret - the selection is the cursor, and it covers every character. char query[SEARCH_QUERY_MAX+2]; - snprintf(query, sizeof(query), "%s|", search_query); + if (search_query_all) snprintf(query, sizeof(query), "%s", search_query); + else snprintf(query, sizeof(query), "%.*s|%s", search_cursor, search_query, search_query+search_cursor); char display_query[SEARCH_QUERY_MAX+2]; GFX_truncateText(font.large, query, display_query, pill_width - count_w, SCALE1(BUTTON_PADDING*2)); @@ -2901,7 +2953,33 @@ static void drawSearch(int ow) { pill_width - SCALE1(BUTTON_PADDING*2) - count_w, pill_rect.h }; - blitRowText(display_query, &text_rect, uintToColour(THEME_COLOR6_255)); + + // selection reuses the highlight the keyboard puts on the key under the + // cursor, so "this is about to be replaced" looks the same in both places + if (search_query_all) { + int query_w = 0; + int query_h = 0; + GFX_sizeText(font.large, display_query, SCALE1(FONT_LARGE), &query_w, &query_h); + if (query_w>text_rect.w) query_w = text_rect.w; + + SDL_Rect highlight_rect = { + text_rect.x - SCALE1(2), + pill_rect.y + (pill_rect.h - query_h - SCALE1(BUTTON_MARGIN)) / 2, + query_w + SCALE1(4), + query_h + SCALE1(BUTTON_MARGIN) + }; + + // the corners are drawn from the asset, so the rect cannot be smaller than + // the asset is - a one character query is narrow enough to reach that + SDL_Rect corner_rect; + GFX_assetRect(ASSET_STATE_BG, &corner_rect); + if (highlight_rect.w0) { + search_focus = SEARCH_FOCUS_RESULTS; + searchScrollTo(search_selected); + searchReadyResume(); + } + dirty = 1; + } else if (PAD_justPressed(BTN_A)) { searchAppend(search_keys[search_key_row][search_key_col]); dirty = 1; @@ -3255,6 +3353,14 @@ int main (int argc, char *argv[]) { searchAppend(' '); dirty = 1; } + else if (PAD_justRepeated(BTN_L1) && !PAD_isPressed(BTN_R1)) { + searchMoveCursor(-1); + dirty = 1; + } + else if (PAD_justRepeated(BTN_R1) && !PAD_isPressed(BTN_L1)) { + searchMoveCursor(1); + dirty = 1; + } else if (PAD_justRepeated(BTN_LEFT)) { search_key_col -= 1; if (search_key_col<0) search_key_col = SEARCH_KEY_COLS-1; @@ -3275,6 +3381,7 @@ int main (int argc, char *argv[]) { // the top row moves into them rather than wrapping around if (search_key_row==0 && results>0) { search_focus = SEARCH_FOCUS_RESULTS; + search_query_all = 0; // browsing the matches accepts the query, same as START searchScrollTo(search_selected); searchReadyResume(); }