diff --git a/docs/specification/LOCALE_PACKS.md b/docs/specification/LOCALE_PACKS.md index d5b497f3..fdb71027 100644 --- a/docs/specification/LOCALE_PACKS.md +++ b/docs/specification/LOCALE_PACKS.md @@ -164,7 +164,7 @@ pack 的可用性由共享运行时代码中的 board-specific memory profile - `constrained` locale 字体预算 `128 KiB`,禁用 content supplement,解码后的地图 cache 为 `2` 张 tile,页面退出后不保留 cache。 - `standard` - locale 字体预算 `768 KiB`,content supplement 预算 `640 KiB`,最多 `1` 个 supplement pack,解码后的地图 cache 为 `4` 张 tile,页面退出后不保留 cache。 + locale 字体预算 `768 KiB`,content supplement 预算 `640 KiB`,最多 `2` 个 supplement pack,解码后的地图 cache 为 `12` 张 tile,页面退出后不保留 cache。 - `extended` locale 字体预算 `2 MiB`,content supplement 预算 `2 MiB`,最多 `3` 个 supplement pack,解码后的地图 cache 为 `12` 张 tile,页面退出后保留 cache。 @@ -424,7 +424,11 @@ tags=language,cjk,chinese,ime - `id` 稳定 package 标识符,供远程 catalog 与未来的 installed-index 文件使用。 - `package_type` - 高层级 package 角色。当前仓库 bundle 使用 `locale-bundle`。 + 高层级 package 角色。合法值包括: + - `locale-bundle`:至少提供一个 locale,可能同时提供 font/IME。 + - `content-bundle`:提供内容字体、supplement 或内容相关 IME,但不声明 locale。 + - `input-bundle`:主要提供 IME runtime payload,可选依赖已有字体。 + 历史空值按 `locale-bundle` 兼容处理。 - `version` package 版本号,独立于固件 tag。字符串、manifest、字体、质量状态或 IME 依赖发生用户可见变化时必须 bump。 - `display_name` @@ -537,21 +541,44 @@ runtime registry 仍然直接从 `/trailmate/packs` 对已解包资源进行编 ## 当前 IME 覆盖 -目前仓库 bundle 中,简体中文和俄语声明了 IME pack: +目前仓库 bundle 中存在两类 IME 声明: -- `zh-Hans` -> `zh-hans-pinyin` -- `ru` -> `ru-cyrillic-keyboard` +1. locale bundle 可以声明专用于该 locale 的 IME。 +2. content/input extension 可以声明不属于任何 locale 的 IME。 -繁体中文、日文、韩文、阿拉伯语和欧洲 Latin bundle 目前都是 display-only review 包。它们有意继续沿用现有 `EN` / `123` 输入路径,直到对应脚本的专用 IME pack 出现。 +当前可下载 content/input extension 中,emoji 扩展声明: + +- `emoji` -> `emoji-picker` 具体约束: -- `zh-Hant` 当前按台湾繁中 review 包处理,不得挂拼音 IME。未来应新增注音优先的真实 IME。 -- `ja` 必须等假名/罗马字日文输入模型完成后才能挂 IME。 -- `ko` 必须等韩文输入模型完成后才能挂 IME。 -- `ar` 必须等阿拉伯键盘、RTL 编辑与 shaping 行为闭环后才能挂 IME。 -- `ru` 可以挂 `ru-cyrillic-keyboard`;该能力只承诺直接 Cyrillic 键盘布局,不承诺物理 QWERTY 到俄文的硬件重映射。 - 欧洲 Latin locale 不应为了“有输入法列表项”而挂虚假的 `builtin-latin`。 +- `emoji-picker` 是 content/input 扩展,不属于任何 locale。安装后 compose widget 可以把它作为一个 script/input 模式轮换出来;未安装时不能显示这个 IME。 + +## 新增内容 / 输入扩展 + +内容或输入扩展可以没有 locale,但必须仍然遵守 runtime payload 分层: + +1. source bundle 位于 `packs//`。 +2. `package.ini` 使用 `package_type=content-bundle` 或 `package_type=input-bundle`。 +3. 字体 payload 仍放在 `fonts//`。 +4. IME payload 仍放在 `ime//`。 +5. `README.md` 必须说明它不是 locale,以及用户如何启用它。 +6. `supported_memory_profiles` 必须按 font RAM 与 supplement 策略声明。 +7. 如果它需要新的 runtime IME 后端,必须提高 `min_firmware_version`。 + +Emoji 扩展是参考实现: + +```text +packs/emoji/ + package.ini # package_type=content-bundle + fonts/emoji-core/manifest.ini # usage=content + ime/emoji-picker/manifest.ini # backend=builtin-candidate-picker + ime/emoji-picker/candidates.txt +``` + +它解决的是“内容可显示”和“输入可插入”,不改变 `display_locale`。 +`candidates.txt` 是输入资源的一部分,必须随扩展包安装;固件不得把这些候选项写死在代码里,也不得按 `emoji-picker` id 编写专属业务分支。 ## 新增一种语言 diff --git a/docs/specification/LOCALE_PACK_RELEASE_SPEC.md b/docs/specification/LOCALE_PACK_RELEASE_SPEC.md index bb01e1c3..01851158 100644 --- a/docs/specification/LOCALE_PACK_RELEASE_SPEC.md +++ b/docs/specification/LOCALE_PACK_RELEASE_SPEC.md @@ -1,6 +1,6 @@ # Locale Pack Release Specification -本文档定义 Trail Mate 语言包的打包、发布、更新与回滚规则。 +本文档定义 Trail Mate 语言/内容/输入扩展包的打包、发布、更新与回滚规则。 它回答的问题不是“如何翻译一句话”,而是: @@ -17,7 +17,7 @@ ## 1. 发布对象 -语言包发布涉及五个对象: +语言/内容/输入扩展包发布涉及五个对象: 1. source bundle - 仓库中的 `packs//`。 @@ -37,6 +37,8 @@ ## 2. 包状态 locale manifest 的 `translation_status` 控制运行时是否能采用该 locale。 +没有 locale 的 `content-bundle` / `input-bundle` 不参与 `translation_status` +判定,但仍必须通过 font/IME payload 校验。 1. `release` - 可以出现在语言选择器。 @@ -123,7 +125,13 @@ payload/locales//strings.tsv payload/ime//manifest.ini ``` -其中 `payload/ime/...` 只允许包含真实可运行 IME。未实现输入法不得进入 archive。 +其中 `payload/locales/...` 与 `payload/ime/...` 按 package 类型可选: + +1. `locale-bundle` 必须至少提供一个 locale。 +2. `content-bundle` 可以不提供 locale,但必须至少提供一个 font 或 IME。 +3. `input-bundle` 可以不提供 locale,但必须至少提供一个真实可运行 IME。 + +`payload/ime/...` 只允许包含真实可运行 IME。未实现输入法不得进入 archive。 archive 不得包含: @@ -138,7 +146,9 @@ archive 不得包含: ## 6. Catalog 规则 -`site/data/packs.json` 是用户看到可安装/可更新语言包的 catalog。 +`site/data/packs.json` 是用户看到可安装/可更新扩展包的 catalog。 +Extensions UI 必须把 `locale-bundle`、`content-bundle` 与 `input-bundle` +都视为可安装扩展,而不是只显示语言包。 每次 package version 或 archive 内容变化,都必须重建 catalog,使以下字段同步: @@ -179,6 +189,14 @@ archive 不得包含: 5. `ime_pack` 只指向真实 runtime IME。 6. RTL locale 必须声明 `direction=rtl`。 +每个 font/IME-only package 必须校验: + +1. package type 不是空值且不是伪装的 `locale-bundle`。 +2. 没有 locale 时仍至少提供一个 font 或 IME record。 +3. IME backend 已被当前固件运行时支持;`builtin-candidate-picker` 这类通用候选列表后端必须带候选 payload。 +4. font `usage` 与包语义匹配;emoji 这类内容补充必须使用 `usage=content`。 +5. 如果 IME 需要候选表或布局表,这些数据必须作为 pack payload 文件发布,不能写进固件代码,也不能靠固件按 package id 补齐。 + 每个 package manifest 必须校验: 1. `version` 已 bump。 diff --git a/docs/specification/LOCALIZATION_SPEC.md b/docs/specification/LOCALIZATION_SPEC.md index a2aebf32..28ac2db5 100644 --- a/docs/specification/LOCALIZATION_SPEC.md +++ b/docs/specification/LOCALIZATION_SPEC.md @@ -366,6 +366,64 @@ English 必须在没有任何外部 pack 的情况下仍可工作。 这条规则的目标是保护 UI 实时域:联系人页、聊天页、地图 overlay、节点详情页等内容页面不得因为遇到中文/日文/韩文/阿拉伯文本而把 UI 线程拖入 SD 阻塞 IO。 +#### Small content supplement preload + +ESP 上允许一个非常窄的例外:如果 content supplement 的 manifest 中的 +`estimated_ram_bytes` 不超过固件规定的小 supplement 上限,registry 可以在 +`reload_language()` / 安装完成后的重新编目阶段预加载它,并把它加入 content font chain。 + +这个例外只用于类似 `emoji-core` 这种小型、可选、用户安装后的内容补充包。它不适用于: + +1. 大型 locale 字体。 +2. locale 激活之外的 UI 字体替换。 +3. 任何需要反复探测缺失文件的失败路径。 +4. 页面渲染、列表构建、LVGL 事件或 timer 路径中的 SD-backed `font.bin` 同步读取。 + +因此,安装到当前运行时 pack root 的小型内容扩展可以在 registry 阶段进入可用字体链;之后聊天内容第一次遇到对应字符时,内容热路径只选择已经 loaded 的字体链,不再为了该字符直接读取 SD。 + +### 4.5.2 Emoji Extension Boundary + +Emoji 支持不是一个 locale。 + +它由两个可下载扩展对象组成: + +1. `emoji-core` + - `kind=font` + - `usage=content` + - 作为 content supplement font pack 参与内容字体链。 +2. `emoji-picker` + - `kind=ime` + - `backend=builtin-candidate-picker` + - `candidates=` 指向 pack payload 中的 UTF-8 候选资源 + - 作为通用候选列表 IME 后端暴露给共享 compose widget。 + +合法依赖方向是: + +```mermaid +flowchart LR + ChatText["Chat / content text"] --> FontResolver["Content font resolver"] + FontResolver --> EmojiFont["emoji-core supplement"] + ComposeWidget["Compose widget"] --> ImeRegistry["IME registry"] + ImeRegistry --> EmojiIme["emoji-picker"] + EmojiIme --> TextInsert["UTF-8 text insertion"] +``` + +非法做法: + +1. 在聊天页面硬编码 emoji 替换表。 +2. 把 emoji 声明成一个假 locale。 +3. 让 `emoji-picker` 在没有 runtime 后端或候选资源为空时出现在设置页。 +4. 为了显示 emoji 而把所有 `1Fxxx` 字符映射到同一个内置图标。 +5. 在 UI 热路径同步读取 SD 上的 emoji font。 + +安装边界: + +1. 默认固件不携带 `emoji-core/font.bin`;只有安装扩展后 registry 才能发现 `emoji-picker`。 +2. Extensions 安装包负责下载并解包 font/IME payload。 +3. 安装后 `reload_language()` 重新编目,IME 设置页才能看到 `emoji-picker`。 +4. 删除扩展包后,registry 必须回到“无 emoji 字库/IME”的解释状态。 +5. 固件代码只允许包含 `builtin-candidate-picker` 这种通用候选列表后端逻辑,不得内置具体 emoji 候选表,也不得按 `emoji-picker` id 做业务分支。 + ### 4.6 Persistence Contract 当前 locale 的持久化 key 是: @@ -409,8 +467,8 @@ English 必须在没有任何外部 pack 的情况下仍可工作。 3. 重新选择 active locale 4. 按需加载字体 -当前 Arduino/ESP32 安装器默认把下载得到的 payload 解到 Flash pack 根。 -与此同时,手工把 runtime payload 拷贝到 SD 仍然是合法安装方式,因为运行时会同时编目当前支持的 Flash/SD 根。 +当前 Arduino/ESP32 安装器把下载得到的 payload 解到当前平台定义的 pack root。 +运行时会同时编目当前支持的 Flash/SD 根,因此手工把 runtime payload 拷贝到任一受支持根仍然是合法安装方式。 禁止让运行时 registry 直接承担网络下载或 zip 解释职责。 diff --git a/modules/ui_shared/include/ui/localization.h b/modules/ui_shared/include/ui/localization.h index 9c8ca00e..35cc7589 100644 --- a/modules/ui_shared/include/ui/localization.h +++ b/modules/ui_shared/include/ui/localization.h @@ -28,6 +28,7 @@ struct ImeInfo const char* display_name = nullptr; const char* backend = nullptr; const char* layout = nullptr; + std::size_t candidate_count = 0; bool builtin = true; }; @@ -46,6 +47,8 @@ const lv_font_t* locale_preview_font(const char* locale_id, const lv_font_t* asc bool ensure_content_font_for_text(const char* text); std::size_t ime_count(); const ImeInfo* ime_at(std::size_t index); +std::size_t ime_candidate_count(const char* ime_id); +const char* ime_candidate_at(const char* ime_id, std::size_t index); std::size_t enabled_ime_count(); bool ime_enabled(const char* ime_id); bool set_ime_enabled(const char* ime_id, bool enabled, bool persist = true); diff --git a/modules/ui_shared/include/ui/widgets/ime/ime_widget.h b/modules/ui_shared/include/ui/widgets/ime/ime_widget.h index 73ca0bb7..8357b283 100644 --- a/modules/ui_shared/include/ui/widgets/ime/ime_widget.h +++ b/modules/ui_shared/include/ui/widgets/ime/ime_widget.h @@ -62,6 +62,7 @@ class ImeWidget bool commit_candidate(int candidate_index); bool pinyin_mode() const; bool direct_keyboard_mode() const; + bool candidate_picker_mode() const; static void on_toggle_clicked(lv_event_t* e); static void on_touch_key_event(lv_event_t* e); @@ -85,6 +86,8 @@ class ImeWidget std::string committed_text_; bool touch_keyboard_enabled_ = false; int candidate_window_start_ = 0; + int script_input_index_ = 0; + int candidate_picker_index_ = 0; }; } // namespace widgets diff --git a/modules/ui_shared/src/ui/i18n/resource_pack_registry.cpp b/modules/ui_shared/src/ui/i18n/resource_pack_registry.cpp index feab5928..66483e84 100644 --- a/modules/ui_shared/src/ui/i18n/resource_pack_registry.cpp +++ b/modules/ui_shared/src/ui/i18n/resource_pack_registry.cpp @@ -54,6 +54,7 @@ constexpr const char* kDisabledImeSentinel = "__none__"; constexpr std::size_t kFontLoadOverlayThresholdBytes = 64U * 1024U; constexpr unsigned kMaxMissingFontDiagnostics = 20; constexpr uint32_t kFontLoadFailureBackoffMs = 5U * 60U * 1000U; +constexpr std::size_t kSmallContentSupplementMaxBytes = 16U * 1024U; #if defined(LV_USE_FS_POSIX) && LV_USE_FS_POSIX constexpr bool kLvFsPosixEnabled = true; @@ -108,6 +109,7 @@ struct ImePackRecord std::string display_name; std::string backend; std::string layout; + std::vector candidates; bool builtin = false; }; @@ -574,11 +576,54 @@ bool ime_backend_supports_runtime_input(const ImePackRecord& pack) { return true; } + if (pack.backend == "builtin-candidate-picker") + { + return !pack.candidates.empty(); + } return pack.id == "ru-cyrillic-keyboard" && pack.backend == "builtin-keyboard-layout" && pack.layout == "ru-cyrillic"; } +bool parse_ime_candidate_file(const std::string& path, std::vector& out) +{ + out.clear(); + + std::string contents; + if (!::ui::fs::read_text_file(path.c_str(), contents)) + { + return false; + } + + std::size_t token_start = 0; + while (token_start <= contents.size()) + { + const std::size_t token_end = contents.find_first_of(",\r\n", token_start); + std::string token = contents.substr( + token_start, + token_end == std::string::npos ? std::string::npos : (token_end - token_start)); + const std::size_t comment = token.find('#'); + if (comment != std::string::npos) + { + token.resize(comment); + } + trim_in_place(token); + if (!token.empty() && + std::find(out.begin(), out.end(), token) == out.end()) + { + out.push_back(std::move(token)); + } + + if (token_end == std::string::npos) + { + break; + } + token_start = token_end + 1U; + } + + return !out.empty(); +} + bool ime_backend_can_be_enabled(const ImePackRecord& pack) { return ime_backend_supports_runtime_input(pack); @@ -1207,10 +1252,35 @@ bool ensure_font_pack_loaded(FontPackRecord* pack) return load_font_pack(*pack); } +bool path_has_prefix(const std::string& path, const char* prefix) +{ + if (path.empty() || prefix == nullptr || prefix[0] == '\0') + { + return false; + } + return path.rfind(prefix, 0) == 0; +} + +bool is_flash_pack_font_source(const FontPackRecord& pack) +{ +#if UI_FS_HAS_FLASH_PACK_STORAGE + return path_has_prefix(pack.source_path, "F:/trailmate/packs/"); +#else + (void)pack; + return false; +#endif +} + bool can_load_font_from_content_hot_path(const FontPackRecord& pack) { #if defined(ESP_PLATFORM) || defined(ARDUINO_ARCH_ESP32) - return pack.builtin; + // UI render paths must not synchronously touch SD-backed external fonts. + // Small Flash-installed content supplements are allowed to become visible + // after install without reintroducing the shared SD/display SPI stall class. + return pack.builtin || + (is_flash_pack_font_source(pack) && + pack.estimated_ram_bytes > 0 && + pack.estimated_ram_bytes <= kSmallContentSupplementMaxBytes); #else (void)pack; return true; @@ -1227,6 +1297,22 @@ bool can_load_font_from_activation_path(const FontPackRecord& pack) #endif } +bool can_add_content_supplement(const FontPackRecord& pack); + +bool can_preload_small_content_supplement(const FontPackRecord& pack) +{ + if (pack.builtin || is_font_runtime_loaded(pack)) + { + return false; + } + if (!font_pack_supports_content(pack) || pack.coverage.empty()) + { + return false; + } + return pack.estimated_ram_bytes > 0 && + pack.estimated_ram_bytes <= kSmallContentSupplementMaxBytes; +} + std::vector current_content_pack_sequence() { std::vector packs; @@ -1249,6 +1335,34 @@ void rebuild_runtime_font_chains() ::ui::fonts::refresh_locale_font_bindings(); } +bool preload_small_content_supplements() +{ + bool changed = false; + for (FontPackRecord& pack : s_font_packs) + { + if (!can_preload_small_content_supplement(pack) || !can_add_content_supplement(pack)) + { + continue; + } + if (!ensure_font_pack_loaded(&pack)) + { + continue; + } + append_unique_pack(s_content_supplement_packs, &pack); + changed = true; + std::printf("%s font preload id=%s role=content_supplement bytes=%lu source=%s\n", + kLogTag, + pack.id.c_str(), + static_cast(pack.estimated_ram_bytes), + pack.source_path.empty() ? "" : pack.source_path.c_str()); + } + if (changed) + { + rebuild_runtime_font_chains(); + } + return changed; +} + bool ensure_active_content_pack_loaded() { bool changed = false; @@ -1548,6 +1662,7 @@ void rebuild_ime_views() view.display_name = pack.display_name.c_str(); view.backend = pack.backend.c_str(); view.layout = pack.layout.empty() ? nullptr : pack.layout.c_str(); + view.candidate_count = pack.candidates.size(); view.builtin = pack.builtin; s_ime_views.push_back(view); } @@ -1748,12 +1863,24 @@ bool catalog_external_ime_pack(const std::string& pack_dir) { pack.layout = layout; } + if (const char* candidates = manifest_value(manifest, "candidates")) + { + const std::string candidates_path = resolve_pack_file_path(pack_dir, candidates); + if (!parse_ime_candidate_file(candidates_path, pack.candidates)) + { + std::printf("%s ime pack candidates unavailable id=%s path=%s\n", + kLogTag, + pack.id.c_str(), + candidates_path.empty() ? "" : candidates_path.c_str()); + } + } - std::printf("%s ime pack catalog id=%s backend=%s layout=%s\n", + std::printf("%s ime pack catalog id=%s backend=%s layout=%s candidates=%lu\n", kLogTag, pack.id.c_str(), pack.backend.c_str(), - pack.layout.empty() ? "" : pack.layout.c_str()); + pack.layout.empty() ? "" : pack.layout.c_str(), + static_cast(pack.candidates.size())); s_ime_packs.push_back(std::move(pack)); return true; } @@ -2453,6 +2580,7 @@ void rebuild_registry() s_allow_sync_external_font_activation = true; (void)activate_locale(resolve_active_locale(preferred_locale)); s_allow_sync_external_font_activation = false; + (void)preload_small_content_supplements(); std::printf("%s registry rebuild end active_locale=%s locale_count=%lu ime_count=%lu enabled_ime=%lu active_ime=%s ui_chain=%s content_chain=%s\n", kLogTag, s_active_locale ? s_active_locale->id.c_str() : "", @@ -2707,7 +2835,9 @@ bool ensure_content_font_for_text(const char* text) { break; } - if (!kAllowSynchronousContentSupplementFontLoad && !is_font_runtime_loaded(*candidate)) + if (!is_font_runtime_loaded(*candidate) && + !kAllowSynchronousContentSupplementFontLoad && + !can_load_font_from_content_hot_path(*candidate)) { log_font_load_deferred(*candidate, "content_supplement", "ui_hot_path"); break; @@ -2759,6 +2889,24 @@ const ImeInfo* ime_at(std::size_t index) return &s_ime_views[index]; } +std::size_t ime_candidate_count(const char* ime_id) +{ + ensure_registry(); + const ImePackRecord* pack = find_pack_by_id(s_ime_packs, ime_id); + return pack ? pack->candidates.size() : 0; +} + +const char* ime_candidate_at(const char* ime_id, std::size_t index) +{ + ensure_registry(); + const ImePackRecord* pack = find_pack_by_id(s_ime_packs, ime_id); + if (pack == nullptr || index >= pack->candidates.size()) + { + return nullptr; + } + return pack->candidates[index].c_str(); +} + std::size_t enabled_ime_count() { ensure_registry(); diff --git a/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp b/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp index 349d14d1..c76f3f0f 100644 --- a/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp @@ -155,9 +155,12 @@ std::string join_values(const std::vector& values) return joined; } -bool is_language_package(const packs::PackageRecord& package) +bool is_extension_package(const packs::PackageRecord& package) { - return package.package_type.empty() || package.package_type == "locale-bundle"; + return package.package_type.empty() || + package.package_type == "locale-bundle" || + package.package_type == "content-bundle" || + package.package_type == "input-bundle"; } bool can_install_or_update(const packs::PackageRecord& package) @@ -460,7 +463,7 @@ void rebuild_filtered_indices() s_runtime.filtered_indices.clear(); for (std::size_t i = 0; i < s_runtime.packages.size(); ++i) { - if (is_language_package(s_runtime.packages[i])) + if (is_extension_package(s_runtime.packages[i])) { s_runtime.filtered_indices.push_back(i); } @@ -822,7 +825,7 @@ void render_current_view() if (s_runtime.view == MainView::Detail) { const std::size_t selected_index = find_package_index(s_runtime.selected_package_id); - if (selected_index != kInvalidIndex && is_language_package(s_runtime.packages[selected_index])) + if (selected_index != kInvalidIndex && is_extension_package(s_runtime.packages[selected_index])) { render_detail_view(selected_index); return; @@ -878,7 +881,7 @@ void create_filter_panel(lv_obj_t* parent) lv_obj_add_event_cb(s_runtime.filter_btn, on_focus_scroll, LV_EVENT_FOCUSED, nullptr); lv_obj_t* label = lv_label_create(s_runtime.filter_btn); - ::ui::i18n::set_label_text(label, "Language"); + ::ui::i18n::set_label_text(label, "Packs"); two_pane_styles::apply_label_primary(label); lv_obj_center(label); } diff --git a/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp b/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp index ee3a7e4b..db8799ff 100644 --- a/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp +++ b/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp @@ -17,6 +17,7 @@ #include #include #include +#include namespace ui { @@ -35,7 +36,15 @@ enum class ScriptInputKind { None, Pinyin, - RuCyrillicKeyboard + RuCyrillicKeyboard, + CandidatePicker +}; + +struct ScriptInputDescriptor +{ + ScriptInputKind kind = ScriptInputKind::None; + const char* ime_id = nullptr; + const char* display_name = nullptr; }; const ::ui::i18n::ImeInfo* active_ime_info() @@ -58,9 +67,8 @@ const ::ui::i18n::ImeInfo* active_ime_info() return nullptr; } -ScriptInputKind active_script_input_kind() +ScriptInputKind script_input_kind_for_ime(const ::ui::i18n::ImeInfo* ime) { - const ::ui::i18n::ImeInfo* ime = active_ime_info(); if (!ime || !ime->id || !ime->backend) { return ScriptInputKind::None; @@ -77,9 +85,73 @@ ScriptInputKind active_script_input_kind() { return ScriptInputKind::RuCyrillicKeyboard; } + if (std::strcmp(ime->backend, "builtin-candidate-picker") == 0 && + ime->candidate_count > 0) + { + return ScriptInputKind::CandidatePicker; + } return ScriptInputKind::None; } +void append_unique_script_input(std::vector& inputs, + const ::ui::i18n::ImeInfo* ime) +{ + const ScriptInputKind kind = script_input_kind_for_ime(ime); + if (kind == ScriptInputKind::None) + { + return; + } + const char* ime_id = ime && ime->id ? ime->id : ""; + const auto existing = std::find_if( + inputs.begin(), + inputs.end(), + [&](const ScriptInputDescriptor& input) + { + return input.ime_id != nullptr && std::strcmp(input.ime_id, ime_id) == 0; + }); + if (existing == inputs.end()) + { + inputs.push_back({kind, ime_id, ime && ime->display_name ? ime->display_name : ime_id}); + } +} + +std::vector available_script_inputs() +{ + std::vector inputs; + append_unique_script_input(inputs, active_ime_info()); + + const std::size_t count = ::ui::i18n::ime_count(); + for (std::size_t index = 0; index < count; ++index) + { + const ::ui::i18n::ImeInfo* ime = ::ui::i18n::ime_at(index); + if (ime == nullptr || ime->id == nullptr || !::ui::i18n::ime_enabled(ime->id)) + { + continue; + } + append_unique_script_input(inputs, ime); + } + return inputs; +} + +ScriptInputDescriptor selected_script_input(int script_input_index) +{ + const std::vector inputs = available_script_inputs(); + if (inputs.empty()) + { + return {}; + } + if (script_input_index < 0 || script_input_index >= static_cast(inputs.size())) + { + script_input_index = 0; + } + return inputs[static_cast(script_input_index)]; +} + +ScriptInputKind selected_script_input_kind(int script_input_index) +{ + return selected_script_input(script_input_index).kind; +} + bool script_mode(ImeWidget::Mode mode) { return mode == ImeWidget::Mode::SCRIPT; @@ -113,9 +185,58 @@ static const char* kTouchRuCyrillicMap[] = { "я", "ч", "с", "м", "и", "т", "ь", "б", "ю", ",", ".", "?", "\n", "Space", ""}; +static const char* kTouchCandidatePickerMap[] = { + "Prev", "Pick", "Next", "Bksp", "\n", + "Space", "Enter", ""}; + static constexpr const char* kRuCyrillicFontProbe = "йцукенгшщзхъфывапролджэячсмитьбю"; #endif + +const char* candidate_picker_ime_id(int script_input_index) +{ + const ScriptInputDescriptor input = selected_script_input(script_input_index); + return input.kind == ScriptInputKind::CandidatePicker ? input.ime_id : nullptr; +} + +const char* candidate_picker_display_name(int script_input_index) +{ + const ScriptInputDescriptor input = selected_script_input(script_input_index); + return input.kind == ScriptInputKind::CandidatePicker ? input.display_name : nullptr; +} + +int candidate_picker_candidate_count(int script_input_index) +{ + const char* ime_id = candidate_picker_ime_id(script_input_index); + return static_cast(::ui::i18n::ime_candidate_count(ime_id)); +} + +const char* candidate_picker_candidate_at(int script_input_index, int index) +{ + if (index < 0) + { + return nullptr; + } + const char* ime_id = candidate_picker_ime_id(script_input_index); + return ::ui::i18n::ime_candidate_at(ime_id, static_cast(index)); +} + +std::vector candidate_picker_candidates_vector(int script_input_index) +{ + std::vector candidates; + candidates.reserve(static_cast( + candidate_picker_candidate_count(script_input_index))); + const int count = candidate_picker_candidate_count(script_input_index); + for (int index = 0; index < count; ++index) + { + const char* candidate = candidate_picker_candidate_at(script_input_index, index); + if (candidate != nullptr && candidate[0] != '\0') + { + candidates.emplace_back(candidate); + } + } + return candidates; +} std::string make_candidates_text(const std::vector& candidates, int active_idx, int max_show = kCandidatesPerPage, @@ -236,6 +357,9 @@ bool touch_token_is_action(const char* token) return token != nullptr && (std::strcmp(token, "Bksp") == 0 || std::strcmp(token, "Enter") == 0 || + std::strcmp(token, "Prev") == 0 || + std::strcmp(token, "Next") == 0 || + std::strcmp(token, "Pick") == 0 || std::strcmp(token, "Space") == 0); } @@ -531,14 +655,22 @@ void ImeWidget::detach() void ImeWidget::setMode(Mode mode) { - const ScriptInputKind requested_kind = active_script_input_kind(); + const std::vector script_inputs = available_script_inputs(); + if (script_input_index_ < 0 || + script_input_index_ >= static_cast(script_inputs.size())) + { + script_input_index_ = 0; + } + const ScriptInputKind requested_kind = + script_inputs.empty() ? ScriptInputKind::None + : script_inputs[static_cast(script_input_index_)].kind; if (script_mode(mode) && requested_kind == ScriptInputKind::None) { mode = Mode::EN; } mode_ = mode; - const bool use_pinyin = pinyin_mode(); + const bool use_pinyin = script_mode(mode_) && requested_kind == ScriptInputKind::Pinyin; ime_.setEnabled(use_pinyin); if (textarea_) { @@ -549,6 +681,10 @@ void ImeWidget::setMode(Mode mode) ime_.reset(); lv_textarea_set_accepted_chars(textarea_, ""); } + else if (script_mode(mode_) && requested_kind == ScriptInputKind::CandidatePicker) + { + lv_textarea_set_accepted_chars(textarea_, ""); + } else { lv_textarea_set_accepted_chars(textarea_, nullptr); @@ -566,18 +702,26 @@ ImeWidget::Mode ImeWidget::mode() const bool ImeWidget::pinyin_mode() const { - return script_mode(mode_) && active_script_input_kind() == ScriptInputKind::Pinyin; + return script_mode(mode_) && + selected_script_input_kind(script_input_index_) == ScriptInputKind::Pinyin; } bool ImeWidget::direct_keyboard_mode() const { return script_mode(mode_) && - active_script_input_kind() == ScriptInputKind::RuCyrillicKeyboard; + selected_script_input_kind(script_input_index_) == ScriptInputKind::RuCyrillicKeyboard; +} + +bool ImeWidget::candidate_picker_mode() const +{ + return script_mode(mode_) && + selected_script_input_kind(script_input_index_) == ScriptInputKind::CandidatePicker; } void ImeWidget::cycleMode() { - if (active_script_input_kind() == ScriptInputKind::None) + const std::vector script_inputs = available_script_inputs(); + if (script_inputs.empty()) { setMode(mode_ == Mode::EN ? Mode::NUM : Mode::EN); return; @@ -585,11 +729,20 @@ void ImeWidget::cycleMode() if (mode_ == Mode::EN) { + script_input_index_ = 0; setMode(Mode::SCRIPT); } else if (script_mode(mode_)) { - setMode(Mode::NUM); + if ((script_input_index_ + 1) < static_cast(script_inputs.size())) + { + ++script_input_index_; + setMode(Mode::SCRIPT); + } + else + { + setMode(Mode::NUM); + } } else { @@ -599,6 +752,24 @@ void ImeWidget::cycleMode() bool ImeWidget::commit_candidate(int candidate_index) { + if (candidate_picker_mode()) + { + if (candidate_index < 0 || + candidate_index >= candidate_picker_candidate_count(script_input_index_)) + { + return false; + } + const char* candidate = candidate_picker_candidate_at(script_input_index_, candidate_index); + if (candidate == nullptr || candidate[0] == '\0') + { + return false; + } + committed_text_ += candidate; + sync_textarea(); + refresh_labels(); + return true; + } + std::string out; if (!ime_.commitCandidate(candidate_index, out)) { @@ -719,6 +890,47 @@ bool ImeWidget::handle_key_code(uint32_t key) } } } + else if (candidate_picker_mode()) + { + const int count = candidate_picker_candidate_count(script_input_index_); + if (key == LV_KEY_BACKSPACE) + { + erase_last_utf8_char(committed_text_); + update_text = true; + consumed = true; + } + else if (key == LV_KEY_UP || key == LV_KEY_LEFT) + { + if (candidate_picker_index_ > 0) + { + --candidate_picker_index_; + } + consumed = true; + } + else if (key == LV_KEY_DOWN || key == LV_KEY_RIGHT) + { + if (candidate_picker_index_ + 1 < count) + { + ++candidate_picker_index_; + } + consumed = true; + } + else if (key == LV_KEY_ENTER || key == ' ') + { + if (candidate_picker_index_ < 0 || candidate_picker_index_ >= count) + { + candidate_picker_index_ = 0; + } + const char* candidate = + candidate_picker_candidate_at(script_input_index_, candidate_picker_index_); + if (candidate != nullptr && candidate[0] != '\0') + { + committed_text_ += candidate; + update_text = true; + } + consumed = true; + } + } else { if (key == LV_KEY_BACKSPACE) @@ -760,7 +972,8 @@ bool ImeWidget::handle_key_code(uint32_t key) bool ImeWidget::handle_text_token(const char* token) { - if (!textarea_ || !token || token[0] == '\0' || !direct_keyboard_mode()) + if (!textarea_ || !token || token[0] == '\0' || + (!direct_keyboard_mode() && !candidate_picker_mode())) { return false; } @@ -835,6 +1048,12 @@ void ImeWidget::refresh_touch_keyboard() map = kTouchRuCyrillicMap; font = ::ui::fonts::content_font(kRuCyrillicFontProbe, ::ui::fonts::ui_chrome_font()); } + else if (candidate_picker_mode()) + { + map = kTouchCandidatePickerMap; + const char* first_candidate = candidate_picker_candidate_at(script_input_index_, 0); + font = ::ui::fonts::content_font(first_candidate ? first_candidate : "", ::ui::fonts::ui_chrome_font()); + } lv_obj_set_style_text_font(keyboard_matrix_, font, LV_PART_ITEMS); lv_btnmatrix_set_map(keyboard_matrix_, map); } @@ -846,7 +1065,7 @@ void ImeWidget::refresh_touch_candidates() return; } - const bool show_candidates = pinyin_mode(); + const bool show_candidates = pinyin_mode() || candidate_picker_mode(); if (!show_candidates) { lv_obj_add_flag(candidate_row_, LV_OBJ_FLAG_HIDDEN); @@ -854,9 +1073,20 @@ void ImeWidget::refresh_touch_candidates() } lv_obj_clear_flag(candidate_row_, LV_OBJ_FLAG_HIDDEN); - const auto& candidates = ime_.candidates(); + const std::vector picker_candidates = + candidate_picker_mode() ? candidate_picker_candidates_vector(script_input_index_) + : std::vector{}; + const auto& candidates = candidate_picker_mode() ? picker_candidates : ime_.candidates(); const int total = static_cast(candidates.size()); - const int active = ime_.candidateIndex(); + int active = candidate_picker_mode() ? candidate_picker_index_ : ime_.candidateIndex(); + if (active < 0 || active >= total) + { + active = 0; + if (candidate_picker_mode()) + { + candidate_picker_index_ = 0; + } + } const int visible_count = static_cast(candidate_btns_.size()); if (total <= 0) @@ -932,7 +1162,7 @@ void ImeWidget::refresh_labels() return; } - if (script_mode(mode_) && active_script_input_kind() == ScriptInputKind::None) + if (script_mode(mode_) && selected_script_input_kind(script_input_index_) == ScriptInputKind::None) { mode_ = Mode::EN; ime_.setEnabled(false); @@ -940,7 +1170,7 @@ void ImeWidget::refresh_labels() if (textarea_) { - if (pinyin_mode() && ime_.hasBuffer()) + if ((pinyin_mode() && ime_.hasBuffer()) || candidate_picker_mode()) { if (lv_group_t* g = lv_group_get_default()) { @@ -992,7 +1222,7 @@ void ImeWidget::refresh_labels() return; } - const ScriptInputKind script_kind = active_script_input_kind(); + const ScriptInputKind script_kind = selected_script_input_kind(script_input_index_); if (script_kind == ScriptInputKind::RuCyrillicKeyboard) { lv_label_set_text(toggle_label_, "RU"); @@ -1009,6 +1239,26 @@ void ImeWidget::refresh_labels() return; } + if (script_kind == ScriptInputKind::CandidatePicker) + { + lv_label_set_text(toggle_label_, "IM"); +#if UI_SHARED_TOUCH_IME_ENABLED + if (touch_keyboard_enabled_) + { + const char* display_name = candidate_picker_display_name(script_input_index_); + set_candidates_label_text(candidates_label_, + display_name && display_name[0] != '\0' + ? display_name + : ::ui::i18n::tr("Input picker")); + refresh_touch_keyboard(); + refresh_touch_candidates(); + return; + } +#endif + refresh_candidates(); + return; + } + lv_label_set_text(toggle_label_, "CN"); #if UI_SHARED_TOUCH_IME_ENABLED if (touch_keyboard_enabled_) @@ -1034,7 +1284,24 @@ void ImeWidget::refresh_candidates() } if (!pinyin_mode() || !ime_.hasBuffer()) { - set_candidates_label_text(candidates_label_, ""); + if (candidate_picker_mode()) + { + if (candidate_picker_index_ < 0 || + candidate_picker_index_ >= candidate_picker_candidate_count(script_input_index_)) + { + candidate_picker_index_ = 0; + } + std::string text = + make_candidates_text(candidate_picker_candidates_vector(script_input_index_), + candidate_picker_index_, + kCompactCandidatesPerPage, + " "); + set_candidates_label_text(candidates_label_, text.c_str()); + } + else + { + set_candidates_label_text(candidates_label_, ""); + } return; } std::string text = @@ -1080,11 +1347,35 @@ void ImeWidget::on_touch_key_event(lv_event_t* e) } const char* token = lv_btnmatrix_get_btn_text(self->keyboard_matrix_, button_id); - if (self->direct_keyboard_mode() && !touch_token_is_action(token)) + if ((self->direct_keyboard_mode() || self->candidate_picker_mode()) && + !touch_token_is_action(token)) { (void)self->handle_text_token(token); return; } + if (self->candidate_picker_mode() && token != nullptr) + { + if (std::strcmp(token, "Prev") == 0) + { + self->handle_key_code(LV_KEY_LEFT); + return; + } + if (std::strcmp(token, "Next") == 0) + { + self->handle_key_code(LV_KEY_RIGHT); + return; + } + if (std::strcmp(token, "Pick") == 0) + { + self->handle_key_code(LV_KEY_ENTER); + return; + } + if (std::strcmp(token, "Space") == 0) + { + (void)self->handle_text_token(" "); + return; + } + } uint32_t key = 0; if (!translate_touch_token(token, key)) diff --git a/packs/emoji/DESCRIPTION.txt b/packs/emoji/DESCRIPTION.txt new file mode 100644 index 00000000..0ecc4218 --- /dev/null +++ b/packs/emoji/DESCRIPTION.txt @@ -0,0 +1,17 @@ +Emoji installs an optional common-emoji content supplement font pack and a +small emoji picker input method. + +It is not a locale. It does not change translated UI text or the active +language. The font pack exists so received chat messages containing common +reaction emoji can render instead of showing missing-glyph boxes. The IME pack +exists so devices with the shared compose widget can insert the same basic +reaction set. + +Included payload: +- emoji-core: content-only LVGL binfont generated from Noto Emoji Monochrome + with a small reaction-focused charset. +- emoji-picker: generic candidate picker backend with candidates stored in + pack payload. + +This package is intentionally distributed as an Extensions download. It is not +part of the default firmware image. diff --git a/packs/emoji/README.md b/packs/emoji/README.md new file mode 100644 index 00000000..376fa30d --- /dev/null +++ b/packs/emoji/README.md @@ -0,0 +1,40 @@ +# Emoji Extension + +This bundle provides optional emoji support for Trail Mate chat content. + +It is a content/input extension, not a locale: + +- font pack: `emoji-core` +- IME pack: `emoji-picker` +- package type: `content-bundle` + +The runtime uses `emoji-core` as a content supplement when installed and when +the active memory profile allows supplement fonts. The picker uses the generic +`builtin-candidate-picker` backend and reads its candidates from pack payload. + +## Source Font + +`emoji-core` is generated from Noto Emoji Monochrome: + +- file: `tools/fonts/NotoEmoji-Regular.ttf` +- upstream: `https://github.com/zjaco13/Noto-Emoji-Monochrome` +- original project: `https://github.com/googlefonts/noto-emoji` +- license: SIL Open Font License 1.1 + +The generated `font.bin` is intentionally not committed. Pages/package builds +regenerate it from `charset.txt` and `build.ini`. + +## Build + +```bash +python scripts/build_pack_repository.py --pack-root packs --site-root site +``` + +The resulting archive installs runtime payload under: + +```text +/trailmate/packs/fonts/emoji-core/manifest.ini +/trailmate/packs/fonts/emoji-core/ranges.txt +/trailmate/packs/fonts/emoji-core/font.bin +/trailmate/packs/ime/emoji-picker/manifest.ini +``` diff --git a/packs/emoji/fonts/emoji-core/build.ini b/packs/emoji/fonts/emoji-core/build.ini new file mode 100644 index 00000000..3db2211a --- /dev/null +++ b/packs/emoji/fonts/emoji-core/build.ini @@ -0,0 +1,5 @@ +font=tools/fonts/NotoEmoji-Regular.ttf +size=16 +bpp=2 +no_compress=true +no_kerning=true diff --git a/packs/emoji/fonts/emoji-core/charset.txt b/packs/emoji/fonts/emoji-core/charset.txt new file mode 100644 index 00000000..8c7b8161 --- /dev/null +++ b/packs/emoji/fonts/emoji-core/charset.txt @@ -0,0 +1 @@ +👍👎❤❤️😂😮😢😡🔥🎉✅❌🙏👏💪👀📍💬⭐❓❗🙂😀😉😎👌🙌🚩⚠⚠️☑☑️☹☹️😅😍😆🤣😃😄😁😐😕🤔🥳✨ diff --git a/packs/emoji/fonts/emoji-core/manifest.ini b/packs/emoji/fonts/emoji-core/manifest.ini new file mode 100644 index 00000000..f47c1f97 --- /dev/null +++ b/packs/emoji/fonts/emoji-core/manifest.ini @@ -0,0 +1,8 @@ +kind=font +id=emoji-core +display_name=Emoji Core +usage=content +estimated_ram_bytes=4096 +source=binfont +file=font.bin +ranges=ranges.txt diff --git a/packs/emoji/fonts/emoji-core/ranges.txt b/packs/emoji/fonts/emoji-core/ranges.txt new file mode 100644 index 00000000..eac5499d --- /dev/null +++ b/packs/emoji/fonts/emoji-core/ranges.txt @@ -0,0 +1,24 @@ +U+203C +U+2049 +U+2122 +U+2139 +U+2194-U+21AA +U+231A-U+231B +U+2328 +U+23CF +U+23E9-U+23F3 +U+23F8-U+23FA +U+24C2 +U+25AA-U+25AB +U+25B6 +U+25C0 +U+25FB-U+25FE +U+2600-U+27BF +U+2934-U+2935 +U+2B05-U+2B55 +U+3030 +U+303D +U+3297 +U+3299 +U+1F300-U+1FAFF +U+FE0E-U+FE0F diff --git a/packs/emoji/ime/emoji-picker/candidates.txt b/packs/emoji/ime/emoji-picker/candidates.txt new file mode 100644 index 00000000..0412fb75 --- /dev/null +++ b/packs/emoji/ime/emoji-picker/candidates.txt @@ -0,0 +1,42 @@ +👍 +👎 +❤ +😂 +😮 +😢 +😡 +🔥 +🎉 +✅ +❌ +🙏 +👏 +💪 +👀 +📍 +💬 +⭐ +❓ +❗ +🙂 +😀 +😉 +😎 +👌 +🙌 +🚩 +⚠ +☑ +☹ +😅 +😍 +😆 +🤣 +😃 +😄 +😁 +😐 +😕 +🤔 +🥳 +✨ diff --git a/packs/emoji/ime/emoji-picker/manifest.ini b/packs/emoji/ime/emoji-picker/manifest.ini new file mode 100644 index 00000000..59e4718c --- /dev/null +++ b/packs/emoji/ime/emoji-picker/manifest.ini @@ -0,0 +1,5 @@ +kind=ime +id=emoji-picker +display_name=Emoji Picker +backend=builtin-candidate-picker +candidates=candidates.txt diff --git a/packs/emoji/package.ini b/packs/emoji/package.ini new file mode 100644 index 00000000..1fe0a6fc --- /dev/null +++ b/packs/emoji/package.ini @@ -0,0 +1,13 @@ +kind=package +id=emoji +package_type=content-bundle +version=1.0.0 +display_name=Emoji +summary=Optional common emoji font supplement and emoji picker input method for chat reactions. +description=DESCRIPTION.txt +readme=README.md +author=Trail Mate +homepage=https://vicliu624.github.io/trail-mate/ +min_firmware_version=0.1.30-alpha +supported_memory_profiles=standard,extended +tags=emoji,chat,input,content diff --git a/platform/linux/common/src/ui/localization.cpp b/platform/linux/common/src/ui/localization.cpp index 71884ba1..8618da53 100644 --- a/platform/linux/common/src/ui/localization.cpp +++ b/platform/linux/common/src/ui/localization.cpp @@ -103,6 +103,19 @@ const ImeInfo* ime_at(std::size_t index) return nullptr; } +std::size_t ime_candidate_count(const char* ime_id) +{ + (void)ime_id; + return 0; +} + +const char* ime_candidate_at(const char* ime_id, std::size_t index) +{ + (void)ime_id; + (void)index; + return nullptr; +} + std::size_t enabled_ime_count() { return 0; diff --git a/site/data/packs.json b/site/data/packs.json index 590f7eab..3cc55b72 100644 --- a/site/data/packs.json +++ b/site/data/packs.json @@ -1,7 +1,7 @@ { "schema_version": 1, "archive_layout": "trailmate-pack-v1", - "generated_at": "2026-06-13T13:06:44.045861Z", + "generated_at": "2026-06-16T21:20:43.050954Z", "packages": [ { "id": "ar", @@ -65,6 +65,63 @@ "readme": "README.md" } }, + { + "id": "emoji", + "package_type": "content-bundle", + "version": "1.0.0", + "display_name": "Emoji", + "summary": "Optional common emoji font supplement and emoji picker input method for chat reactions.", + "description": "Emoji installs an optional common-emoji content supplement font pack and a\nsmall emoji picker input method.\n\nIt is not a locale. It does not change translated UI text or the active\nlanguage. The font pack exists so received chat messages containing common\nreaction emoji can render instead of showing missing-glyph boxes. The IME pack\nexists so devices with the shared compose widget can insert the same basic\nreaction set.\n\nIncluded payload:\n- emoji-core: content-only LVGL binfont generated from Noto Emoji Monochrome\n with a small reaction-focused charset.\n- emoji-picker: generic candidate picker backend with candidates stored in\n pack payload.\n\nThis package is intentionally distributed as an Extensions download. It is not\npart of the default firmware image.", + "author": "Trail Mate", + "homepage": "https://vicliu624.github.io/trail-mate/", + "min_firmware_version": "0.1.30-alpha", + "supported_memory_profiles": [ + "standard", + "extended" + ], + "tags": [ + "emoji", + "chat", + "input", + "content" + ], + "provides": { + "locales": [], + "fonts": [ + { + "id": "emoji-core", + "display_name": "Emoji Core", + "usage": "content", + "estimated_ram_bytes": 4096 + } + ], + "ime": [ + { + "id": "emoji-picker", + "display_name": "Emoji Picker", + "backend": "builtin-candidate-picker", + "layout": "" + } + ] + }, + "runtime": { + "estimated_unique_font_ram_bytes": 4096, + "locale_count": 0, + "font_count": 1, + "ime_count": 1 + }, + "archive": { + "layout": "trailmate-pack-v1", + "path": "assets/packs/emoji-1.0.0.zip", + "size_bytes": 4880, + "sha256": "fa30d0b7566c4945ea7f251b873b2aa90d0d3c29b5bbd18c6e3c3dde7d9c4e48" + }, + "files": { + "package_manifest": "package.ini", + "description": "DESCRIPTION.txt", + "readme": "README.md" + } + }, { "id": "europe-cyrillic-ext", "package_type": "locale-bundle", diff --git a/tools/fonts/NotoEmoji-Regular.ttf b/tools/fonts/NotoEmoji-Regular.ttf new file mode 100644 index 00000000..5c902c08 Binary files /dev/null and b/tools/fonts/NotoEmoji-Regular.ttf differ diff --git a/tools/fonts/README.md b/tools/fonts/README.md index b0270824..1c92eaed 100644 --- a/tools/fonts/README.md +++ b/tools/fonts/README.md @@ -16,6 +16,15 @@ This directory stores source vector fonts used to generate Trail Mate font asset `https://raw.githubusercontent.com/googlefonts/noto-fonts/main/unhinted/otf/NotoNaskhArabic/NotoNaskhArabic-Regular.otf` - License: SIL Open Font License 1.1 (OFL) +## Noto Emoji Monochrome + +- File: `NotoEmoji-Regular.ttf` +- Source: + `https://raw.githubusercontent.com/zjaco13/Noto-Emoji-Monochrome/main/fonts/NotoEmoji-Regular.ttf` +- Upstream project: + `https://github.com/googlefonts/noto-emoji` +- License: SIL Open Font License 1.1 (OFL) + ## Bitmap Compression Tool Use the in-repo tool to convert an existing LVGL bitmap font (`lv_font_conv` output)