diff --git a/button.ts b/button.ts index 043e4b6..34355f3 100644 --- a/button.ts +++ b/button.ts @@ -4,7 +4,6 @@ namespace ui { const BUTTON_FOCUS_LABEL_OFFSET = 1 const BUTTON_FOCUS_LABEL_PADDING = 1 const BUTTON_CONTENT_GAP = 3 - const BUTTON_DEFAULT_FONT = bitmaps.font8 const BUTTON_CONTROL_HORIZONTAL_PADDING = 16 const BUTTON_CONTROL_VERTICAL_PADDING = 8 const BUTTON_CONTROL_MIN_WIDTH = 24 @@ -132,7 +131,7 @@ namespace ui { content: UiControlContent, style?: UiButtonStyle, ): number { - const font = (style && style.font) || BUTTON_DEFAULT_FONT + const font = (style && style.font) || locFont() const text = content.text || "" const textWidth = text.length ? font.charWidth * text.length : 0 const bitmapWidth = content.bitmap ? content.bitmap.width : 0 @@ -151,7 +150,7 @@ namespace ui { content: UiControlContent, style?: UiButtonStyle, ): number { - const font = (style && style.font) || BUTTON_DEFAULT_FONT + const font = (style && style.font) || locFont() const text = content.text || "" const textHeight = text.length ? font.charHeight : 0 const bitmapHeight = content.bitmap ? content.bitmap.height : 0 @@ -263,7 +262,7 @@ namespace ui { this.contentRect(rect, content, style, contentRect) const bitmap = content.bitmap const text = this.contentText(content, style) - const font = style.font || BUTTON_DEFAULT_FONT + const font = style.font || locFont() const color = style.color !== undefined ? style.color : 15 const graphicWidth = bitmap ? bitmap.width : 0 @@ -365,7 +364,7 @@ namespace ui { } const text = this.focusLabelText(content, style, focusLabelText) if (text.length == 0) return - const font = style.font || BUTTON_DEFAULT_FONT + const font = style.font || locFont() const textWidth = font.charWidth * text.length const textHeight = font.charHeight const padding = BUTTON_FOCUS_LABEL_PADDING @@ -410,7 +409,7 @@ namespace ui { style: UiButtonStyle, output: Rect, ): void { - const font = style.font || BUTTON_DEFAULT_FONT + const font = style.font || locFont() const text = this.contentText(content, style) const textWidth = text.length > 0 ? font.charWidth * text.length : 0 const textHeight = text.length > 0 ? font.charHeight : 0 diff --git a/label.ts b/label.ts index adaf93a..0595c30 100644 --- a/label.ts +++ b/label.ts @@ -1,5 +1,4 @@ namespace ui { - const LABEL_DEFAULT_FONT = bitmaps.font8 const LABEL_CONTENT_GAP = 3 /** @@ -63,7 +62,7 @@ namespace ui { this.content_ = undefined this.color_ = color !== undefined ? color : 1 this.backgroundColor_ = undefined - this.font_ = LABEL_DEFAULT_FONT + this.font_ = locFont() this.width_ = 0 this.height_ = 0 } else { @@ -72,7 +71,7 @@ namespace ui { this.content_ = options this.color_ = options.color !== undefined ? options.color : 1 this.backgroundColor_ = options.backgroundColor - this.font_ = options.font || LABEL_DEFAULT_FONT + this.font_ = options.font || locFont() this.width_ = _uiControls.sizeWidth(options.size, 0) this.height_ = _uiControls.sizeHeight(options.size, 0) } diff --git a/locales/README.md b/locales/README.md new file mode 100644 index 0000000..2385a7c --- /dev/null +++ b/locales/README.md @@ -0,0 +1,25 @@ +# Locale catalogs + +Each catalog is a flat JSON object keyed by the English source string that +appears inside a `ui.loc(...)` / `ui.locc(...)` / `ui.locf(...)` call, mapping +that key to its translation for one language. + +- `en.json` is generated -- run `npm run loc:strings` to regenerate it + from the `files` listed in `pxt.json`; do not edit it by hand. +- Every other file (`fr.json`, ...) is the translation catalog for one + language, keyed by the same English strings. A key missing from a catalog + falls back to the English string at runtime, so partial catalogs are safe. + +Key-cap translations render on fixed-width keys, so keep them at most one +character longer than the English source (for example `space` -> `espace`, +`DEL` -> `SUP`). + +## Adding a new caption + +Wrap the caption literal in `ui.loc(...)` where the control renders it, then run +`npm run loc:strings` to regenerate `en.json`. Add translations to the +`.json` catalogs, keyed by the source string. + +An application that consumes this library can override any of these translations +from its own catalogs: the app catalog layer wins over the library layers when +they are merged for a build. diff --git a/locales/ca.json b/locales/ca.json new file mode 100644 index 0000000..75ffafd --- /dev/null +++ b/locales/ca.json @@ -0,0 +1,10 @@ +{ + "#+=": "#+=", + "+/-": "+/-", + "123": "123", + "ABC": "ABC", + "DEL": "SUP", + "OK": "OK", + "abc": "abc", + "space": "espai" +} diff --git a/locales/cs.json b/locales/cs.json new file mode 100644 index 0000000..0e8783d --- /dev/null +++ b/locales/cs.json @@ -0,0 +1,10 @@ +{ + "123": "123", + "#+=": "#+=", + "+/-": "+/-", + "ABC": "ABC", + "DEL": "SMAŽ", + "OK": "OK", + "abc": "abc", + "space": "mezera" +} diff --git a/locales/cy.json b/locales/cy.json new file mode 100644 index 0000000..1dad7a1 --- /dev/null +++ b/locales/cy.json @@ -0,0 +1,10 @@ +{ + "123": "123", + "#+=": "#+=", + "+/-": "+/-", + "ABC": "ABC", + "DEL": "DIL", + "OK": "OK", + "abc": "abc", + "space": "bwlch" +} diff --git a/locales/de.json b/locales/de.json new file mode 100644 index 0000000..20c290a --- /dev/null +++ b/locales/de.json @@ -0,0 +1,10 @@ +{ + "123": "123", + "#+=": "#+=", + "+/-": "+/-", + "ABC": "ABC", + "DEL": "ENTF", + "OK": "OK", + "abc": "abc", + "space": "Leer" +} diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..6396357 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,10 @@ +{ + "123": "123", + "#+=": "#+=", + "+/-": "+/-", + "ABC": "ABC", + "DEL": "DEL", + "OK": "OK", + "abc": "abc", + "space": "space" +} diff --git a/locales/es-ES.json b/locales/es-ES.json new file mode 100644 index 0000000..00d4f98 --- /dev/null +++ b/locales/es-ES.json @@ -0,0 +1,10 @@ +{ + "#+=": "#+=", + "+/-": "+/-", + "123": "123", + "ABC": "ABC", + "DEL": "SUP", + "OK": "OK", + "abc": "abc", + "space": "espacio" +} diff --git a/locales/es-MX.json b/locales/es-MX.json new file mode 100644 index 0000000..00d4f98 --- /dev/null +++ b/locales/es-MX.json @@ -0,0 +1,10 @@ +{ + "#+=": "#+=", + "+/-": "+/-", + "123": "123", + "ABC": "ABC", + "DEL": "SUP", + "OK": "OK", + "abc": "abc", + "space": "espacio" +} diff --git a/locales/eu.json b/locales/eu.json new file mode 100644 index 0000000..74b92ce --- /dev/null +++ b/locales/eu.json @@ -0,0 +1,10 @@ +{ + "123": "123", + "#+=": "#+=", + "+/-": "+/-", + "ABC": "ABC", + "DEL": "EZAB", + "OK": "OK", + "abc": "abc", + "space": "tartea" +} diff --git a/locales/fil.json b/locales/fil.json new file mode 100644 index 0000000..1be41e9 --- /dev/null +++ b/locales/fil.json @@ -0,0 +1,10 @@ +{ + "123": "123", + "#+=": "#+=", + "+/-": "+/-", + "ABC": "ABC", + "DEL": "BURA", + "OK": "OK", + "abc": "abc", + "space": "puwang" +} diff --git a/locales/fr-CA.json b/locales/fr-CA.json new file mode 100644 index 0000000..f8f558d --- /dev/null +++ b/locales/fr-CA.json @@ -0,0 +1,10 @@ +{ + "#+=": "#+=", + "+/-": "+/-", + "123": "123", + "ABC": "ABC", + "DEL": "SUP", + "OK": "OK", + "abc": "abc", + "space": "espace" +} diff --git a/locales/fr.json b/locales/fr.json new file mode 100644 index 0000000..08bb2bf --- /dev/null +++ b/locales/fr.json @@ -0,0 +1,10 @@ +{ + "123": "123", + "#+=": "#+=", + "+/-": "+/-", + "ABC": "ABC", + "DEL": "SUP", + "OK": "OK", + "abc": "abc", + "space": "espace" +} diff --git a/locales/hr.json b/locales/hr.json new file mode 100644 index 0000000..b3f1ee6 --- /dev/null +++ b/locales/hr.json @@ -0,0 +1,10 @@ +{ + "123": "123", + "#+=": "#+=", + "+/-": "+/-", + "ABC": "ABC", + "DEL": "DEL", + "OK": "OK", + "abc": "abc", + "space": "razmak" +} diff --git a/locales/hu.json b/locales/hu.json new file mode 100644 index 0000000..278c8c5 --- /dev/null +++ b/locales/hu.json @@ -0,0 +1,10 @@ +{ + "123": "123", + "#+=": "#+=", + "+/-": "+/-", + "ABC": "ABC", + "DEL": "TÖRL", + "OK": "OK", + "abc": "abc", + "space": "szóköz" +} diff --git a/locales/it.json b/locales/it.json new file mode 100644 index 0000000..ca01e9d --- /dev/null +++ b/locales/it.json @@ -0,0 +1,10 @@ +{ + "#+=": "#+=", + "+/-": "+/-", + "123": "123", + "ABC": "ABC", + "DEL": "CANC", + "OK": "OK", + "abc": "abc", + "space": "spazio" +} diff --git a/locales/nl.json b/locales/nl.json new file mode 100644 index 0000000..652448f --- /dev/null +++ b/locales/nl.json @@ -0,0 +1,10 @@ +{ + "123": "123", + "#+=": "#+=", + "+/-": "+/-", + "ABC": "ABC", + "DEL": "WIS", + "OK": "OK", + "abc": "abc", + "space": "spatie" +} diff --git a/locales/pl.json b/locales/pl.json new file mode 100644 index 0000000..fbadeee --- /dev/null +++ b/locales/pl.json @@ -0,0 +1,10 @@ +{ + "123": "123", + "#+=": "#+=", + "+/-": "+/-", + "ABC": "ABC", + "DEL": "USUŃ", + "OK": "OK", + "abc": "abc", + "space": "spacja" +} diff --git a/locales/pt-BR.json b/locales/pt-BR.json new file mode 100644 index 0000000..56e9066 --- /dev/null +++ b/locales/pt-BR.json @@ -0,0 +1,10 @@ +{ + "#+=": "#+=", + "+/-": "+/-", + "123": "123", + "ABC": "ABC", + "DEL": "APAG", + "OK": "OK", + "abc": "abc", + "space": "espaço" +} diff --git a/locales/pt-PT.json b/locales/pt-PT.json new file mode 100644 index 0000000..56e9066 --- /dev/null +++ b/locales/pt-PT.json @@ -0,0 +1,10 @@ +{ + "#+=": "#+=", + "+/-": "+/-", + "123": "123", + "ABC": "ABC", + "DEL": "APAG", + "OK": "OK", + "abc": "abc", + "space": "espaço" +} diff --git a/locales/ru.json b/locales/ru.json new file mode 100644 index 0000000..10cc315 --- /dev/null +++ b/locales/ru.json @@ -0,0 +1,10 @@ +{ + "123": "123", + "#+=": "#+=", + "+/-": "+/-", + "ABC": "ABC", + "DEL": "СТЕР", + "OK": "OK", + "abc": "abc", + "space": "пробел" +} diff --git a/locales/tr.json b/locales/tr.json new file mode 100644 index 0000000..482a71f --- /dev/null +++ b/locales/tr.json @@ -0,0 +1,10 @@ +{ + "123": "123", + "#+=": "#+=", + "+/-": "+/-", + "ABC": "ABC", + "DEL": "SİL", + "OK": "OK", + "abc": "abc", + "space": "boşluk" +} diff --git a/modal-grid.ts b/modal-grid.ts index 76e8fe9..cc39f90 100644 --- a/modal-grid.ts +++ b/modal-grid.ts @@ -409,6 +409,7 @@ namespace ui { this.style_ && this.style_.color !== undefined ? this.style_.color : 1, + font: locFont(), }) this.renderControls( surface, @@ -502,7 +503,7 @@ namespace ui { const text = controls[i].text || "" textLength = Math.max(textLength, text.length) } - return Math.max(24, textLength * bitmaps.font8.charWidth + 16) + return Math.max(24, textLength * locFont().charWidth + 16) } private titleHeight(): number { @@ -779,7 +780,7 @@ namespace ui { ? this.title_ : this.title_.text let width = titleText - ? titleText.length * bitmaps.font8.charWidth + ? titleText.length * locFont().charWidth : 0 const bitmap = this.title_ !== undefined && typeof this.title_ != "string" diff --git a/numeric-entry.ts b/numeric-entry.ts index 732ebfc..a3aa660 100644 --- a/numeric-entry.ts +++ b/numeric-entry.ts @@ -1,5 +1,4 @@ namespace ui { - const NUMERIC_ENTRY_FONT = bitmaps.font8 const UI_NUMERIC_ENTRY_FLAG_DELETE_ENABLED = 1 const UI_NUMERIC_ENTRY_FLAG_CANCEL_ENABLED = 2 @@ -190,7 +189,7 @@ namespace ui { surface, rect, this.text_, - NUMERIC_ENTRY_FONT, + locFont(), ) } @@ -967,10 +966,10 @@ namespace ui { private keyTextForKey(key: UiNumericEntryModalKeyValue): string { if (key >= 0 && key <= 9) return "" + key if (key == UI_NUMERIC_ENTRY_KEY_DECIMAL_POINT) return "." - if (key == UI_NUMERIC_ENTRY_KEY_TOGGLE_SIGN) return "+/-" + if (key == UI_NUMERIC_ENTRY_KEY_TOGGLE_SIGN) return loc("+/-") if (key == UI_NUMERIC_ENTRY_KEY_BACKSPACE) return "<-" - if (key == UI_NUMERIC_ENTRY_KEY_DELETE) return "DEL" - return "OK" + if (key == UI_NUMERIC_ENTRY_KEY_DELETE) return loc("DEL") + return loc("OK") } } } diff --git a/package.json b/package.json index e68f6dc..4ae4f11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "scripts": { - "format": "prettier --write \"**/*.ts\"" + "format": "prettier --write \"**/*.ts\"", + "loc:strings": "node scripts/locstrings.mjs" }, "devDependencies": { "prettier": "^3.8.3" diff --git a/pxt.json b/pxt.json index a5e5bc0..00e2260 100644 --- a/pxt.json +++ b/pxt.json @@ -31,7 +31,7 @@ "radio": "*", "microphone": "*", "display-shield": "github:microbit-apps/display-shield#v1.1.4", - "ui-core": "github:microbit-apps/ui-core#v0.0.4" + "ui-core": "github:microbit-apps/ui-core#v0.0.5" }, "testDependencies": {} } diff --git a/scripts/locstrings.mjs b/scripts/locstrings.mjs new file mode 100644 index 0000000..6c1e271 --- /dev/null +++ b/scripts/locstrings.mjs @@ -0,0 +1,158 @@ +// Regenerates locales/en.json from source. +// +// Scans the .ts files listed in pxt.json "files" (so test files are excluded) +// and extracts the source display strings passed to the localization helpers: +// +// ui.loc("...") / loc("...") +// ui.locf("...") / locf("...") (first argument only) +// ui.locc("ctx", "...") / locc("ctx", "...") (emits key "ctx#string") +// +// Arguments must be string literals at the call site (the documented +// convention; see locales/README.md). Any other argument is reported as a +// loud warning naming the file and the argument, so a missed string is never +// dropped silently. A call whose argument is deliberately dynamic (its +// strings reach the catalog by another route) is suppressed by putting +// "locstrings-ignore" in a comment on the call's line. +// +// The result is a sorted JSON object mapping each catalog key to its source +// display string. For locc entries the value is the plain string, without the +// context prefix. The file is fully derived: it is regenerated from source, so +// any prior keys not found in the current sources are dropped. + +import { readFileSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +const root = join(dirname(fileURLToPath(import.meta.url)), "..") + +// A double- or single-quoted string literal, honoring escaped quotes. +const STR = `"(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*'` +// One helper argument: a string literal, or any run up to the next comma or +// close paren. The string alternative is tried first so commas or parens +// inside a literal do not truncate the capture. +const ARG = `(?:${STR}|[^,)]*)` + +// Guards for false call-site matches: a helper definition (`function +// loc(...)`) is not a call, and a preceding identifier character or dot means +// the name is a property access (obj.loc) or a longer identifier (myloc), +// not the helper. +const DEF = `(? { + if (ch == "n") return "\n" + if (ch == "t") return "\t" + if (ch == "r") return "\r" + return ch + }) +} + +let warnings = 0 + +// Resolves a captured helper argument to its string value, or null (with a +// warning) when it is not a string literal. +function resolveArg(raw, file) { + const arg = raw.trim() + if (arg == "") return null + if (STR_ONLY.test(arg)) return unquote(arg) + console.warn(`locstrings: WARNING: ${file}: cannot resolve loc argument '${arg}'`) + warnings++ + return null +} + +const pxt = JSON.parse(readFileSync(join(root, "pxt.json"), "utf8")) +const catalog = {} + +// The file that implements the localization API passes its own parameters +// through loc(); it defines the helpers rather than consuming them, so it is +// not a source of catalog keys. +const API_DEF_RE = new RegExp(`\\bexport\\s+function\\s+loc\\s*\\(`) + +for (const file of pxt.files) { + if (!file.endsWith(".ts")) continue + const raw = readFileSync(join(root, file), "utf8") + .split("\n") + .filter(line => !line.includes("locstrings-ignore")) + .join("\n") + const src = stripComments(raw) + if (API_DEF_RE.test(src)) continue + + for (const m of src.matchAll(LOCC_RE)) { + const ctx = resolveArg(m[1], file) + const str = resolveArg(m[2], file) + if (ctx == null || str == null) continue + catalog[ctx + "#" + str] = str + } + for (const m of src.matchAll(LOC_RE)) { + const str = resolveArg(m[1], file) + if (str == null) continue + catalog[str] = str + } +} + +const sorted = {} +for (const key of Object.keys(catalog).sort()) sorted[key] = catalog[key] + +writeFileSync( + join(root, "locales", "en.json"), + JSON.stringify(sorted, null, 4) + "\n", +) + +const count = Object.keys(sorted).length +console.log(`locstrings: wrote ${count} key(s) to locales/en.json`) +if (warnings) console.log(`locstrings: ${warnings} unresolved loc argument(s); see warnings above`) diff --git a/scripts/sync-local-pxt-deps.js b/scripts/sync-local-pxt-deps.js new file mode 100644 index 0000000..fae9f07 --- /dev/null +++ b/scripts/sync-local-pxt-deps.js @@ -0,0 +1,181 @@ +const fs = require("fs") +const os = require("os") +const path = require("path") + +const projectRoot = path.resolve(__dirname, "..") +const cacheRoots = [] + +function addCacheRoot(root) { + if (!root) return + const resolved = path.resolve(root) + if (cacheRoots.indexOf(resolved) < 0) cacheRoots.push(resolved) +} + +if (process.env.PXT_CACHE_DIR) { + addCacheRoot(process.env.PXT_CACHE_DIR) +} else { + addCacheRoot(path.join(os.homedir(), ".pxt", "mkc-cache")) + addCacheRoot(path.join(projectRoot, ".pxt", "mkc-cache")) +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")) +} + +function isGitHubSpec(spec) { + return typeof spec == "string" && spec.indexOf("github:") == 0 +} + +function cacheNameForGitHubSpec(spec) { + const ref = spec.substring("github:".length) + let encoded = "" + for (let i = 0; i < ref.length; i++) { + const ch = ref.charAt(i) + if ( + (ch >= "a" && ch <= "z") || + (ch >= "A" && ch <= "Z") || + (ch >= "0" && ch <= "9") || + ch == "-" || + ch == "_" || + ch == "." + ) { + encoded += ch + } else { + encoded += "_" + ref.charCodeAt(i) + "_" + } + } + return "c-gh-pkg-" + encoded +} + +function findLocalPackageRoot(name, dependentRoot) { + const candidates = [ + path.join(dependentRoot, "pxt_modules", name), + path.join(projectRoot, "pxt_modules", name), + ] + + for (const candidate of candidates) { + if ( + fs.existsSync(path.join(candidate, "pxt.json")) && + fs.lstatSync(candidate).isSymbolicLink() + ) { + return candidate + } + } + + return undefined +} + +function findResolvedPackageRoot(name, dependentRoot) { + const candidates = [ + path.join(dependentRoot, "pxt_modules", name), + path.join(projectRoot, "pxt_modules", name), + ] + + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, "pxt.json"))) return candidate + } + + return undefined +} + +function pxtFileNames(pxt) { + const seen = {} + const names = ["pxt.json"] + const add = name => { + if (!name || seen[name]) return + seen[name] = true + names.push(name) + } + + ;(pxt.files || []).forEach(add) + return names +} + +function packPackage(root) { + const pxt = readJson(path.join(root, "pxt.json")) + const files = {} + const names = pxtFileNames(pxt) + + for (const name of names) { + files[name] = fs.readFileSync(path.join(root, name), "utf8") + } + + return { pxt, blob: { files }, count: names.length } +} + +function writeCacheBlob(cachePath, blob) { + fs.mkdirSync(path.dirname(cachePath), { recursive: true }) + if (fs.existsSync(cachePath) && !fs.existsSync(cachePath + ".remote")) { + fs.copyFileSync(cachePath, cachePath + ".remote") + } + fs.writeFileSync(cachePath, JSON.stringify(blob)) +} + +function writeCacheBlobs(cacheName, blob) { + const paths = [] + for (let i = 0; i < cacheRoots.length; i++) { + const cachePath = path.join(cacheRoots[i], cacheName) + writeCacheBlob(cachePath, blob) + paths.push(cachePath) + } + return paths +} + +function syncDependency(name, spec, dependentRoot, synced, visited) { + if (!isGitHubSpec(spec)) return + + const cacheName = cacheNameForGitHubSpec(spec) + if (visited[cacheName]) return + visited[cacheName] = true + + const localRoot = findLocalPackageRoot(name, dependentRoot) + const resolvedRoot = localRoot || findResolvedPackageRoot(name, dependentRoot) + + if (!resolvedRoot) return + const packed = packPackage(resolvedRoot) + + if (localRoot) { + const cachePaths = writeCacheBlobs(cacheName, packed.blob) + synced[cacheName] = true + + console.log( + name + + " -> " + + cachePaths.join(", ") + + " (" + + packed.count + + " files from " + + localRoot + + ")" + ) + } + + const dependencies = packed.pxt.dependencies || {} + Object.keys(dependencies).forEach(childName => { + syncDependency( + childName, + dependencies[childName], + resolvedRoot, + synced, + visited, + ) + }) +} + +function main() { + const pxt = readJson(path.join(projectRoot, "pxt.json")) + const dependencies = pxt.dependencies || {} + const synced = {} + const visited = {} + + console.log("cache roots: " + cacheRoots.join(", ")) + + Object.keys(dependencies).forEach(name => { + syncDependency(name, dependencies[name], projectRoot, synced, visited) + }) + + const count = Object.keys(synced).length + console.log("synced " + count + " local PXT package cache blob(s)") +} + +main() diff --git a/test.ts b/test.ts index 229d8f0..3070038 100644 --- a/test.ts +++ b/test.ts @@ -1785,11 +1785,92 @@ namespace ui.controls.test { "text modal custom callback", ) } + + /** + * Smoke harness for control-caption localization and the localized default + * font. Verifies that with no catalog the English source captions render + * unchanged, that an assigned catalog replaces a rendered caption, and that + * an assigned default font drives control measurement. + */ + export function runLocalizationSmokeTest(): void { + // Baseline: no catalog and no default font means identity behavior. + _loc.table = undefined + _loc.defaultFont = undefined + control.assert(loc("OK") == "OK", "loc caption identity no table") + control.assert(loc("space") == "space", "loc space identity no table") + + const modal = new UiTextEntryModal({ + modalScopeId: "loc-text-modal", + allowWhitespace: true, + }) + modal.measure({ maxWidth: 160, maxHeight: 120 }, new UiMeasuredSize()) + modal.arrange(new Rect(0, 0, 160, 67)) + const modalFocus = new UiFocusState() + const modalController = new UiFocusInputController(modalFocus) + modalFocus.setScope({ id: "loc-text-parent" }) + modalFocus.setActiveScope("loc-text-parent") + modal.open(modalFocus, modalController) + + const srcSurface = new ControlSmokeSurface() + modal.render(srcSurface, new ControlSmokeAssets(), modalFocus) + control.assert( + srcSurface.log.indexOf("text:OK;") >= 0, + "text modal renders source enter caption", + ) + control.assert( + srcSurface.log.indexOf("text:space;") >= 0, + "text modal renders source space caption", + ) + + // Catalog hit: the same reachable render path picks up the translation. + _loc.table = { + OK: "Valider", + space: "espace", + } + const localizedSurface = new ControlSmokeSurface() + modal.render(localizedSurface, new ControlSmokeAssets(), modalFocus) + control.assert(loc("OK") == "Valider", "loc caption table hit") + control.assert( + localizedSurface.log.indexOf("text:Valider;") >= 0, + "text modal renders localized enter caption", + ) + control.assert( + localizedSurface.log.indexOf("text:espace;") >= 0, + "text modal renders localized space caption", + ) + _loc.table = undefined + + // Assigned default font drives control measurement at the use site. + const wideFont: TextFont = { + charWidth: 12, + charHeight: 5, + data: hex``, + } + _loc.defaultFont = wideFont + const measured = new UiMeasuredSize() + const localizedLabel = label("AB") + localizedLabel.measure({ maxWidth: 160, maxHeight: 120 }, measured) + control.assert( + measured.preferredWidth == 24, + "label measure uses localized default font width", + ) + _loc.defaultFont = undefined + const defaultMeasured = new UiMeasuredSize() + const defaultLabel = label("AB") + defaultLabel.measure({ maxWidth: 160, maxHeight: 120 }, defaultMeasured) + control.assert( + defaultMeasured.preferredWidth == + "AB".length * bitmaps.font8.charWidth, + "label measure reverts to font8 when default font unset", + ) + } + runControlButtonSmokeTest() runPickerSmokeTest() runScreenControllerSmokeTest() runNumericEntrySmokeTest() runTextEntrySmokeTest() + runLocalizationSmokeTest() control.__log(1, "All tests passed!") } diff --git a/text-entry.ts b/text-entry.ts index 5bcbe2e..31c7ec5 100644 --- a/text-entry.ts +++ b/text-entry.ts @@ -1,5 +1,4 @@ namespace ui { - const TEXT_ENTRY_FONT = bitmaps.font8 const TEXT_ENTRY_LOWERCASE = "abcdefghijklmnopqrstuvwxyz" const TEXT_ENTRY_UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" const TEXT_ENTRY_SYMBOLS = "-_.,!?@#" @@ -32,18 +31,20 @@ namespace ui { const UI_TEXT_ENTRY_PAGE_LETTERS = 0 const UI_TEXT_ENTRY_PAGE_DIGITS = 1 const UI_TEXT_ENTRY_PAGE_SYMBOLS = 2 + // These key styles intentionally omit `font`: the render path resolves the + // default font at use time via `ui.locFont()`. Setting it here would capture + // `bitmaps.font8` at module-init time, before the app assigns a per-language + // font, and would never pick up a localized default. const UI_TEXT_ENTRY_MODAL_ENTER_STYLE: UiButtonStyle = { backgroundColor: 1, borderColor: 7, frame: "roundedRect", - font: bitmaps.font8, textPlacement: "content", } const UI_TEXT_ENTRY_MODAL_CUSTOM_STYLE: UiButtonStyle = { backgroundColor: 1, borderColor: 10, frame: "roundedRect", - font: bitmaps.font8, textPlacement: "content", } @@ -292,7 +293,7 @@ namespace ui { surface, rect, this.text_, - TEXT_ENTRY_FONT, + locFont(), ) } @@ -434,13 +435,11 @@ namespace ui { this.keyStyle_ = options.keyStyle || buttonStyle(UiButtonStyles.LightShadowedWhite, { - font: bitmaps.font8, textPlacement: "content", }) if (this.title_ !== undefined) this.titleStyle_ = { color: options.color === undefined ? 1 : options.color, - font: bitmaps.font8, textPlacement: "content", } this.keyView_ = new UiButtonView(this.keyStyle_) @@ -874,14 +873,14 @@ namespace ui { private keyTextForKey(key: string): string { if (key == UI_TEXT_ENTRY_KEY_SHIFT) return this.flags_ & UI_TEXT_ENTRY_FLAG_UPPERCASE - ? "abc" - : "ABC" + ? loc("abc") + : loc("ABC") if (key == UI_TEXT_ENTRY_KEY_BACKSPACE) return "<-" - if (key == UI_TEXT_ENTRY_KEY_ENTER) return "OK" - if (key == UI_TEXT_ENTRY_KEY_PAGE_LETTERS) return "ABC" - if (key == UI_TEXT_ENTRY_KEY_PAGE_DIGITS) return "123" - if (key == UI_TEXT_ENTRY_KEY_PAGE_SYMBOLS) return "#+=" - if (key == UI_TEXT_ENTRY_KEY_SPACE) return "space" + if (key == UI_TEXT_ENTRY_KEY_ENTER) return loc("OK") + if (key == UI_TEXT_ENTRY_KEY_PAGE_LETTERS) return loc("ABC") + if (key == UI_TEXT_ENTRY_KEY_PAGE_DIGITS) return loc("123") + if (key == UI_TEXT_ENTRY_KEY_PAGE_SYMBOLS) return loc("#+=") + if (key == UI_TEXT_ENTRY_KEY_SPACE) return loc("space") if ( this.flags_ & (UI_TEXT_ENTRY_FLAG_UPPERCASE |