sdk(ui): 字符串资源外置(i18n 雏形 §13)—— meow_str_* + 内嵌双语表 + TF 覆盖表 + 控件接入

公开 SDK 前把 SDK/App 硬编码文案外置:新增 meow_str/meow_strf/meow_str_set_locale/
meow_str_locale,按当前 locale 取串(永不 NULL),第三方开发者可做多语言。

- 新增 include/meow_ui_str.h + meow_ui_str.c:取串优先级 = TF 覆盖表 > 内嵌当前语言
  (en_us→en 其余→zh) > 内嵌内置默认(zh_cn) > 原样返回 id。内嵌双语表(common.* 21 条 +
  ime.* 9 条);热路径零 malloc(编译期静态表 + ≤256 静态覆盖数组);纯 libc 宿主可单测。
- locale 存系统设置 settings.conf 的 ui.locale(默认 zh_cn,首次调用惰性读取);
  meow_str_set_locale 即时切换 + 重载覆盖表 + best-effort 持久化(流式改写保留其它行)。
- TF 覆盖表 MeowData/<pkg>/strings/<locale>.txt(id=text 行,UTF-8,'=' 两侧去空白);
  无卡/无文件/无该 id → 回落内嵌。meow_strf 用调用方缓冲(snprintf 语义,重入安全)。
- SDK 控件接入:dialog 默认按钮 OK/CANCEL + INPUT 占位、IME 键面/徽标(空格/确定/拼英/
  ?123/清/拼音/(无候选)/中)改走 meow_str;空态/加载态给 App 提供 common.no_data/loading。
- 测试:新增 test_meow_ui_str.c(内嵌回退/TF 覆盖优先级/未知 id 回退/切 locale 即时生效/
  查找正确性/meow_strf,tmp+env 两模式);全量宿主回归(widgets/dispatch/box/anim/scroll/
  listvirt/theme/ime_fix/pinyin/overlay/notif/sdk_input_keys/p1p2p3/config/vol/sb_collapse/
  notify_*) 0 回归;交叉 zig-mipsel -Wall -Wextra 0 warning。
- 文档:MEOW_APP_SDK.md 新增 §13(字符串资源/i18n,遗留顺延为 §14);docs/APP_DEV_GUIDE.md
  新增 §7.11。各宿主 test_*.c 头注释补 meow_ui_str.c 编译依赖。

边界(§13.7):仅 zh_cn/en_us 整串取用;不做复数规则 / BiDi / RTL / 动态字体回退(雏形)。
This commit is contained in:
AskaEth committed 2026-09-02 02:37:38 +08:00
1 parent d7bf62f448
commit 54857bc0fd
23 files changed
+615 -29

No files matched your search

