From 0b4ba062bb174e67af5be1e8e9604fb61e1ae1bc Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Thu, 20 Aug 2026 18:53:47 -0500 Subject: [PATCH] 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 --- assets/locale/en_US.po | 26 ++++++++++++ src/dusk/display/text/text.c | 34 +++++++++++++++ src/dusk/display/text/text.h | 14 +++++++ src/dusk/rpg/cutscene/cutscene.h | 9 +++- src/dusk/rpg/cutscene/item/ui/cutscenemodal.h | 10 +++-- src/dusk/scene/initial/sceneinitial.c | 9 ++-- src/dusk/ui/frame/uimodal.c | 30 +++++++++++-- src/dusk/ui/frame/uimodal.h | 42 +++++++++++++++---- 8 files changed, 153 insertions(+), 21 deletions(-) diff --git a/assets/locale/en_US.po b/assets/locale/en_US.po index 2b7f69e3..05eeb24b 100644 --- a/assets/locale/en_US.po +++ b/assets/locale/en_US.po @@ -5,6 +5,32 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : n==2 ? 1 : (n<7 ? 2 : 3));\n" +msgid "initial.checking_save.title" +msgstr "Checking for save data" + +msgid "initial.checking_save.message" +msgstr "Please wait..." + +msgid "initial.no_device.title" +msgstr "No Save Device Found" + +msgid "initial.no_device.message" +msgstr "Could not find a save device, ensure it is connected and try again. You can continue, but progress will not be saved." + +msgid "initial.no_device.retry" +msgstr "Try again" + +msgid "initial.no_device.continue" +msgstr "Continue without saving" + + + + + + + + + #: ui/menu.c:10 msgid "ui.title" msgstr "" diff --git a/src/dusk/display/text/text.c b/src/dusk/display/text/text.c index aea07b96..fb83bc3d 100644 --- a/src/dusk/display/text/text.c +++ b/src/dusk/display/text/text.c @@ -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; + } + } +} diff --git a/src/dusk/display/text/text.h b/src/dusk/display/text/text.h index d79a60b4..d62c9e6b 100644 --- a/src/dusk/display/text/text.h +++ b/src/dusk/display/text/text.h @@ -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); diff --git a/src/dusk/rpg/cutscene/cutscene.h b/src/dusk/rpg/cutscene/cutscene.h index dfe02354..81966305 100644 --- a/src/dusk/rpg/cutscene/cutscene.h +++ b/src/dusk/rpg/cutscene/cutscene.h @@ -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, \ diff --git a/src/dusk/rpg/cutscene/item/ui/cutscenemodal.h b/src/dusk/rpg/cutscene/item/ui/cutscenemodal.h index c1ff265b..7d843e41 100644 --- a/src/dusk/rpg/cutscene/item/ui/cutscenemodal.h +++ b/src/dusk/rpg/cutscene/item/ui/cutscenemodal.h @@ -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; diff --git a/src/dusk/scene/initial/sceneinitial.c b/src/dusk/scene/initial/sceneinitial.c index b9e4baad..42d618b4 100644 --- a/src/dusk/scene/initial/sceneinitial.c +++ b/src/dusk/scene/initial/sceneinitial.c @@ -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"), diff --git a/src/dusk/ui/frame/uimodal.c b/src/dusk/ui/frame/uimodal.c index 981b944d..d070a347 100644 --- a/src/dusk/ui/frame/uimodal.c +++ b/src/dusk/ui/frame/uimodal.c @@ -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); diff --git a/src/dusk/ui/frame/uimodal.h b/src/dusk/ui/frame/uimodal.h index b496425e..49c51d0f 100644 --- a/src/dusk/ui/frame/uimodal.h +++ b/src/dusk/ui/frame/uimodal.h @@ -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 +);