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 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 14:16:39 -05:00
parent 27b6ddf5cb
commit cb28d2b611
17 changed files with 841 additions and 49 deletions
+22 -5
View File
@@ -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"
+3 -1
View File
@@ -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
+7 -2
View File
@@ -59,9 +59,14 @@ 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) {
+1
View File
@@ -12,3 +12,4 @@ add_subdirectory(game)
add_subdirectory(mainmenu)
add_subdirectory(battle)
add_subdirectory(backpack)
add_subdirectory(save)
+22 -21
View File
@@ -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);
+2 -3
View File
@@ -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;
+10
View File
@@ -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
)
+105
View File
@@ -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();
}
+114
View File
@@ -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
);
+298
View File
@@ -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();
}
+133
View File
@@ -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);
+20 -10
View File
@@ -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
},
// ===========================================================================
+1
View File
@@ -15,6 +15,7 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
uiscrolling.c
uiitem.c
uiitemlist.c
uikeyboard.c
uimenu.c
uimodal.c
)
+24
View File
@@ -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();
}
+37
View File
@@ -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);
+21 -2
View File
@@ -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;
}
+19 -3
View File
@@ -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
);