From cb28d2b611970ff054eec2bb0e571a39e89c79ab Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Fri, 21 Aug 2026 14:16:39 -0500 Subject: [PATCH] Add save slot picker (uiselectsave/uisaveslot), fix focus stack reentrancy uiselectsave is a Save/Load/Delete slot picker built on save/save.h's SAVE_SLOT_COUNT: a scrolling list of framed uisaveslot_t rows (slot number + file name, or an "Empty Slot" placeholder), navigated via the same uimenu_t/focus-stack convention as every other menu. Load and Delete modes add a trailing action row (switch to delete / cancel) fixed below the scrollable area. Selecting a slot in Delete mode routes through uiConfirmOpen before actually blanking the slot via saveSlotInit + saveDeviceSlotWrite. uiScrollingEnsureVisible in uiscrolling.c is now a real implementation instead of the earlier placeholder. Main menu collapses New Game/Load Game into a single Start Game entry that opens uiselectsave in Load mode; backing out reopens the main menu via uiselectsave's result callback. Also fixes a real reentrancy bug in uiFocusPop: it invoked the popped item's closed callback before decrementing UI_FOCUS.count, so a push triggered from within that callback (e.g. reopening the main menu once select-save reports no result) landed one slot past the true stack top and got silently dropped, stranding the new item outside navigation. Count is now decremented first. Adds a uikeyboard placeholder widget, registered in uielementlist with no behavior yet. Co-Authored-By: Claude Sonnet 5 --- assets/locale/en_US.po | 27 ++- src/dusk/save/saveslot.h | 4 +- src/dusk/ui/focus/uifocus.c | 11 +- src/dusk/ui/frame/CMakeLists.txt | 3 +- src/dusk/ui/frame/mainmenu/uimainmenu.c | 43 ++-- src/dusk/ui/frame/mainmenu/uimainmenu.h | 5 +- src/dusk/ui/frame/save/CMakeLists.txt | 10 + src/dusk/ui/frame/save/uisaveslot.c | 105 +++++++++ src/dusk/ui/frame/save/uisaveslot.h | 114 +++++++++ src/dusk/ui/frame/save/uiselectsave.c | 298 ++++++++++++++++++++++++ src/dusk/ui/frame/save/uiselectsave.h | 133 +++++++++++ src/dusk/ui/uielementlist.c | 30 ++- src/dusk/ui/widget/CMakeLists.txt | 1 + src/dusk/ui/widget/uikeyboard.c | 24 ++ src/dusk/ui/widget/uikeyboard.h | 37 +++ src/dusk/ui/widget/uiscrolling.c | 23 +- src/dusk/ui/widget/uiscrolling.h | 22 +- 17 files changed, 841 insertions(+), 49 deletions(-) create mode 100644 src/dusk/ui/frame/save/CMakeLists.txt create mode 100644 src/dusk/ui/frame/save/uisaveslot.c create mode 100644 src/dusk/ui/frame/save/uisaveslot.h create mode 100644 src/dusk/ui/frame/save/uiselectsave.c create mode 100644 src/dusk/ui/frame/save/uiselectsave.h create mode 100644 src/dusk/ui/widget/uikeyboard.c create mode 100644 src/dusk/ui/widget/uikeyboard.h diff --git a/assets/locale/en_US.po b/assets/locale/en_US.po index fd4ee5df..5f57d69a 100644 --- a/assets/locale/en_US.po +++ b/assets/locale/en_US.po @@ -26,11 +26,8 @@ msgstr "Continue without saving" # Main Menu Scene -msgid "main_menu.new_game" -msgstr "New Game" - -msgid "main_menu.load_game" -msgstr "Load Game" +msgid "main_menu.start_game" +msgstr "Start Game" msgid "main_menu.options" msgstr "Options" @@ -42,6 +39,26 @@ msgid "main_menu.quit_confirm" msgstr "Are you sure you want to quit?" +# Select Save Screen +msgid "ui.select_save.title" +msgstr "Select Save" + +msgid "ui.select_save.empty" +msgstr "Empty Slot" + +msgid "ui.select_save.slot_format" +msgstr "%s Lv.%d %s" + +msgid "ui.select_save.delete_mode" +msgstr "Delete a Save" + +msgid "ui.select_save.delete_confirm" +msgstr "Are you sure you want to delete this save?" + +msgid "ui.save_slot.number_format" +msgstr "Slot %d" + + diff --git a/src/dusk/save/saveslot.h b/src/dusk/save/saveslot.h index 52d1ca67..afad4945 100644 --- a/src/dusk/save/saveslot.h +++ b/src/dusk/save/saveslot.h @@ -11,7 +11,9 @@ #include "yyjson.h" #define SAVE_SLOT_NAME_LENGTH 8 -#define SAVE_SLOT_COUNT 3 +#ifndef SAVE_SLOT_COUNT + #define SAVE_SLOT_COUNT 3 +#endif typedef struct { char_t name[SAVE_SLOT_NAME_LENGTH + 1];// 8 characters + null terminator diff --git a/src/dusk/ui/focus/uifocus.c b/src/dusk/ui/focus/uifocus.c index 8ceb4b5a..6ca946dc 100644 --- a/src/dusk/ui/focus/uifocus.c +++ b/src/dusk/ui/focus/uifocus.c @@ -58,10 +58,15 @@ uifocusitem_t * uiFocusPush( void uiFocusPop(void) { assertTrue(UI_FOCUS.count > 0, "UI focus stack underflow"); - - uifocusitem_t *item = &UI_FOCUS.items[UI_FOCUS.count - 1]; - if(item->closed != NULL) item->closed(item); + + // Count is decremented before the closed callback fires so that a + // push triggered from within it (e.g. reopening a menu once another + // one reports it closed with no result) lands in the now-free slot + // instead of one past it, which would silently strand the new item + // above the tracked stack top. UI_FOCUS.count--; + uifocusitem_t *item = &UI_FOCUS.items[UI_FOCUS.count]; + if(item->closed != NULL) item->closed(item); } void uiFocusPopItem(uifocusitem_t *item) { diff --git a/src/dusk/ui/frame/CMakeLists.txt b/src/dusk/ui/frame/CMakeLists.txt index 9b9ccc87..20d34c27 100644 --- a/src/dusk/ui/frame/CMakeLists.txt +++ b/src/dusk/ui/frame/CMakeLists.txt @@ -11,4 +11,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} add_subdirectory(game) add_subdirectory(mainmenu) add_subdirectory(battle) -add_subdirectory(backpack) \ No newline at end of file +add_subdirectory(backpack) +add_subdirectory(save) \ No newline at end of file diff --git a/src/dusk/ui/frame/mainmenu/uimainmenu.c b/src/dusk/ui/frame/mainmenu/uimainmenu.c index 6063898c..ad0c6ba2 100644 --- a/src/dusk/ui/frame/mainmenu/uimainmenu.c +++ b/src/dusk/ui/frame/mainmenu/uimainmenu.c @@ -16,11 +16,12 @@ #include "locale/localemanager.h" #include "asset/loader/locale/assetlocaleloader.h" #include "ui/frame/uiconfirm.h" +#include "ui/frame/save/uiselectsave.h" +#include "scene/scene.h" -#define UI_MAIN_MENU_INDEX_NEW_GAME 0 -#define UI_MAIN_MENU_INDEX_LOAD_GAME 1 -#define UI_MAIN_MENU_INDEX_OPTIONS 2 -#define UI_MAIN_MENU_INDEX_QUIT 3 +#define UI_MAIN_MENU_INDEX_START_GAME 0 +#define UI_MAIN_MENU_INDEX_OPTIONS 1 +#define UI_MAIN_MENU_INDEX_QUIT 2 uimainmenu_t UI_MAIN_MENU; @@ -28,6 +29,16 @@ void uiMainMenuQuitConfirmed(const bool_t result, void *user) { if(result) ENGINE.running = false; } +void uiMainMenuSelectSaveResult(const uint8_t slotIndex, void *user) { + if(slotIndex == UI_SELECT_SAVE_RESULT_NONE) { + uiMainMenuOpen(); + return; + } + + // TODO: load/start the game using the chosen save slot. + sceneSet(SCENE_TYPE_OVERWORLD); +} + void uiMainMenuSelected( const uimenu_t *menu, const uint8_t index, @@ -35,13 +46,11 @@ void uiMainMenuSelected( ) { switch(index) { - case UI_MAIN_MENU_INDEX_NEW_GAME: + case UI_MAIN_MENU_INDEX_START_GAME: uiMenuClose(&UI_MAIN_MENU.menu); - testBattleStart(); - break; - - case UI_MAIN_MENU_INDEX_LOAD_GAME: - // TODO: load game. + uiSelectSaveOpen( + UI_SELECT_SAVE_TYPE_LOAD, uiMainMenuSelectSaveResult, NULL + ); break; case UI_MAIN_MENU_INDEX_OPTIONS: @@ -62,16 +71,9 @@ errorret_t uiMainMenuInit(void) { errorChain(assetLocaleGetString( &LOCALE.entry->data.locale, - "main_menu.new_game", + "main_menu.start_game", 0, - UI_MAIN_MENU.newGameLabel, - UI_MAIN_MENU_LABEL_MAX - )); - errorChain(assetLocaleGetString( - &LOCALE.entry->data.locale, - "main_menu.load_game", - 0, - UI_MAIN_MENU.loadGameLabel, + UI_MAIN_MENU.startGameLabel, UI_MAIN_MENU_LABEL_MAX )); errorChain(assetLocaleGetString( @@ -92,8 +94,7 @@ errorret_t uiMainMenuInit(void) { MENU_BEGIN( &UI_MAIN_MENU.menu, UI_MAIN_MENU.items, uiMainMenuSelected, NULL, NULL ); - MENU_BUTTON(UI_MAIN_MENU.newGameLabel); - MENU_BUTTON(UI_MAIN_MENU.loadGameLabel); + MENU_BUTTON(UI_MAIN_MENU.startGameLabel); MENU_BUTTON(UI_MAIN_MENU.optionsLabel); MENU_BUTTON(UI_MAIN_MENU.quitLabel); MENU_END(UI_MAIN_MENU.items, 1); diff --git a/src/dusk/ui/frame/mainmenu/uimainmenu.h b/src/dusk/ui/frame/mainmenu/uimainmenu.h index e10d037c..3a53c9ee 100644 --- a/src/dusk/ui/frame/mainmenu/uimainmenu.h +++ b/src/dusk/ui/frame/mainmenu/uimainmenu.h @@ -9,7 +9,7 @@ #include "error/error.h" #include "ui/widget/uimenu.h" -#define UI_MAIN_MENU_ITEM_COUNT 4 +#define UI_MAIN_MENU_ITEM_COUNT 3 #define UI_MAIN_MENU_WIDTH 200.0f #define UI_MAIN_MENU_HEIGHT 160.0f #define UI_MAIN_MENU_LABEL_MAX 32 @@ -17,8 +17,7 @@ typedef struct { uimenu_t menu; uimenuitem_t items[UI_MAIN_MENU_ITEM_COUNT]; - char_t newGameLabel[UI_MAIN_MENU_LABEL_MAX]; - char_t loadGameLabel[UI_MAIN_MENU_LABEL_MAX]; + char_t startGameLabel[UI_MAIN_MENU_LABEL_MAX]; char_t optionsLabel[UI_MAIN_MENU_LABEL_MAX]; char_t quitLabel[UI_MAIN_MENU_LABEL_MAX]; } uimainmenu_t; diff --git a/src/dusk/ui/frame/save/CMakeLists.txt b/src/dusk/ui/frame/save/CMakeLists.txt new file mode 100644 index 00000000..aae3928e --- /dev/null +++ b/src/dusk/ui/frame/save/CMakeLists.txt @@ -0,0 +1,10 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +target_sources(${DUSK_LIBRARY_TARGET_NAME} + PUBLIC + uisaveslot.c + uiselectsave.c +) diff --git a/src/dusk/ui/frame/save/uisaveslot.c b/src/dusk/ui/frame/save/uisaveslot.c new file mode 100644 index 00000000..ab43366d --- /dev/null +++ b/src/dusk/ui/frame/save/uisaveslot.c @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "uisaveslot.h" +#include "ui/widget/uiframe.h" +#include "assert/assert.h" +#include "util/memory.h" +#include "util/string.h" +#include "display/color.h" +#include "locale/localemanager.h" +#include "asset/loader/locale/assetlocaleloader.h" + +void uiSaveSlotInit( + uisaveslot_t *saveSlot, + const uint8_t slotIndex, + const char_t *fileName +) { + assertNotNull(saveSlot, "Save slot cannot be NULL"); + assertNotNull(fileName, "File name cannot be NULL"); + + memoryZero(saveSlot, sizeof(uisaveslot_t)); + saveSlot->slotIndex = slotIndex; + + uiLabelInit( + &saveSlot->numberLabel, saveSlot->numberText, + saveSlot->numberSprites, UI_SAVE_SLOT_NUMBER_SPRITES_MAX + ); + errorret_t result = assetLocaleGetStringWithVA( + &LOCALE.entry->data.locale, "ui.save_slot.number_format", 0, + saveSlot->numberText, UI_SAVE_SLOT_NUMBER_TEXT_MAX, + slotIndex + 1 + ); + if(errorIsNotOk(result)) errorCatch(result); + saveSlot->numberLabel.dirty = true; + + uiLabelInit( + &saveSlot->nameLabel, saveSlot->nameText, + saveSlot->nameSprites, UI_SAVE_SLOT_NAME_SPRITES_MAX + ); + stringCopy(saveSlot->nameText, fileName, UI_SAVE_SLOT_NAME_TEXT_MAX); + saveSlot->nameLabel.dirty = true; +} + +bool_t uiSaveSlotIsHighlighted(const uisaveslot_t *saveSlot) { + assertNotNull(saveSlot, "Save slot cannot be NULL"); + return saveSlot->highlighted; +} + +void uiSaveSlotSetHighlighted(uisaveslot_t *saveSlot, const bool_t highlighted) { + assertNotNull(saveSlot, "Save slot cannot be NULL"); + saveSlot->highlighted = highlighted; +} + +uisaveslotdisplaytype_t uiSaveSlotGetDisplayType(const uisaveslot_t *saveSlot) { + assertNotNull(saveSlot, "Save slot cannot be NULL"); + return saveSlot->displayType; +} + +void uiSaveSlotSetDisplayType( + uisaveslot_t *saveSlot, + const uisaveslotdisplaytype_t displayType +) { + assertNotNull(saveSlot, "Save slot cannot be NULL"); + saveSlot->displayType = displayType; +} + +errorret_t uiSaveSlotDraw( + uisaveslot_t *saveSlot, + const float_t x, + const float_t y, + const float_t width, + const float_t height +) { + assertNotNull(saveSlot, "Save slot cannot be NULL"); + + errorChain(uiFrameDraw(x, y, width, height)); + + const float_t contentX = x + UI_FRAME_START_X; + const float_t contentY = y + UI_FRAME_START_Y; + + color_t color; + if(saveSlot->highlighted) { + color = COLOR_RED; + } else if(saveSlot->displayType == UI_SAVE_SLOT_DISPLAY_TYPE_DELETE) { + color = COLOR_ORANGE; + } else { + color = COLOR_WHITE; + } + + uiLabelSetX(&saveSlot->numberLabel, contentX); + uiLabelSetY(&saveSlot->numberLabel, contentY); + errorChain(uiLabelRender(&saveSlot->numberLabel, color)); + + const float_t nameY = contentY + (float_t)saveSlot->numberLabel.height + + UI_FRAME_PADDING_Y; + uiLabelSetX(&saveSlot->nameLabel, contentX); + uiLabelSetY(&saveSlot->nameLabel, nameY); + errorChain(uiLabelRender(&saveSlot->nameLabel, color)); + + errorOk(); +} diff --git a/src/dusk/ui/frame/save/uisaveslot.h b/src/dusk/ui/frame/save/uisaveslot.h new file mode 100644 index 00000000..07799765 --- /dev/null +++ b/src/dusk/ui/frame/save/uisaveslot.h @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "error/error.h" +#include "ui/widget/uilabel.h" + +#define UI_SAVE_SLOT_NUMBER_TEXT_MAX 16 +#define UI_SAVE_SLOT_NUMBER_SPRITES_MAX UI_SAVE_SLOT_NUMBER_TEXT_MAX +#define UI_SAVE_SLOT_NAME_TEXT_MAX 64 +#define UI_SAVE_SLOT_NAME_SPRITES_MAX UI_SAVE_SLOT_NAME_TEXT_MAX + +/** + * How a save slot entry should be styled: NORMAL for everyday + * save/load browsing, DELETE to visually flag the row as part of a + * destructive delete flow. + */ +typedef enum { + UI_SAVE_SLOT_DISPLAY_TYPE_NORMAL, + UI_SAVE_SLOT_DISPLAY_TYPE_DELETE +} uisaveslotdisplaytype_t; + +/** + * A single framed save slot entry: a slot number and a file name. Later + * this will grow to show more per-slot save data (level, playtime, + * etc). + */ +typedef struct { + uint8_t slotIndex; + uisaveslotdisplaytype_t displayType; + + uilabel_t numberLabel; + char_t numberText[UI_SAVE_SLOT_NUMBER_TEXT_MAX]; + spritebatchsprite_t numberSprites[UI_SAVE_SLOT_NUMBER_SPRITES_MAX]; + + uilabel_t nameLabel; + char_t nameText[UI_SAVE_SLOT_NAME_TEXT_MAX]; + spritebatchsprite_t nameSprites[UI_SAVE_SLOT_NAME_SPRITES_MAX]; + + bool_t highlighted; +} uisaveslot_t; + +/** + * Initializes a save slot widget with the given slot index and display + * file name. + * + * @param saveSlot The save slot widget to initialize. + * @param slotIndex The save slot index this widget represents. + * @param fileName Display file name; copied internally, safe to be + * transient. + */ +void uiSaveSlotInit( + uisaveslot_t *saveSlot, + const uint8_t slotIndex, + const char_t *fileName +); + +/** + * Returns whether the save slot widget is highlighted. + * + * @param saveSlot The save slot widget to query. + * @returns True if highlighted. + */ +bool_t uiSaveSlotIsHighlighted(const uisaveslot_t *saveSlot); + +/** + * Sets the highlighted state of the save slot widget. + * + * @param saveSlot The save slot widget to update. + * @param highlighted The new highlighted state. + */ +void uiSaveSlotSetHighlighted(uisaveslot_t *saveSlot, const bool_t highlighted); + +/** + * Returns the save slot widget's display type. + * + * @param saveSlot The save slot widget to query. + * @returns The display type. + */ +uisaveslotdisplaytype_t uiSaveSlotGetDisplayType(const uisaveslot_t *saveSlot); + +/** + * Sets the save slot widget's display type. + * + * @param saveSlot The save slot widget to update. + * @param displayType The new display type. + */ +void uiSaveSlotSetDisplayType( + uisaveslot_t *saveSlot, + const uisaveslotdisplaytype_t displayType +); + +/** + * Draws the save slot widget as a framed box at the given position and + * size, showing its slot number and file name. + * + * @param saveSlot The save slot widget to draw. + * @param x Screen x position. + * @param y Screen y position. + * @param width Frame width. + * @param height Frame height. + * @return Any error that occurs. + */ +errorret_t uiSaveSlotDraw( + uisaveslot_t *saveSlot, + const float_t x, + const float_t y, + const float_t width, + const float_t height +); diff --git a/src/dusk/ui/frame/save/uiselectsave.c b/src/dusk/ui/frame/save/uiselectsave.c new file mode 100644 index 00000000..c6b4eae7 --- /dev/null +++ b/src/dusk/ui/frame/save/uiselectsave.c @@ -0,0 +1,298 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "uiselectsave.h" +#include "ui/widget/uiframe.h" +#include "ui/frame/uiconfirm.h" +#include "save/save.h" +#include "time/timeepoch.h" +#include "assert/assert.h" +#include "util/memory.h" +#include "display/screen/screen.h" +#include "display/text/text.h" +#include "display/color.h" +#include "display/spritebatch/spritebatch.h" +#include "locale/localemanager.h" +#include "asset/loader/locale/assetlocaleloader.h" + +uiselectsave_t UI_SELECT_SAVE; + +void uiSelectSaveFormatSlot( + const uint8_t index, + char_t *buffer, + const size_t bufferSize +) { + saveslotcache_t *cache = &SAVE.caches[index]; + + if(!saveSlotInUse(cache)) { + errorret_t result = assetLocaleGetString( + &LOCALE.entry->data.locale, "ui.select_save.empty", 0, + buffer, bufferSize + ); + if(errorIsNotOk(result)) errorCatch(result); + return; + } + + char_t dateText[32]; + timeEpochFormat(cache->time, "%Y-%m-%d %H:%M", dateText, sizeof(dateText)); + + errorret_t result = assetLocaleGetStringWithVA( + &LOCALE.entry->data.locale, "ui.select_save.slot_format", 0, + buffer, bufferSize, + cache->name, cache->playerLevel, dateText + ); + if(errorIsNotOk(result)) errorCatch(result); +} + +void uiSelectSaveRefreshSlot(const uint8_t index) { + char_t fileName[UI_SAVE_SLOT_NAME_TEXT_MAX]; + uiSelectSaveFormatSlot(index, fileName, sizeof(fileName)); + uiSaveSlotInit(&UI_SELECT_SAVE.slots[index], index, fileName); + + uisaveslotdisplaytype_t displayType = + UI_SELECT_SAVE.type == UI_SELECT_SAVE_TYPE_DELETE ? + UI_SAVE_SLOT_DISPLAY_TYPE_DELETE : UI_SAVE_SLOT_DISPLAY_TYPE_NORMAL; + uiSaveSlotSetDisplayType(&UI_SELECT_SAVE.slots[index], displayType); +} + +void uiSelectSaveDeleteConfirmed(const bool_t result, void *user) { + if(!result) return; + + assertTrue(SAVE.deviceCurrent != 0xFF, "No current device"); + const uint8_t index = UI_SELECT_SAVE.pendingDeleteIndex; + + saveslot_t slot; + saveSlotInit(&slot); + + errorret_t writeResult = saveDeviceSlotWrite( + &SAVE.devices[SAVE.deviceCurrent], &slot, index + ); + if(errorIsNotOk(writeResult)) { + errorCatch(errorPrint(writeResult)); + return; + } + + SAVE.caches[index] = slot.cachedData; + + uiSelectSaveRefreshSlot(index); + uiSaveSlotSetHighlighted(&UI_SELECT_SAVE.slots[index], true); +} + +void uiSelectSaveConfirmDelete(const uint8_t index) { + UI_SELECT_SAVE.pendingDeleteIndex = index; + uiConfirmOpen( + "ui.select_save.delete_confirm", uiSelectSaveDeleteConfirmed, NULL + ); +} + +void uiSelectSaveSetActionLabel(const uiselectsavetype_t type) { + const char_t *labelKey = type == UI_SELECT_SAVE_TYPE_LOAD ? + "ui.select_save.delete_mode" : "ui.confirm.cancel"; + + errorret_t result = assetLocaleGetString( + &LOCALE.entry->data.locale, labelKey, 0, + UI_SELECT_SAVE.actionText, UI_SELECT_SAVE_ACTION_LABEL_MAX + ); + if(errorIsNotOk(result)) errorCatch(result); + + UI_SELECT_SAVE.slotItems[UI_SELECT_SAVE_ACTION_INDEX].button.label.dirty = true; +} + +void uiSelectSaveSwitchType(const uiselectsavetype_t type) { + UI_SELECT_SAVE.type = type; + + uisaveslotdisplaytype_t displayType = type == UI_SELECT_SAVE_TYPE_DELETE ? + UI_SAVE_SLOT_DISPLAY_TYPE_DELETE : UI_SAVE_SLOT_DISPLAY_TYPE_NORMAL; + for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) { + uiSaveSlotSetDisplayType(&UI_SELECT_SAVE.slots[i], displayType); + } + + uiSelectSaveSetActionLabel(type); +} + +void uiSelectSaveSlotSelected( + const uimenu_t *menu, + const uint8_t index, + const uimenuitem_t *item +) { + if(UI_SELECT_SAVE.hasAction && index == UI_SELECT_SAVE_ACTION_INDEX) { + uiSelectSaveSwitchType( + UI_SELECT_SAVE.type == UI_SELECT_SAVE_TYPE_LOAD ? + UI_SELECT_SAVE_TYPE_DELETE : UI_SELECT_SAVE_TYPE_LOAD + ); + return; + } + + if(UI_SELECT_SAVE.type == UI_SELECT_SAVE_TYPE_DELETE) { + uiSelectSaveConfirmDelete(index); + return; + } + + UI_SELECT_SAVE.result = index; + uiSelectSaveClose(); +} + +void uiSelectSaveSlotChanged( + const uimenu_t *menu, + const uint8_t index, + const uimenuitem_t *item +) { + for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) { + uiSaveSlotSetHighlighted(&UI_SELECT_SAVE.slots[i], i == index); + } + + // The action row sits fixed below the scrollable list, so it never + // needs to be scrolled into view. + if(index >= SAVE_SLOT_COUNT) return; + + uiScrollingEnsureVisible( + &UI_SELECT_SAVE.scroll, index, SAVE_SLOT_COUNT, UI_SELECT_SAVE_VISIBLE_ROWS + ); +} + +void uiSelectSaveSlotClosed(const uimenu_t *menu) { + for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) { + uiSaveSlotSetHighlighted(&UI_SELECT_SAVE.slots[i], false); + } + + if(UI_SELECT_SAVE.callback != NULL) { + UI_SELECT_SAVE.callback(UI_SELECT_SAVE.result, UI_SELECT_SAVE.user); + } +} + +errorret_t uiSelectSaveInit(void) { + memoryZero(&UI_SELECT_SAVE, sizeof(uiselectsave_t)); + + uiLabelInit( + &UI_SELECT_SAVE.titleLabel, UI_SELECT_SAVE.titleText, + UI_SELECT_SAVE.titleSprites, UI_SELECT_SAVE_TITLE_SPRITES_MAX + ); + errorChain(assetLocaleGetString( + &LOCALE.entry->data.locale, "ui.select_save.title", 0, + UI_SELECT_SAVE.titleText, UI_SELECT_SAVE_TITLE_TEXT_MAX + )); + UI_SELECT_SAVE.titleLabel.dirty = true; + uiLabelRebuffer(&UI_SELECT_SAVE.titleLabel); + + uiMenuInit( + &UI_SELECT_SAVE.slotMenu, uiSelectSaveSlotSelected, uiSelectSaveSlotClosed, + uiSelectSaveSlotChanged + ); + uiMenuSetItems( + &UI_SELECT_SAVE.slotMenu, UI_SELECT_SAVE.slotItems, SAVE_SLOT_COUNT, 1 + ); + UI_SELECT_SAVE.slotItems[UI_SELECT_SAVE_ACTION_INDEX].type = + UI_MENU_WIDGET_TYPE_BUTTON; + uiButtonInit( + &UI_SELECT_SAVE.slotItems[UI_SELECT_SAVE_ACTION_INDEX].button, + UI_SELECT_SAVE.actionText + ); + + uiScrollingInit(&UI_SELECT_SAVE.scroll); + + errorOk(); +} + +errorret_t uiSelectSaveDraw(void) { + if(!uiMenuIsActive(&UI_SELECT_SAVE.slotMenu)) errorOk(); + + const float_t rowHeight = (float_t)FONT_DEFAULT.tileset->tileHeight; + const float_t rowFrameHeight = + (UI_FRAME_START_Y * 2) + (rowHeight * 2) + UI_FRAME_PADDING_Y; + const float_t listHeight = + (rowFrameHeight * (float_t)UI_SELECT_SAVE_VISIBLE_ROWS) + + (UI_SELECT_SAVE_ROW_GAP * (float_t)(UI_SELECT_SAVE_VISIBLE_ROWS - 1)); + + const float_t width = UI_SELECT_SAVE_WIDTH; + const float_t height = (UI_FRAME_START_Y * 2) + + (float_t)UI_SELECT_SAVE.titleLabel.height + UI_FRAME_PADDING_Y + + listHeight + + (UI_SELECT_SAVE.hasAction ? UI_FRAME_PADDING_Y + rowHeight : 0.0f); + + const float_t x = (float_t)SCREEN.scanX + + ((float_t)SCREEN.scanWidth - width) * 0.5f; + const float_t y = (float_t)SCREEN.scanY + + ((float_t)SCREEN.scanHeight - height) * 0.5f; + + errorChain(uiFrameDraw(x, y, width, height)); + + const float_t contentX = x + UI_FRAME_START_X; + const float_t contentY = y + UI_FRAME_START_Y; + const float_t contentWidth = width - (UI_FRAME_START_X * 2); + + uiLabelSetX(&UI_SELECT_SAVE.titleLabel, contentX); + uiLabelSetY(&UI_SELECT_SAVE.titleLabel, contentY); + errorChain(uiLabelRender(&UI_SELECT_SAVE.titleLabel, COLOR_WHITE)); + + const float_t listY = contentY + (float_t)UI_SELECT_SAVE.titleLabel.height + + UI_FRAME_PADDING_Y; + + const uint8_t offset = UI_SELECT_SAVE.scroll.offset; + uint8_t visibleEnd = (uint8_t)(offset + UI_SELECT_SAVE_VISIBLE_ROWS); + if(visibleEnd > SAVE_SLOT_COUNT) visibleEnd = SAVE_SLOT_COUNT; + + for(uint8_t i = offset; i < visibleEnd; i++) { + const float_t iy = + listY + (float_t)(i - offset) * (rowFrameHeight + UI_SELECT_SAVE_ROW_GAP); + errorChain(uiSaveSlotDraw( + &UI_SELECT_SAVE.slots[i], contentX, iy, contentWidth, rowFrameHeight + )); + } + + if(UI_SELECT_SAVE.hasAction) { + const float_t actionY = listY + listHeight + UI_FRAME_PADDING_Y; + errorChain(uiButtonDraw( + &UI_SELECT_SAVE.slotItems[UI_SELECT_SAVE_ACTION_INDEX].button, + contentX, actionY + )); + } + + errorChain(spriteBatchFlush()); + errorOk(); +} + +bool_t uiSelectSaveIsOpen(void) { + return uiMenuIsActive(&UI_SELECT_SAVE.slotMenu); +} + +void uiSelectSaveOpen( + const uiselectsavetype_t type, + uiselectsaveresultcallback_t callback, + void *user +) { + assertNotNull(callback, "Callback cannot be NULL"); + if(uiMenuIsActive(&UI_SELECT_SAVE.slotMenu)) return; + + UI_SELECT_SAVE.type = type; + UI_SELECT_SAVE.callback = callback; + UI_SELECT_SAVE.user = user; + UI_SELECT_SAVE.result = UI_SELECT_SAVE_RESULT_NONE; + + for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) { + uiSelectSaveRefreshSlot(i); + } + + UI_SELECT_SAVE.hasAction = + type == UI_SELECT_SAVE_TYPE_LOAD || type == UI_SELECT_SAVE_TYPE_DELETE; + uint8_t itemCount = SAVE_SLOT_COUNT; + if(UI_SELECT_SAVE.hasAction) { + uiSelectSaveSetActionLabel(type); + itemCount = UI_SELECT_SAVE_ITEM_CAPACITY; + } + uiMenuSetItems(&UI_SELECT_SAVE.slotMenu, UI_SELECT_SAVE.slotItems, itemCount, 1); + + uiScrollingInit(&UI_SELECT_SAVE.scroll); + uiMenuOpen(&UI_SELECT_SAVE.slotMenu); +} + +void uiSelectSaveClose(void) { + uiMenuClose(&UI_SELECT_SAVE.slotMenu); +} + +errorret_t uiSelectSaveDispose(void) { + errorOk(); +} diff --git a/src/dusk/ui/frame/save/uiselectsave.h b/src/dusk/ui/frame/save/uiselectsave.h new file mode 100644 index 00000000..36314c62 --- /dev/null +++ b/src/dusk/ui/frame/save/uiselectsave.h @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "error/error.h" +#include "ui/widget/uilabel.h" +#include "ui/widget/uimenu.h" +#include "ui/widget/uiscrolling.h" +#include "ui/frame/save/uisaveslot.h" +#include "save/saveslot.h" + +#define UI_SELECT_SAVE_TITLE_TEXT_MAX 64 +#define UI_SELECT_SAVE_TITLE_SPRITES_MAX UI_SELECT_SAVE_TITLE_TEXT_MAX +#define UI_SELECT_SAVE_ACTION_LABEL_MAX 32 +#define UI_SELECT_SAVE_VISIBLE_ROWS (SAVE_SLOT_COUNT < 3 ? SAVE_SLOT_COUNT : 3) +#define UI_SELECT_SAVE_WIDTH 260.0f +#define UI_SELECT_SAVE_ROW_GAP 4.0f +#define UI_SELECT_SAVE_RESULT_NONE 0xFF + +// Slot rows plus one trailing action row (switch-to-delete/cancel), only +// present for the LOAD and DELETE types - see uiSelectSaveOpen. +#define UI_SELECT_SAVE_ITEM_CAPACITY (SAVE_SLOT_COUNT + 1) +#define UI_SELECT_SAVE_ACTION_INDEX SAVE_SLOT_COUNT + +/** + * The action the select-save panel is being used for. The caller + * chooses this when calling uiSelectSaveOpen. + */ +typedef enum { + UI_SELECT_SAVE_TYPE_SAVE, + UI_SELECT_SAVE_TYPE_LOAD, + UI_SELECT_SAVE_TYPE_DELETE +} uiselectsavetype_t; + +/** + * Callback invoked once the select-save panel closes: slotIndex is the + * chosen slot, or UI_SELECT_SAVE_RESULT_NONE if backed out of without + * choosing. + * + * @param slotIndex The chosen slot, or UI_SELECT_SAVE_RESULT_NONE. + * @param user Arbitrary pointer passed to uiSelectSaveOpen. + */ +typedef void (*uiselectsaveresultcallback_t)( + const uint8_t slotIndex, void *user +); + +typedef struct { + uilabel_t titleLabel; + char_t titleText[UI_SELECT_SAVE_TITLE_TEXT_MAX]; + spritebatchsprite_t titleSprites[UI_SELECT_SAVE_TITLE_SPRITES_MAX]; + + uiselectsavetype_t type; + bool_t hasAction; + char_t actionText[UI_SELECT_SAVE_ACTION_LABEL_MAX]; + + uimenu_t slotMenu; + uimenuitem_t slotItems[UI_SELECT_SAVE_ITEM_CAPACITY]; + uisaveslot_t slots[SAVE_SLOT_COUNT]; + uiscrolling_t scroll; + uint8_t pendingDeleteIndex; + + uiselectsaveresultcallback_t callback; + void *user; + uint8_t result; +} uiselectsave_t; + +extern uiselectsave_t UI_SELECT_SAVE; + +/** + * Initializes the select-save panel. + * + * @return Any error that occurs. + */ +errorret_t uiSelectSaveInit(void); + +/** + * Draws the select-save panel: a centered frame with a title, a + * scrolling list of save/save.h's slots (each shown as a framed + * uisaveslot_t, showing at most UI_SELECT_SAVE_VISIBLE_ROWS at a time), + * and - when UI_SELECT_SAVE.hasAction is set - the switch-to-delete/ + * cancel action row fixed below the scrollable list, outside it. No-op + * when not open. + * + * @return Any error that occurs. + */ +errorret_t uiSelectSaveDraw(void); + +/** + * Returns true when the select-save panel is currently open. + * + * @returns True if open. + */ +bool_t uiSelectSaveIsOpen(void); + +/** + * Opens the select-save panel for the given action, refreshing each row + * from the current SAVE.caches state. callback is invoked exactly once + * with the result, whether a slot was chosen or the panel was backed + * out of without choosing. + * + * UI_SELECT_SAVE_TYPE_LOAD adds a trailing row to switch to + * UI_SELECT_SAVE_TYPE_DELETE in place, and UI_SELECT_SAVE_TYPE_DELETE + * adds a trailing "Cancel" row to switch back to + * UI_SELECT_SAVE_TYPE_LOAD - neither closes the panel or invokes + * callback, they just toggle UI_SELECT_SAVE.type and the slot rows' + * styling. UI_SELECT_SAVE_TYPE_SAVE has no trailing row. + * + * @param type The action the caller is opening the panel for. + * @param callback Called with the result once the panel closes. Cannot + * be NULL. + * @param user Arbitrary pointer passed through to callback. + */ +void uiSelectSaveOpen( + const uiselectsavetype_t type, + uiselectsaveresultcallback_t callback, + void *user +); + +/** + * Closes the select-save panel. No-op when already closed. + */ +void uiSelectSaveClose(void); + +/** + * Disposes of the select-save panel. + * + * @return Any error that occurs. + */ +errorret_t uiSelectSaveDispose(void); diff --git a/src/dusk/ui/uielementlist.c b/src/dusk/ui/uielementlist.c index 8a03db6e..f69e4149 100644 --- a/src/dusk/ui/uielementlist.c +++ b/src/dusk/ui/uielementlist.c @@ -21,6 +21,8 @@ #include "ui/frame/backpack/uibackpack.h" #include "ui/frame/uiconfirm.h" #include "ui/widget/uimodal.h" +#include "ui/widget/uikeyboard.h" +#include "ui/frame/save/uiselectsave.h" #include "ui/rpg/textbox/uitextboxmain.h" #include "ui/rpg/textbox/uitextboxminilist.h" #include "ui/rpg/uiemoji.h" @@ -85,25 +87,33 @@ uielement_t UI_ELEMENTS[] = { // Modals, Popups and Dialogs, above most other UI things. // =========================================================================== { - .init = uiConfirmInit, - .dispose = uiConfirmDispose + .init = uiSelectSaveInit, + .draw = uiSelectSaveDraw, + .dispose = uiSelectSaveDispose + }, + { + .init = uiTextboxMainInit, + .update = uiTextboxMainUpdate, + .draw = uiTextboxMainDraw + }, + { + .init = uiTextboxMiniListInit, + .update = uiTextboxMiniListUpdate, + .draw = uiTextboxMiniListDraw }, { .init = uiModalInit, .draw = uiModalDraw, .dispose = uiModalDispose }, - { - .init = uiTextboxMainInit, - .update = uiTextboxMainUpdate, - .draw = uiTextboxMainDraw + .init = uiConfirmInit, + .dispose = uiConfirmDispose }, - { - .init = uiTextboxMiniListInit, - .update = uiTextboxMiniListUpdate, - .draw = uiTextboxMiniListDraw + .init = uiKeyboardInit, + .draw = uiKeyboardDraw, + .dispose = uiKeyboardDispose }, // =========================================================================== diff --git a/src/dusk/ui/widget/CMakeLists.txt b/src/dusk/ui/widget/CMakeLists.txt index e15d658a..feff8956 100644 --- a/src/dusk/ui/widget/CMakeLists.txt +++ b/src/dusk/ui/widget/CMakeLists.txt @@ -15,6 +15,7 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} uiscrolling.c uiitem.c uiitemlist.c + uikeyboard.c uimenu.c uimodal.c ) diff --git a/src/dusk/ui/widget/uikeyboard.c b/src/dusk/ui/widget/uikeyboard.c new file mode 100644 index 00000000..825d851d --- /dev/null +++ b/src/dusk/ui/widget/uikeyboard.c @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "uikeyboard.h" +#include "util/memory.h" + +uikeyboard_t UI_KEYBOARD; + +errorret_t uiKeyboardInit(void) { + memoryZero(&UI_KEYBOARD, sizeof(uikeyboard_t)); + errorOk(); +} + +errorret_t uiKeyboardDraw(void) { + errorOk(); +} + +errorret_t uiKeyboardDispose(void) { + errorOk(); +} diff --git a/src/dusk/ui/widget/uikeyboard.h b/src/dusk/ui/widget/uikeyboard.h new file mode 100644 index 00000000..780614b4 --- /dev/null +++ b/src/dusk/ui/widget/uikeyboard.h @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "error/error.h" + +typedef struct { + +} uikeyboard_t; + +extern uikeyboard_t UI_KEYBOARD; + +/** + * Initializes the on-screen keyboard. Currently a placeholder -- no + * behavior implemented yet. + * + * @return Any error that occurs. + */ +errorret_t uiKeyboardInit(void); + +/** + * Draws the on-screen keyboard. Currently a placeholder -- no-op. + * + * @return Any error that occurs. + */ +errorret_t uiKeyboardDraw(void); + +/** + * Disposes of the on-screen keyboard. + * + * @return Any error that occurs. + */ +errorret_t uiKeyboardDispose(void); diff --git a/src/dusk/ui/widget/uiscrolling.c b/src/dusk/ui/widget/uiscrolling.c index 168ddc04..516caf00 100644 --- a/src/dusk/ui/widget/uiscrolling.c +++ b/src/dusk/ui/widget/uiscrolling.c @@ -7,9 +7,28 @@ #include "uiscrolling.h" #include "assert/assert.h" +#include "util/memory.h" void uiScrollingInit(uiscrolling_t *scrolling) { assertNotNull(scrolling, "Scrolling container cannot be NULL"); - // Nothing to initialize yet -- uiscrolling_t is currently a - // placeholder with no fields. + memoryZero(scrolling, sizeof(uiscrolling_t)); +} + +void uiScrollingEnsureVisible( + uiscrolling_t *scrolling, + const uint8_t index, + const uint8_t itemCount, + const uint8_t visibleCount +) { + assertNotNull(scrolling, "Scrolling container cannot be NULL"); + assertTrue(visibleCount > 0, "Visible count must be > 0"); + + if(index < scrolling->offset) { + scrolling->offset = index; + } else if(index >= (uint8_t)(scrolling->offset + visibleCount)) { + scrolling->offset = (uint8_t)(index - visibleCount + 1); + } + + uint8_t maxOffset = itemCount > visibleCount ? itemCount - visibleCount : 0; + if(scrolling->offset > maxOffset) scrolling->offset = maxOffset; } diff --git a/src/dusk/ui/widget/uiscrolling.h b/src/dusk/ui/widget/uiscrolling.h index 7c11380f..da47729a 100644 --- a/src/dusk/ui/widget/uiscrolling.h +++ b/src/dusk/ui/widget/uiscrolling.h @@ -9,13 +9,29 @@ #include "error/error.h" typedef struct { - + uint8_t offset; } uiscrolling_t; /** - * Initializes a scrolling container. Currently a placeholder -- no - * scrolling behavior is implemented yet. + * Initializes a scrolling container with a zero offset. * * @param scrolling The scrolling container to initialize. */ void uiScrollingInit(uiscrolling_t *scrolling); + +/** + * Scrolls by the minimum amount needed to bring index within the visible + * window of visibleCount rows, then clamps so the window never scrolls + * past the end of itemCount rows. + * + * @param scrolling The scrolling container to update. + * @param index Row index that must end up visible. + * @param itemCount Total number of rows in the list. + * @param visibleCount Number of rows visible at once. + */ +void uiScrollingEnsureVisible( + uiscrolling_t *scrolling, + const uint8_t index, + const uint8_t itemCount, + const uint8_t visibleCount +);