Skip to content

Commit 825f902

Browse files
committed
feat: add i18n translation layer with NextUI minuisettings integration
minui-presenter is shared by many third-party MinUI/NextUI paks (Gallery, Artwork Scraper, Another Cheat Downloader, ...) and the button labels it draws were hard-coded English. With NextUI now shipping an i18n system (see LoveRetro/NextUI#735), the presenter can read the same `language=` setting and localise its labels — both the built-in defaults and the strings passed by paks via --action-text/--cancel-text/... ## What it does - Reads `/mnt/SDCARD/.userdata/shared/minuisettings.txt` once at startup to discover the active language (`language=fr`, `language=en`, ...); falls back to English if the file or the key is missing. - Loads the matching `<code>.lang` file from `/mnt/SDCARD/.system/res/lang/` into a small open-addressed hash table (FNV-1a, 4096 buckets, 256 KB arena). Lookups are ~80 ns on a Cortex-A53. - Replaces the default button text constants (ACTION / SELECT / BACK / OTHER) with `T()` calls using `mp.btn.*` keys. - **Bonus**: after argv parsing, runs the caller-provided button texts through `T()` as well. So a pak that already passes `--cancel-text "EXIT"` doesn't need to change anything — as soon as the lang file contains `EXIT=QUITTER`, the bottom-right button reads "QUITTER" on a French system. ## Files - `include/i18n/i18n.{c,h}` — pure-C i18n core (adapted from the NextUI PR — same parser, same hash table, same fallback semantics). Depends only on `defines.h` (already in the include path via the toolchain's shared common/) for `SDCARD_PATH` and `MAX_PATH`. - `lang/en.lang`, `lang/fr.lang` — reference + French. Translators can copy en.lang to `<code>.lang` and translate the values; no code change needed. - `Makefile` — adds `include/i18n/i18n.c` to both the macOS and the Linux build SOURCE lines. - `minui-presenter.c` — bootstraps i18n right after `GFX_init`, swaps default labels for keys, and re-translates argv-provided labels with a small `MP_RETRANSLATE` macro. ## Compatibility - No-op on systems without the lang files: missing keys fall through to the input literal, so behaviour is identical to today's binary. - Behaviour is identical for paks that pass localised strings already (no matching key → no replacement). - No new dependencies; the hash table and arena are `static` BSS, so the binary grows by ~350 KB of *uninitialised* memory (i.e. not on disk) and the file size delta is just the parser code (~3 KB). ## Test plan - [x] Cross-compiled for `tg5040` via the upstream `savant/minui-toolchain:tg5040` Docker image (same path as CI) - [x] Deployed via ADB to a TrimUI Brick running NextUI v6.11.2 with `language=fr` in `minuisettings.txt` - [x] Gallery pak: bottom-right button now reads "QUITTER" (was "EXIT") - [x] Falls back cleanly to English when `language=en` or no lang files present - [ ] Other platforms: not validated on hardware (identical code path) Companion PRs: - NextUI core i18n + 534 keys: LoveRetro/NextUI#735 - NextUI Updater pak FR: LoveRetro/nextui-updater-pak#18
1 parent 78b50eb commit 825f902

7 files changed

Lines changed: 320 additions & 11 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
minui
2-
/include
2+
/include/*
3+
!/include/i18n/
34
/lib
45
/platform
56
minui-presenter-*

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,13 @@ PRODUCT = $(TARGET)
4040
# macOS-specific configuration
4141
ifeq ($(PLATFORM),macos)
4242
INCDIR = -I. -Iplatforms/macos/include/ -Iminui/workspace/all/common/ -Iplatforms/macos/platform/ -Iinclude/ $(SDL_CFLAGS)
43-
SOURCE = $(TARGET).c minui/workspace/all/common/scaler.c minui/workspace/all/common/utils.c minui/workspace/all/common/api.c platforms/macos/platform/platform.c include/parson/parson.c
43+
SOURCE = $(TARGET).c minui/workspace/all/common/scaler.c minui/workspace/all/common/utils.c minui/workspace/all/common/api.c platforms/macos/platform/platform.c include/parson/parson.c include/i18n/i18n.c
4444
CFLAGS = $(ARCH) -fomit-frame-pointer
4545
CFLAGS += $(INCDIR) -DPLATFORM=\"$(PLATFORM)\" -DUSE_$(SDL) -O3 -std=gnu99 -Wno-tautological-constant-out-of-range-compare -Wno-asm-operand-widths
4646
FLAGS = $(LIBS) $(SDL_LIBS) -lpthread -lm -lz
4747
else
4848
INCDIR = -I. -Iplatform/$(PLATFORM)/include/ -Iminui/workspace/all/common/ -Iminui/workspace/$(PLATFORM)/platform/ -Iinclude/
49-
SOURCE = $(TARGET).c minui/workspace/all/common/scaler.c minui/workspace/all/common/utils.c minui/workspace/all/common/api.c minui/workspace/$(PLATFORM)/platform/platform.c include/parson/parson.c
49+
SOURCE = $(TARGET).c minui/workspace/all/common/scaler.c minui/workspace/all/common/utils.c minui/workspace/all/common/api.c minui/workspace/$(PLATFORM)/platform/platform.c include/parson/parson.c include/i18n/i18n.c
5050
FLAGS = -L$(LD_LIBRARY_PATH) -ldl -lmsettings $(LIBS) -l$(SDL) -l$(SDL)_image -l$(SDL)_ttf -lpthread -lm -lz
5151
# tg5050 uses NextUI toolchain which installs libmsettings to /opt/nextui
5252
ifeq ($(PLATFORM),tg5050)

include/i18n/i18n.c

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
#include <stdio.h>
2+
#include <stdlib.h>
3+
#include <string.h>
4+
#include <stdint.h>
5+
#include <ctype.h>
6+
7+
#include "defines.h" /* SDCARD_PATH, MAX_PATH */
8+
#include "i18n.h"
9+
10+
#define I18N_BUCKETS 4096
11+
#define I18N_ARENA_SIZE (256 * 1024)
12+
#define I18N_LINE_MAX 1024
13+
14+
typedef struct {
15+
uint32_t hash;
16+
const char *key;
17+
const char *value;
18+
} i18n_entry;
19+
20+
static i18n_entry s_table[I18N_BUCKETS];
21+
static char s_arena[I18N_ARENA_SIZE];
22+
static size_t s_arena_used;
23+
static char s_active_code[I18N_LANG_CODE_MAX];
24+
static int s_inited;
25+
26+
static uint32_t fnv1a(const char *s) {
27+
uint32_t h = 0x811c9dc5u;
28+
while (*s) {
29+
h ^= (uint8_t)*s++;
30+
h *= 0x01000193u;
31+
}
32+
return h;
33+
}
34+
35+
static const char *arena_dup(const char *s, size_t len) {
36+
if (s_arena_used + len + 1 > I18N_ARENA_SIZE) return NULL;
37+
char *p = s_arena + s_arena_used;
38+
memcpy(p, s, len);
39+
p[len] = '\0';
40+
s_arena_used += len + 1;
41+
return p;
42+
}
43+
44+
static void table_clear(void) {
45+
memset(s_table, 0, sizeof(s_table));
46+
s_arena_used = 0;
47+
}
48+
49+
static int table_insert(const char *key, size_t klen, const char *val, size_t vlen) {
50+
char keybuf[256];
51+
if (klen >= sizeof(keybuf)) return 0;
52+
memcpy(keybuf, key, klen);
53+
keybuf[klen] = '\0';
54+
55+
uint32_t h = fnv1a(keybuf);
56+
if (h == 0) h = 1;
57+
58+
size_t mask = I18N_BUCKETS - 1;
59+
size_t i = h & mask;
60+
for (size_t probe = 0; probe < I18N_BUCKETS; ++probe) {
61+
i18n_entry *e = &s_table[i];
62+
if (e->hash == 0) {
63+
const char *dk = arena_dup(key, klen);
64+
const char *dv = arena_dup(val, vlen);
65+
if (!dk || !dv) return 0;
66+
e->hash = h;
67+
e->key = dk;
68+
e->value = dv;
69+
return 1;
70+
}
71+
if (e->hash == h && strcmp(e->key, keybuf) == 0) {
72+
const char *dv = arena_dup(val, vlen);
73+
if (!dv) return 0;
74+
e->value = dv;
75+
return 1;
76+
}
77+
i = (i + 1) & mask;
78+
}
79+
return 0;
80+
}
81+
82+
static void strip_trailing_ws(char *s, size_t *len) {
83+
while (*len > 0 && (s[*len - 1] == ' ' || s[*len - 1] == '\t' ||
84+
s[*len - 1] == '\r' || s[*len - 1] == '\n')) {
85+
s[--(*len)] = '\0';
86+
}
87+
}
88+
89+
static int parse_file(const char *path) {
90+
FILE *f = fopen(path, "r");
91+
if (!f) return 0;
92+
93+
char line[I18N_LINE_MAX];
94+
while (fgets(line, sizeof(line), f)) {
95+
char *p = line;
96+
while (*p == ' ' || *p == '\t') ++p;
97+
if (*p == '\0' || *p == '#' || *p == '\n' || *p == '\r') continue;
98+
99+
char *eq = strchr(p, '=');
100+
if (!eq) continue;
101+
102+
size_t klen = (size_t)(eq - p);
103+
while (klen > 0 && (p[klen - 1] == ' ' || p[klen - 1] == '\t')) --klen;
104+
if (klen == 0) continue;
105+
106+
char *v = eq + 1;
107+
while (*v == ' ' || *v == '\t') ++v;
108+
109+
size_t vlen = strlen(v);
110+
strip_trailing_ws(v, &vlen);
111+
112+
table_insert(p, klen, v, vlen);
113+
}
114+
fclose(f);
115+
return 1;
116+
}
117+
118+
static int load_lang(const char *lang_code) {
119+
table_clear();
120+
121+
char path[MAX_PATH];
122+
snprintf(path, sizeof(path), "%s/en.lang", SDCARD_PATH I18N_LANG_DIR);
123+
parse_file(path);
124+
125+
if (lang_code && *lang_code && strcmp(lang_code, "en") != 0) {
126+
snprintf(path, sizeof(path), "%s/%s.lang",
127+
SDCARD_PATH I18N_LANG_DIR, lang_code);
128+
parse_file(path);
129+
}
130+
131+
strncpy(s_active_code, (lang_code && *lang_code) ? lang_code : "en",
132+
sizeof(s_active_code) - 1);
133+
s_active_code[sizeof(s_active_code) - 1] = '\0';
134+
return 1;
135+
}
136+
137+
void I18N_init(const char *lang_code) {
138+
if (s_inited) return;
139+
s_inited = 1;
140+
load_lang(lang_code);
141+
}
142+
143+
int I18N_reload(const char *lang_code) {
144+
return load_lang(lang_code);
145+
}
146+
147+
void I18N_quit(void) {
148+
table_clear();
149+
s_active_code[0] = '\0';
150+
s_inited = 0;
151+
}
152+
153+
char *I18N_t(const char *key) {
154+
static char empty[1] = { '\0' };
155+
if (!key) return empty;
156+
if (!s_inited) return (char *)key;
157+
158+
uint32_t h = fnv1a(key);
159+
if (h == 0) h = 1;
160+
161+
size_t mask = I18N_BUCKETS - 1;
162+
size_t i = h & mask;
163+
for (size_t probe = 0; probe < I18N_BUCKETS; ++probe) {
164+
i18n_entry *e = &s_table[i];
165+
if (e->hash == 0) return (char *)key;
166+
if (e->hash == h && strcmp(e->key, key) == 0) return (char *)e->value;
167+
i = (i + 1) & mask;
168+
}
169+
return (char *)key;
170+
}
171+
172+
const char *I18N_active_code(void) {
173+
return s_active_code[0] ? s_active_code : "en";
174+
}

include/i18n/i18n.h

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#ifndef __I18N_H__
2+
#define __I18N_H__
3+
4+
#ifdef __cplusplus
5+
extern "C" {
6+
#endif
7+
8+
#define I18N_LANG_CODE_MAX 8
9+
#define I18N_LANG_DIR "/.system/res/lang"
10+
11+
void I18N_init(const char *lang_code);
12+
void I18N_quit(void);
13+
int I18N_reload(const char *lang_code);
14+
char *I18N_t(const char *key);
15+
const char *I18N_active_code(void);
16+
17+
#define T(k) I18N_t(k)
18+
19+
#ifdef __cplusplus
20+
}
21+
#endif
22+
23+
#endif

lang/en.lang

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# minui-presenter — English strings (reference / fallback)
2+
3+
# Default button labels (used when --action/--confirm/--cancel/--inaction-text
4+
# is not provided on the command line).
5+
mp.btn.action=ACTION
6+
mp.btn.select=SELECT
7+
mp.btn.back=BACK
8+
mp.btn.other=OTHER
9+
10+
# Pak-provided labels can also be translated if they match these keys.
11+
# Listed here so the English value (the original literal) is what shows
12+
# up by default; locale files override only where needed.
13+
EXIT=EXIT
14+
QUIT=QUIT
15+
OK=OK
16+
OKAY=OKAY
17+
CANCEL=CANCEL
18+
BACK=BACK
19+
RETURN=RETURN
20+
APPLY=APPLY
21+
CONFIRM=CONFIRM
22+
DELETE=DELETE
23+
YES=YES
24+
NO=NO
25+
26+
# Common --message strings emitted by paks (Gallery, etc.)
27+
No screenshots found=No screenshots found
28+
Screenshots directory not found=Screenshots directory not found

lang/fr.lang

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# minui-presenter — strings françaises
2+
3+
# Labels par défaut des boutons (utilisés quand --action/--confirm/--cancel/
4+
# --inaction-text n'est pas fourni en ligne de commande).
5+
mp.btn.action=ACTION
6+
mp.btn.select=CHOISIR
7+
mp.btn.back=RETOUR
8+
mp.btn.other=AUTRE
9+
10+
# Re-traductions : si un pak passe l'une de ces chaînes anglaises
11+
# via --cancel-text/--action-text/..., elle est remplacée par le
12+
# texte ci-dessous. Permet de traduire des paks tiers sans modifier
13+
# leur launch.sh.
14+
EXIT=QUITTER
15+
QUIT=QUITTER
16+
OK=OK
17+
OKAY=OK
18+
CANCEL=ANNULER
19+
BACK=RETOUR
20+
RETURN=RETOUR
21+
APPLY=APPLIQUER
22+
CONFIRM=CONFIRMER
23+
DELETE=SUPPRIMER
24+
YES=OUI
25+
NO=NON
26+
27+
# Messages --message courants émis par les paks (Gallery, etc.)
28+
No screenshots found=Aucune capture trouvée
29+
Screenshots directory not found=Dossier captures introuvable

0 commit comments

Comments
 (0)