+288
View File
@@ -0,0 +1,288 @@
/*
* meow_ui_str.c — SDK 字符串资源框架(i18n 雏形,§13.meow_str_*)。
*
* 背景:SDK/系统 UI 文案此前全部硬编码在 C 源码(dialog 的 "OK/CANCEL"、IME 的 "空格/确定/
* 拼音/(无候选)" 等),第三方开发者无法按语言取串,公开 SDK 前必须外置。本 TU 提供
* 「按 locale 取整串」的最小框架:内嵌双语默认表(编译进 meow_ui.a,必可用)+ TF 卡覆盖表
* (MeowData/<pkg>/strings/<locale>.txt,开发者可加壳不改二进制)+ locale 存系统设置
* settings.conf 的 `ui.locale`(默认 zh_cn)。
*
* 取值优先级(meow_str,四层,逐层回落,永不返回 NULL):
* ① 当前 locale 的 TF 覆盖表(meow_ui_str_set_locale 加载,静态数组,≤256 条)
* ② 内嵌表中当前语言(en_us→en,其余→zh)
* ③ 内嵌表中内置默认(zh_cn,即 locale 缺省/未知语言时回退)
* ④ 仍不可得 → 原样返回 id(调用方可展示 id / 打点,绝不见 NULL)
*
* 设计要点:
* - 零 malloc 热路径:内嵌为编译期静态表;覆盖表为启动/切 locale 时的静态数组,
* meow_str 只做线性扫描(表 ≤256,O(n) 可接受),不申请任何堆。
* - 纯 libc:不依赖其它 SDK TU,宿主 gcc 直接可单测(同 meow_ui_config.c / meow_ui_vol.c)。
* - locale 初值惰性读 settings.conf ui.locale(默认 zh_cn);meow_str_set_locale 即时生效。
* - 覆盖表解析兼容 "key = value"('=' 两侧去空白),忽略空行/无 '=' 行;UTF-8 原样透传。
* - 幂等:settings 只读一次;覆盖表按需加载。locale 修改须在 App 主线程(防并发写覆盖表)。
*
* 边界(§13 明示不做):无复数规则/BiDi/RTL/动态字体回退,理由见 meow_ui_str.h。
*/
#include "meow_ui.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>
#define MEOW_STR_SETTINGS_FILE "/usr/data/meow/settings.conf" /* 板载系统设置(与 theme/vol 同源) */
#define MEOW_STR_LOCALE_MAX 16
#define MEOW_STR_OVERRIDE_MAX 256 /* 覆盖表上限(§13 性能约束 ≤256) */
#define MEOW_STR_OVR_ID_MAX 64
#define MEOW_STR_OVR_TEXT_MAX 128
#define MEOW_STR_PATH_MAX 384
/* ---- 内嵌双语默认表(编译进二进制,必可用;zh = 内置默认)----
* id 规范:`域.名`(common.* 为 SDK 自带通用组;ime.* 为 SDK 自带 IME 键面组;
* 第三方 App 可自建 域(如 player.pause)经 TF 覆盖表追加,不改 SDK 二进制)。 */
typedef struct meow_str_embed {
const char *id;
const char *zh;
const char *en;
} meow_str_embed;
static const meow_str_embed g_embed[] = {
/* common.*:通用 UI(dialog 默认按钮 / 空态占位可复用) */
{ "common.ok", "确定", "OK" },
{ "common.cancel", "取消", "Cancel" },
{ "common.back", "返回", "Back" },
{ "common.confirm", "确认", "Confirm" },
{ "common.done", "完成", "Done" },
{ "common.save", "保存", "Save" },
{ "common.delete", "删除", "Delete" },
{ "common.edit", "编辑", "Edit" },
{ "common.search", "搜索", "Search" },
{ "common.input", "输入", "Input" },
{ "common.please_input", "请输入", "Please input" },
{ "common.select", "请选择", "Select" },
{ "common.loading", "加载中...", "Loading..." },
{ "common.no_data", "暂无数据", "No data" },
{ "common.retry", "重试", "Retry" },
{ "common.failed", "失败", "Failed" },
{ "common.retry_fail", "重试失败", "Retry failed" },
{ "common.close", "关闭", "Close" },
{ "common.unknown", "未知", "Unknown" },
{ "common.settings", "设置", "Settings" },
{ "common.tap_ok_edit", "点击确定编辑", "tap OK to edit..." },
/* ime.*:SDK 内置 IME 键面/徽标(meow_ui_ime.c 接入) */
{ "ime.space", "空格", "Space" },
{ "ime.enter", "确定", "OK" },
{ "ime.esc", "取消", "Cancel" },
{ "ime.switch", "拼/英", "PY/EN" },
{ "ime.symbol", "?123", "?123" },
{ "ime.clear", "清", "Clear" },
{ "ime.pinyin_mode", "拼音", "Pinyin" },
{ "ime.no_candidate", "(无候选)", "(none)" },
{ "ime.to_pinyin", "中", "中" },
};
#define MEOW_STR_EMBED_N (sizeof(g_embed) / sizeof(g_embed[0]))
/* ---- 运行时状态(静态,零 malloc)---- */
static char g_locale[MEOW_STR_LOCALE_MAX] = "zh_cn";
static int g_locale_loaded = 0; /* 1 = 已从 settings 读过一次 */
static int g_override_loaded = 0; /* 1 = 覆盖表已按当前 locale 加载 */
/* 覆盖表(当前 locale):线性静态数组(id/text 各自定长),≤ MEOW_STR_OVERRIDE_MAX。 */
static char g_ov_id[MEOW_STR_OVERRIDE_MAX][MEOW_STR_OVR_ID_MAX];
static char g_ov_text[MEOW_STR_OVERRIDE_MAX][MEOW_STR_OVR_TEXT_MAX];
static int g_ov_n = 0;
/* ---- settings.conf 读(同 meow_ui_theme.c get_setting_str 口径:'=' 切分,精确匹配 key)----
* 返回 0=命中并把值拷入 buf;-1=未命中/文件不可读/超长。 */
static int str_setting_get(const char *key, char *buf, int buflen)
{
FILE *f = fopen(MEOW_STR_SETTINGS_FILE, "r");
if (!f || buflen <= 0) return -1;
char line[160];
int kl = (int)strlen(key);
int found = 0;
while (fgets(line, sizeof line, f)) {
char *eq = strchr(line, '=');
if (!eq) continue;
char *k = line;
while (*k == ' ' || *k == '\t') k++;
int n = (int)(eq - k);
while (n > 0 && (k[n-1] == ' ' || k[n-1] == '\t')) n--;
if (n != kl || strncmp(k, key, (size_t)kl) != 0) continue;
const char *v = eq + 1;
while (*v == ' ' || *v == '\t') v++;
int m = 0;
while (*v && *v != '\n' && *v != '\r' && m < buflen - 1) buf[m++] = *v++;
while (m > 0 && (buf[m-1] == ' ' || buf[m-1] == '\t')) m--;
buf[m] = 0;
found = 1;
break;
}
fclose(f);
return found ? 0 : -1;
}
/* ---- settings.conf 写一个字符串键(best-effort):流式拷贝到 .tmp,命中键行改写为
* "key = value"(launcher 落盘格式),键缺失则末尾追加;rename 原子替换。保留其余注释/设置,
* 绝不 O_TRUNC 整文件(同 meow_ui_vol_set 思路)。失败静默返回 -1(读不改状态、不崩)。 */
static int str_setting_set(const char *key, const char *val)
{
if (!key || !key[0] || !val) return -1;
FILE *in = fopen(MEOW_STR_SETTINGS_FILE, "r");
char tmp[MEOW_STR_PATH_MAX];
int kl = (int)strlen(key);
snprintf(tmp, sizeof tmp, "%s.tmp", MEOW_STR_SETTINGS_FILE);
FILE *out = fopen(tmp, "w");
if (!out) { if (in) fclose(in); return -1; }
int found = 0;
char line[256];
while (in && fgets(line, sizeof line, in)) {
char *eq = strchr(line, '=');
char *k = line;
while (*k == ' ' || *k == '\t') k++;
int n = eq ? (int)(eq - k) : 0;
while (n > 0 && (k[n-1] == ' ' || k[n-1] == '\t')) n--;
if (eq && n == kl && strncmp(k, key, (size_t)kl) == 0) {
fprintf(out, "%s = %s\n", key, val);
found = 1;
} else {
fputs(line, out);
}
}
if (in) fclose(in);
if (!found) fprintf(out, "%s = %s\n", key, val);
fclose(out);
if (rename(tmp, MEOW_STR_SETTINGS_FILE) != 0) { remove(tmp); return -1; }
return 0;
}
/* ---- TF 覆盖表路径:<MEOW_DATA_DIR 去尾 /data>/strings/<locale>.txt(= MeowData/<pkg>/strings/)。
* 无 MEOW_DATA_DIR(手动跑/宿主/无卡)→ path 置空(覆盖表不加载,内嵌兜底)。返回 path 是否有效。 */
static int str_override_path(char *path, int n)
{
const char *env = getenv("MEOW_DATA_DIR");
if (!env || !env[0] || n <= 0) { if (n > 0) path[0] = 0; return 0; }
char base[MEOW_STR_PATH_MAX];
snprintf(base, sizeof base, "%s", env);
size_t bn = strlen(base);
/* launcher 注入的 MEOW_DATA_DIR = <pkg>/data;去尾 "/data" 回到 <pkg>,再拼 strings/。 */
if (bn >= 5 && strcmp(base + bn - 5, "/data") == 0) base[bn - 5] = 0;
if (snprintf(path, (size_t)n, "%s/strings/%s.txt", base, g_locale) >= n) { path[0] = 0; return 0; }
return path[0] != 0;
}
/* 解析覆盖表(`id=text` 行,'=' 两侧去空白;空行/无 '=' 行跳过;UTF-8 原样透传)。
* 覆盖优先级:同名覆盖内嵌,未覆盖该 id 则回退内嵌。 */
static void str_load_override(void)
{
char path[MEOW_STR_PATH_MAX];
g_ov_n = 0;
if (!str_override_path(path, sizeof path)) return;
FILE *f = fopen(path, "r");
if (!f) return;
char line[MEOW_STR_OVR_TEXT_MAX + 16];
while (fgets(line, sizeof line, f) && g_ov_n < MEOW_STR_OVERRIDE_MAX) {
char *eq = strchr(line, '=');
if (!eq) continue; /* 注释/空行/非 key=value */
char *k = line;
while (*k == ' ' || *k == '\t') k++; /* id 左去空白 */
int klen = (int)(eq - k);
while (klen > 0 && (k[klen-1] == ' ' || k[klen-1] == '\t')) klen--;
if (klen <= 0 || klen >= MEOW_STR_OVR_ID_MAX) continue;
const char *v = eq + 1;
while (*v == ' ' || *v == '\t') v++; /* text 左去空白 */
int vlen = 0;
while (v[vlen] && v[vlen] != '\n' && v[vlen] != '\r' && vlen < MEOW_STR_OVR_TEXT_MAX - 1)
vlen++;
while (vlen > 0 && (v[vlen-1] == ' ' || v[vlen-1] == '\t')) vlen--; /* text 右去空白 */
if (vlen <= 0) continue;
memcpy(g_ov_id[g_ov_n], k, (size_t)klen);
g_ov_id[g_ov_n][klen] = 0;
memcpy(g_ov_text[g_ov_n], v, (size_t)vlen);
g_ov_text[g_ov_n][vlen] = 0;
g_ov_n++;
}
fclose(f);
}
/* 惰性初值:首次调用读 settings.conf ui.locale(缺省 "zh_cn")。幂等(只读一次)。 */
static void str_init_locale(void)
{
if (g_locale_loaded) return;
char buf[MEOW_STR_LOCALE_MAX];
if (str_setting_get("ui.locale", buf, sizeof buf) == 0 && buf[0])
snprintf(g_locale, sizeof g_locale, "%s", buf);
else
snprintf(g_locale, sizeof g_locale, "zh_cn");
g_locale_loaded = 1;
}
/* 惰性加载覆盖表(对当前 g_locale)。幂等(只加载一次直到 set_locale 重载)。 */
static void str_ensure_override(void)
{
if (g_override_loaded) return;
str_load_override();
g_override_loaded = 1;
}
/* meow_str / meow_str_locale 前置:确保 locale 初值 + 覆盖表就绪。 */
static void str_init(void)
{
str_init_locale();
str_ensure_override();
}
/* 持久化 ui.locale(best-effort)。失败静默(运行时仍切换,仅不跨启动)。 */
static void str_persist_locale(void)
{
str_setting_set("ui.locale", g_locale);
}
const char *meow_str_locale(void)
{
str_init();
return g_locale;
}
const char *meow_str(const char *id)
{
if (!id) return "";
str_init();
int en = (strcmp(g_locale, "en_us") == 0);
/* ① 当前 locale 的 TF 覆盖表(最高优先) */
for (int i = 0; i < g_ov_n; i++)
if (strcmp(g_ov_id[i], id) == 0) return g_ov_text[i];
/* ② 内嵌当前语言(en_us→en;其余→zh)③ 兜底内嵌内置默认(zh)——同一表内按语言取。 */
for (size_t i = 0; i < MEOW_STR_EMBED_N; i++)
if (strcmp(g_embed[i].id, id) == 0) return en ? g_embed[i].en : g_embed[i].zh;
/* ④ 未知 id:原样返回调用方(永不 NULL)。 */
return id;
}
int meow_strf(char *out, int outsz, const char *id, ...)
{
if (!out || outsz <= 0) return -1;
const char *s = meow_str(id);
va_list ap;
va_start(ap, id);
int r = vsnprintf(out, (size_t)outsz, s, ap);
va_end(ap);
return r;
}
void meow_str_set_locale(const char *locale)
{
if (!locale || !locale[0]) return;
str_init(); /* 确保 locale 初值 + 覆盖表基座就绪 */
snprintf(g_locale, sizeof g_locale, "%s", locale);
str_load_override(); /* 按新 locale 重载覆盖表 */
g_override_loaded = 1;
str_persist_locale(); /* best-effort 持久化(失败静默) */
}