Translate and word-wrap cutscene modal text

uiModalOpen now resolves title/message/option strings as locale
message IDs first, falling back to the literal string if no match is
found, and word-wraps the result to stay within the screen's scan
width via the new shared textWrap helper. sceneinitial.c's no-device
modal now uses locale keys instead of hardcoded English strings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 18:53:47 -05:00
parent 28f5e66662
commit 0b4ba062bb
8 changed files with 153 additions and 21 deletions
+34
View File
@@ -187,3 +187,37 @@ int32_t textMeasure(
return spriteCount;
}
void textWrap(char_t *text, const font_t *font, const float_t maxWidth) {
assertNotNull(text, "Text cannot be NULL");
assertNotNull(font, "Font cannot be NULL");
float_t fontWidth = (float_t)font->tileset->tileWidth;
if(fontWidth <= 0.0f) return;
int32_t charsPerLine = (int32_t)(maxWidth / fontWidth);
if(charsPerLine <= 0) return;
int32_t lineWidth = 0;
int32_t lastSpace = -1;
for(int32_t i = 0; text[i] != '\0'; i++) {
if(text[i] == '\n') {
lineWidth = 0;
lastSpace = -1;
continue;
}
if(text[i] == ' ') {
lastSpace = i;
}
lineWidth++;
if(lineWidth > charsPerLine && lastSpace != -1) {
text[lastSpace] = '\n';
lineWidth = i - lastSpace;
lastSpace = -1;
}
}
}
+14
View File
@@ -104,3 +104,17 @@ int32_t textMeasure(
int32_t *outWidth,
int32_t *outHeight
);
/**
* Word-wraps text in place for display at up to maxWidth pixels wide,
* by replacing the space nearest each overflow point with a newline.
* Length is unchanged - this only ever swaps existing spaces for
* newlines, never inserts characters - so it's always safe to call on a
* fixed-size buffer. A single word wider than maxWidth on its own is
* left unbroken.
*
* @param text Null-terminated, caller-owned buffer to wrap in place.
* @param font Font to measure character width with.
* @param maxWidth Maximum line width, in pixels.
*/
void textWrap(char_t *text, const font_t *font, const float_t maxWidth);
+7 -2
View File
@@ -89,6 +89,9 @@ typedef struct cutscene_s {
// the dialog to be dismissed. Script the rest of the interaction (e.g.
// CUTSCENE_CALLBACK to kick off work, CUTSCENE_WAIT, then
// CUTSCENE_MODAL_CLOSE) as later items in the same cutscene.
// TITLE and MESSAGE are each displayed as-is unless they match a
// locale message ID, in which case the translated string is shown
// instead - see uiModalLocalize.
#define CUTSCENE_MODAL(TITLE, MESSAGE) \
{ \
.type = CUTSCENE_ITEM_TYPE_MODAL, \
@@ -100,8 +103,10 @@ typedef struct cutscene_s {
// CALLBACK fires with the selected option index (or UI_MODAL_RESULT_NONE
// if backed out of) once the dialog closes. Option labels are passed as
// trailing arguments, e.g. CUTSCENE_MODAL_OPTIONS(title, message,
// callback, "Retry", "Cancel") - their strings are not copied, so they
// must outlive the modal (string literals are fine).
// callback, "Retry", "Cancel") - their strings are not copied by this
// item, so they must stay valid until the modal opens (string literals
// are fine). Like TITLE and MESSAGE, each option is translated if it
// matches a locale message ID.
#define CUTSCENE_MODAL_OPTIONS(TITLE, MESSAGE, CALLBACK, ...) \
{ \
.type = CUTSCENE_ITEM_TYPE_MODAL, \
@@ -28,12 +28,16 @@ typedef void (*cutscenemodaloptioncallback_t)(
);
typedef struct {
// Display text, or a locale message ID to translate - see
// uiModalLocalize.
char_t title[CUTSCENE_MODAL_TITLE_MAX_CHARS];
char_t message[CUTSCENE_MODAL_MESSAGE_MAX_CHARS];
// NOT copied - these pointers are stored directly by the underlying
// buttons, so they must stay valid for as long as the modal is open
// (e.g. string literals, as CUTSCENE_MODAL_OPTIONS produces).
// NOT copied by this struct - these pointers are only read once,
// while uiModalOpen is opening the modal in cutsceneModalStart, so
// they only need to stay valid until then (e.g. string literals, as
// CUTSCENE_MODAL_OPTIONS produces). Each is displayed text or a
// locale message ID, same as title/message.
const char_t **options;
uint8_t optionCount;
+4 -5
View File
@@ -22,8 +22,7 @@ void sceneInitialSaveDeviceRetryCallback(const uint8_t opt, void *u);
CUTSCENE(INITIAL, 0, DEFAULT,
CUTSCENE_MODAL(
"Checking for save data...",
"Please wait."
"initial.checking_save.title", "initial.checking_save.message"
),
CUTSCENE_WAIT(1.0f),
CUTSCENE_CALLBACK(sceneInitialFindDevices),
@@ -31,10 +30,10 @@ CUTSCENE(INITIAL, 0, DEFAULT,
CUTSCENE_MARKER("NO_DEVICE"),
CUTSCENE_MODAL_CLOSE(),
CUTSCENE_MODAL_OPTIONS(
"No save device found",
"Please ensure a save device is connected and try again.",
"initial.no_device.title",
"initial.no_device.message",
sceneInitialSaveDeviceRetryCallback,
"Retry", "Continue without saving"
"initial.no_device.retry", "initial.no_device.continue"
),
CUTSCENE_MARKER("RETRY"),
+27 -3
View File
@@ -17,6 +17,8 @@
#include "display/spritebatch/spritebatch.h"
#include "display/texture/texture.h"
#include "display/shader/shaderunlit.h"
#include "locale/localemanager.h"
#include "asset/loader/locale/assetlocaleloader.h"
#define UI_MODAL_BACKDROP_COLOR color4b(0, 0, 0, 160)
@@ -119,6 +121,20 @@ errorret_t uiModalDraw(void) {
errorOk();
}
void uiModalLocalize(
const char_t *key,
char_t *buffer,
const size_t bufferSize
) {
errorret_t result = assetLocaleGetString(
&LOCALE.entry->data.locale, key, 0, buffer, bufferSize
);
if(errorIsNotOk(result)) {
errorCatch(result);
stringCopy(buffer, key, bufferSize - 1);
}
}
bool_t uiModalIsOpen(void) {
return UI_MODAL.open;
}
@@ -146,11 +162,16 @@ void uiModalOpen(
optionCount <= UI_MODAL_OPTIONS_MAX, "Too many options for modal"
);
stringCopy(UI_MODAL.titleText, title, UI_MODAL_TITLE_TEXT_MAX - 1);
float_t maxContentWidth = ((float_t)SCREEN.scanWidth * UI_MODAL_MAX_WIDTH_RATIO)
- (UI_FRAME_START_X * 2.0f);
uiModalLocalize(title, UI_MODAL.titleText, UI_MODAL_TITLE_TEXT_MAX);
textWrap(UI_MODAL.titleText, &FONT_DEFAULT, maxContentWidth);
UI_MODAL.titleLabel.dirty = true;
uiLabelRebuffer(&UI_MODAL.titleLabel);
stringCopy(UI_MODAL.messageText, message, UI_MODAL_MESSAGE_TEXT_MAX - 1);
uiModalLocalize(message, UI_MODAL.messageText, UI_MODAL_MESSAGE_TEXT_MAX);
textWrap(UI_MODAL.messageText, &FONT_DEFAULT, maxContentWidth);
UI_MODAL.messageLabel.dirty = true;
uiLabelRebuffer(&UI_MODAL.messageLabel);
@@ -165,7 +186,10 @@ void uiModalOpen(
&UI_MODAL.menu, UI_MODAL.options, uiModalSelected, uiModalClosed, NULL
);
for(uint8_t i = 0; i < optionCount; i++) {
MENU_BUTTON(options[i]);
uiModalLocalize(
options[i], UI_MODAL.optionsText[i], UI_MODAL_OPTION_TEXT_MAX
);
MENU_BUTTON(UI_MODAL.optionsText[i]);
}
MENU_END(UI_MODAL.options, menuIndex);
+34 -8
View File
@@ -15,7 +15,12 @@
#define UI_MODAL_MESSAGE_TEXT_MAX 256
#define UI_MODAL_MESSAGE_SPRITES_MAX UI_MODAL_MESSAGE_TEXT_MAX
#define UI_MODAL_OPTIONS_MAX 4
#define UI_MODAL_OPTION_TEXT_MAX 32
#define UI_MODAL_MIN_WIDTH 160.0f
// Title/message text wraps at word boundaries to stay within this
// fraction of the screen's scan width, so long (e.g. translated)
// strings don't run off-screen.
#define UI_MODAL_MAX_WIDTH_RATIO 0.8f
#define UI_MODAL_RESULT_NONE 0xFF
/**
@@ -54,6 +59,7 @@ typedef struct {
uimenu_t menu;
uimenuitem_t options[UI_MODAL_OPTIONS_MAX];
char_t optionsText[UI_MODAL_OPTIONS_MAX][UI_MODAL_OPTION_TEXT_MAX];
uimodaloptioncallback_t callback;
uimodalopenedcallback_t onOpen;
@@ -99,14 +105,19 @@ uint8_t uiModalGetResult(void);
* callback is invoked exactly once with the result, whether the dialog
* was dismissed by selecting an option or by pressing cancel/back.
*
* @param title Display title; copied internally, safe to be transient.
* @param message Display message; copied internally, safe to be
* transient.
* @param options Array of option label strings; NOT copied internally,
* the pointers are stored directly by the underlying buttons, so they
* must remain valid for as long as the modal is open (e.g. string
* literals or locale-owned strings). May be NULL for a message-only
* dialog with no option buttons, in which case optionCount must be 0.
* title, message, and each option are treated as locale message IDs
* first: if one matches an entry in the active locale file its
* translated string is used, otherwise the string itself is displayed
* verbatim. See uiModalLocalize.
*
* @param title Display title, or a locale message ID; copied
* internally, safe to be transient.
* @param message Display message, or a locale message ID; copied
* internally, safe to be transient.
* @param options Array of option label strings/locale message IDs;
* copied internally, safe to be transient. May be NULL for a
* message-only dialog with no option buttons, in which case optionCount
* must be 0.
* @param optionCount Number of options, from 0 to UI_MODAL_OPTIONS_MAX.
* @param callback Called with the result once the dialog closes. May be
* NULL.
@@ -139,3 +150,18 @@ void uiModalClose(uimodalclosedcallback_t callback);
* @return Any error that occurs.
*/
errorret_t uiModalDispose(void);
/**
* Resolves display text for uiModalOpen: if key matches a locale
* message ID in the active locale file, buffer is filled with its
* translated string, otherwise key is copied into buffer verbatim.
*
* @param key Display string or locale message ID.
* @param buffer Destination buffer.
* @param bufferSize Size of buffer, in bytes.
*/
void uiModalLocalize(
const char_t *key,
char_t *buffer,
const size_t bufferSize
);