diff --git a/locales/README.md b/locales/README.md index 2385a7c..5c4276b 100644 --- a/locales/README.md +++ b/locales/README.md @@ -23,3 +23,34 @@ Wrap the caption literal in `ui.loc(...)` where the control renders it, then run 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. + +## Keyboard character sets + +`charsets.json` holds the per-language on-screen-keyboard character data. It is +consumed at build time into the text-entry `_loc` charset fields +(`alphabetLower`, `alphabetUpper`, `accentsLower`, `accentsUpper`, `symbols`), +which drive the letter, accent, and symbol pages of the text-entry keyboard. + +The file is a JSON object keyed by language code. Each language maps to an +object whose fields are all optional; an omitted field inherits the module +default (ASCII `a-z` / `A-Z` for the alphabet, `-_.,!?@#` for symbols). Base +language keys serve their regional variants (`es` serves `es-ES` and `es-MX`, +`fr` serves `fr` and `fr-CA`, `pt` serves `pt-BR` and `pt-PT`); add a regional +key only where a region genuinely differs. + +Field contracts: + +- `alphabetLower` / `alphabetUpper` are the letter page. String order is key + order. The two strings are case-parallel: equal code-point length, with the + character at each index the lower and upper form of the same letter. +- `accentsLower` / `accentsUpper` are additional accented pairs, under the same + equal-length, positionally-paired contract. When the field is non-empty they + render on a dedicated accents page reached from the keyboard's page key; they + are also accepted by the input filter and case-normalized. +- `symbols` is the symbol page, in key order. + +The letter page shows at most 39 letters (3 rows of 13); letters beyond that are +filter-accepted but not drawn. This bound drives the alphabet-vs-accents split: +an alphabet that fits within 39 places its diacritic letters in `alphabet*` at +their conventional positions, while a larger set keeps the base letters in +`alphabet*` and moves the overflow diacritic forms into `accents*`. diff --git a/locales/charsets.json b/locales/charsets.json new file mode 100644 index 0000000..eb5cc02 --- /dev/null +++ b/locales/charsets.json @@ -0,0 +1,71 @@ +{ + "fr": { + "accentsLower": "àâäçéèêëîïôöùûüÿœ", + "accentsUpper": "ÀÂÄÇÉÈÊËÎÏÔÖÙÛÜŸŒ" + }, + "es": { + "alphabetLower": "abcdefghijklmnñopqrstuvwxyz", + "alphabetUpper": "ABCDEFGHIJKLMNÑOPQRSTUVWXYZ", + "accentsLower": "áéíóúü", + "accentsUpper": "ÁÉÍÓÚÜ", + "symbols": "-_.,!?@#¡¿" + }, + "ca": { + "accentsLower": "àçéèíïóòúü", + "accentsUpper": "ÀÇÉÈÍÏÓÒÚÜ" + }, + "it": { + "accentsLower": "àèéìòù", + "accentsUpper": "ÀÈÉÌÒÙ" + }, + "pt": { + "accentsLower": "áàâãçéêíóôõú", + "accentsUpper": "ÁÀÂÃÇÉÊÍÓÔÕÚ" + }, + "de": { + "alphabetLower": "abcdefghijklmnopqrstuvwxyzäöü", + "alphabetUpper": "ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÜ" + }, + "nl": { + "accentsLower": "áéèëíïóöúü", + "accentsUpper": "ÁÉÈËÍÏÓÖÚÜ" + }, + "pl": { + "alphabetLower": "aąbcćdeęfghijklłmnńoópqrsśtuvwxyzźż", + "alphabetUpper": "AĄBCĆDEĘFGHIJKLŁMNŃOÓPQRSŚTUVWXYZŹŻ" + }, + "cs": { + "accentsLower": "áčďéěíňóřšťúůýž", + "accentsUpper": "ÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ" + }, + "ru": { + "alphabetLower": "абвгдеёжзийклмнопрстуфхцчшщъыьэюя", + "alphabetUpper": "АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ" + }, + "hr": { + "alphabetLower": "abcčćdđefghijklmnoprsštuvzž", + "alphabetUpper": "ABCČĆDĐEFGHIJKLMNOPRSŠTUVZŽ" + }, + "hu": { + "alphabetLower": "aábcdeéfghiíjklmnoóöőpqrstuúüűvwxyz", + "alphabetUpper": "AÁBCDEÉFGHIÍJKLMNOÓÖŐPQRSTUÚÜŰVWXYZ" + }, + "tr": { + "alphabetLower": "abcçdefgğhıijklmnoöprsştuüvyz", + "alphabetUpper": "ABCÇDEFGĞHIİJKLMNOÖPRSŞTUÜVYZ" + }, + "eu": { + "alphabetLower": "abcdefghijklmnñopqrstuvwxyz", + "alphabetUpper": "ABCDEFGHIJKLMNÑOPQRSTUVWXYZ", + "accentsLower": "çü", + "accentsUpper": "ÇÜ" + }, + "cy": { + "accentsLower": "âêîôûŵŷ", + "accentsUpper": "ÂÊÎÔÛŴŶ" + }, + "fil": { + "alphabetLower": "abcdefghijklmnñopqrstuvwxyz", + "alphabetUpper": "ABCDEFGHIJKLMNÑOPQRSTUVWXYZ" + } +} diff --git a/package.json b/package.json index 4ae4f11..d0a73a2 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "scripts": { "format": "prettier --write \"**/*.ts\"", - "loc:strings": "node scripts/locstrings.mjs" + "loc:strings": "loc-strings" }, "devDependencies": { - "prettier": "^3.8.3" + "prettier": "^3.8.3", + "@microbit-apps/ui-core": "github:microbit-apps/ui-core#v0.0.6" } } diff --git a/pxt.json b/pxt.json index 8112a9c..1c174d5 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.5" + "ui-core": "github:microbit-apps/ui-core#v0.0.6" }, "testDependencies": {} } diff --git a/samples.ts b/samples.ts index aeb1da0..fcf8c64 100644 --- a/samples.ts +++ b/samples.ts @@ -72,7 +72,7 @@ namespace ui.controls.samples { this.openModal( new ui.UiTextEntryModal({ modalScopeId: "name-editor", - title: "Enter your name:", + title: loc("Enter your name:"), initialText: this.name, allowWhitespace: true, allowSymbols: true, @@ -169,6 +169,37 @@ namespace ui.controls.samples { } } + // Selects a locale by assigning the localization seams directly. A + // consuming app normally assigns these from its generated per-language + // file; any code that runs before the UI is constructed can do the same, + // which is how a sample or test drives a locale. + function applySpanishLocale(): void { + _loc.table = { + "space": "espacio", + "DEL": "SUP", + "Enter your name:": "Escribe tu nombre:", + } + _loc.alphabetLower = "abcdefghijklmnñopqrstuvwxyz" + _loc.alphabetUpper = "ABCDEFGHIJKLMNÑOPQRSTUVWXYZ" + _loc.accentsLower = "áéíóúü" + _loc.accentsUpper = "ÁÉÍÓÚÜ" + _loc.symbols = "-_.,!?@#¡¿" + } + + // French keeps the default alphabet and symbols and adds an accent set, + // so it exercises the accents keyboard page. + function applyFrenchLocale(): void { + _loc.table = { + "space": "espace", + "DEL": "SUP", + "Enter your name:": "Entre ton nom :", + } + _loc.accentsLower = "àâäçéèêëîïôöùûüÿœ" + _loc.accentsUpper = "ÀÂÄÇÉÈÊËÎÏÔÖÙÛÜŸŒ" + } + + //applySpanishLocale() + applyFrenchLocale() const runtime = new ui.UiRuntime(new ui.DisplayShieldFrameAdapter()) //runtime.push(new SettingsScreen(runtime)) runtime.push(new NameEntryScreen(runtime)) diff --git a/scripts/locstrings.mjs b/scripts/locstrings.mjs deleted file mode 100644 index 6c1e271..0000000 --- a/scripts/locstrings.mjs +++ /dev/null @@ -1,158 +0,0 @@ -// 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/test.ts b/test.ts index 3070038..ae65a92 100644 --- a/test.ts +++ b/test.ts @@ -1786,6 +1786,548 @@ namespace ui.controls.test { ) } + /** + * Smoke harness for keyboard charset injection. Verifies that the default + * charset is unchanged, that an injected `_loc` alphabet renders and accepts + * a non-ASCII letter with working case toggling, that a per-modal override + * beats `_loc`, that a caseless charset hides the shift key, and that a + * symbols override drives the symbol page. + */ + export function runTextEntryCharsetSmokeTest(): void { + function resetLoc(): void { + _loc.table = undefined + _loc.alphabetLower = undefined + _loc.alphabetUpper = undefined + _loc.accentsLower = undefined + _loc.accentsUpper = undefined + _loc.symbols = undefined + } + + const assets = new ControlSmokeAssets() + // Spanish alphabet: the base 26 letters plus the n-tilde pair after n. + const esLower = "abcdefghijklmnñopqrstuvwxyz" + const esUpper = "ABCDEFGHIJKLMNÑOPQRSTUVWXYZ" + + // Group 1: no _loc charset fields means the ASCII default is untouched. + resetLoc() + const defaultModal = new UiTextEntryModal({ + modalScopeId: "charset-default", + allowDigits: true, + }) + const defaultMeasured = new UiMeasuredSize() + defaultModal.measure({ maxWidth: 160, maxHeight: 120 }, defaultMeasured) + defaultModal.arrange(new Rect(0, 0, 160, defaultMeasured.preferredHeight)) + const defaultFocus = new UiFocusState() + const defaultController = new UiFocusInputController(defaultFocus) + defaultFocus.setScope({ id: "charset-default-parent" }) + defaultFocus.setActiveScope("charset-default-parent") + defaultModal.open(defaultFocus, defaultController) + const defaultSurface = new ControlSmokeSurface() + defaultModal.render(defaultSurface, assets, defaultFocus) + control.assert( + defaultMeasured.preferredHeight == 67, + "charset default height unchanged", + ) + control.assert( + defaultSurface.log.indexOf("text:a;") >= 0, + "charset default renders a", + ) + control.assert( + defaultSurface.log.indexOf("text:z;") >= 0, + "charset default renders z", + ) + control.assert( + defaultSurface.log.indexOf("text:ABC;") >= 0, + "charset default shift caption present", + ) + + // Group 2: an injected _loc alphabet renders and accepts its new letter. + resetLoc() + _loc.alphabetLower = esLower + _loc.alphabetUpper = esUpper + let injectedResult: UiTextEntryResult = undefined + const injected = new UiTextEntryModal({ + modalScopeId: "charset-es", + allowDigits: true, + onResult: result => { + injectedResult = result + }, + }) + const injMeasured = new UiMeasuredSize() + injected.measure({ maxWidth: 160, maxHeight: 120 }, injMeasured) + injected.arrange(new Rect(0, 0, 160, injMeasured.preferredHeight)) + const injFocus = new UiFocusState() + const injController = new UiFocusInputController(injFocus) + injFocus.setScope({ id: "charset-es-parent" }) + injFocus.setActiveScope("charset-es-parent") + injected.open(injFocus, injController) + const injLower = new ControlSmokeSurface() + injected.render(injLower, assets, injFocus) + control.assert( + injMeasured.preferredHeight == 80, + "charset injected height grows for third letter row", + ) + control.assert( + injLower.log.indexOf("text:ñ;") >= 0, + "charset injected renders n-tilde key", + ) + control.assert( + injLower.log.indexOf("text:Ñ;") < 0, + "charset injected lowercase before shift", + ) + control.assert( + injLower.log.indexOf("text:ABC;") >= 0, + "charset injected shift present", + ) + + // Case toggle maps the pair to uppercase and back to lowercase. + injFocus.setActiveTarget("charset-es", "charset-es/27") + injected.handleFocusInput( + injController.handleInput({ action: "activate" }), + ) + const injUpper = new ControlSmokeSurface() + injected.render(injUpper, assets, injFocus) + control.assert( + injUpper.log.indexOf("text:Ñ;") >= 0, + "charset injected renders uppercase n-tilde after shift", + ) + control.assert( + injUpper.log.indexOf("text:abc;") >= 0, + "charset injected shift caption toggles", + ) + injFocus.setActiveTarget("charset-es", "charset-es/27") + injected.handleFocusInput( + injController.handleInput({ action: "activate" }), + ) + const injBack = new ControlSmokeSurface() + injected.render(injBack, assets, injFocus) + control.assert( + injBack.log.indexOf("text:ñ;") >= 0, + "charset injected maps back to lowercase", + ) + control.assert( + injBack.log.indexOf("text:Ñ;") < 0, + "charset injected uppercase gone after toggle back", + ) + + // Typing and committing the new letter flows through the modal value. + injFocus.setActiveTarget("charset-es", "charset-es/14") + injected.handleFocusInput( + injController.handleInput({ action: "activate" }), + ) + injFocus.setActiveTarget("charset-es", "charset-es/32") + const injCompleted = injected.handleFocusInput( + injController.handleInput({ action: "activate" }), + ) + control.assert( + injCompleted.kind == "completed", + "charset injected commit completes", + ) + control.assert( + (injCompleted).text == "ñ", + "charset injected typed value", + ) + control.assert( + injectedResult && (injectedResult).text == "ñ", + "charset injected result callback", + ) + + // initialText holding the injected letter survives normalization. + const injInitial = new UiTextEntryModal({ + modalScopeId: "charset-es-initial", + initialText: "ñ", + }) + injInitial.arrange(new Rect(0, 0, 160, 80)) + const injInitFocus = new UiFocusState() + const injInitController = new UiFocusInputController(injInitFocus) + injInitFocus.setScope({ id: "charset-es-initial-parent" }) + injInitFocus.setActiveScope("charset-es-initial-parent") + injInitial.open(injInitFocus, injInitController) + injInitFocus.setActiveTarget( + "charset-es-initial", + "charset-es-initial/32", + ) + const injInitResult = injInitial.handleFocusInput( + injInitController.handleInput({ action: "activate" }), + ) + control.assert( + injInitResult && injInitResult.kind == "completed", + "charset injected initial text commits", + ) + control.assert( + (injInitResult).text == "ñ", + "charset injected initial text not stripped", + ) + + // Group 3: a per-modal override beats the assigned _loc alphabet. + resetLoc() + _loc.alphabetLower = esLower + _loc.alphabetUpper = esUpper + const overrideModal = new UiTextEntryModal({ + modalScopeId: "charset-override", + charset: { lower: "abcde", upper: "ABCDE" }, + }) + const overrideMeasured = new UiMeasuredSize() + overrideModal.measure({ maxWidth: 160, maxHeight: 120 }, overrideMeasured) + overrideModal.arrange( + new Rect(0, 0, 160, overrideMeasured.preferredHeight), + ) + const overrideFocus = new UiFocusState() + const overrideController = new UiFocusInputController(overrideFocus) + overrideFocus.setScope({ id: "charset-override-parent" }) + overrideFocus.setActiveScope("charset-override-parent") + overrideModal.open(overrideFocus, overrideController) + const overrideSurface = new ControlSmokeSurface() + overrideModal.render(overrideSurface, assets, overrideFocus) + control.assert( + overrideSurface.log.indexOf("text:a;") >= 0, + "charset override renders first letter", + ) + control.assert( + overrideSurface.log.indexOf("text:e;") >= 0, + "charset override renders last letter", + ) + control.assert( + overrideSurface.log.indexOf("text:ñ;") < 0, + "charset override beats loc alphabet", + ) + + // Group 4: a caseless charset (upper equals lower) hides the shift key. + resetLoc() + _loc.alphabetLower = "abcdefghijklmnopqrstuvwxyz" + _loc.alphabetUpper = "abcdefghijklmnopqrstuvwxyz" + const caselessModal = new UiTextEntryModal({ + modalScopeId: "charset-caseless", + allowDigits: true, + }) + const caselessMeasured = new UiMeasuredSize() + caselessModal.measure({ maxWidth: 160, maxHeight: 120 }, caselessMeasured) + caselessModal.arrange( + new Rect(0, 0, 160, caselessMeasured.preferredHeight), + ) + const caselessFocus = new UiFocusState() + const caselessController = new UiFocusInputController(caselessFocus) + caselessFocus.setScope({ id: "charset-caseless-parent" }) + caselessFocus.setActiveScope("charset-caseless-parent") + caselessModal.open(caselessFocus, caselessController) + const caselessSurface = new ControlSmokeSurface() + caselessModal.render(caselessSurface, assets, caselessFocus) + control.assert( + caselessSurface.log.indexOf("text:a;") >= 0, + "charset caseless renders letters", + ) + control.assert( + caselessSurface.log.indexOf("text:ABC;") < 0, + "charset caseless has no shift caption", + ) + + // Group 5: a symbols override drives the symbol page. + resetLoc() + let symResult: UiTextEntryResult = undefined + const symbolsModal = new UiTextEntryModal({ + modalScopeId: "charset-symbols", + allowDigits: true, + allowSymbols: true, + charset: { symbols: "~%^" }, + onResult: result => { + symResult = result + }, + }) + const symMeasured = new UiMeasuredSize() + symbolsModal.measure({ maxWidth: 160, maxHeight: 120 }, symMeasured) + symbolsModal.arrange(new Rect(0, 0, 160, symMeasured.preferredHeight)) + const symFocus = new UiFocusState() + const symController = new UiFocusInputController(symFocus) + symFocus.setScope({ id: "charset-symbols-parent" }) + symFocus.setActiveScope("charset-symbols-parent") + symbolsModal.open(symFocus, symController) + // Letters page to digits page to symbols page via the page toggles. + symFocus.setActiveTarget("charset-symbols", "charset-symbols/27") + symbolsModal.handleFocusInput( + symController.handleInput({ action: "activate" }), + ) + symFocus.setActiveTarget("charset-symbols", "charset-symbols/27") + symbolsModal.handleFocusInput( + symController.handleInput({ action: "activate" }), + ) + const symSurface = new ControlSmokeSurface() + symbolsModal.render(symSurface, assets, symFocus) + control.assert( + symSurface.log.indexOf("text:~;") >= 0, + "charset symbols override renders first symbol", + ) + control.assert( + symSurface.log.indexOf("text:%;") >= 0, + "charset symbols override renders second symbol", + ) + control.assert( + symSurface.log.indexOf("text:!;") < 0, + "charset symbols override replaces default symbols", + ) + symFocus.setActiveTarget("charset-symbols", "charset-symbols/0") + symbolsModal.handleFocusInput( + symController.handleInput({ action: "activate" }), + ) + symFocus.setActiveTarget("charset-symbols", "charset-symbols/31") + const symCompleted = symbolsModal.handleFocusInput( + symController.handleInput({ action: "activate" }), + ) + control.assert( + (symCompleted).text == "~", + "charset symbols override accepts a symbol", + ) + control.assert( + symResult && (symResult).text == "~", + "charset symbols override result callback", + ) + + resetLoc() + } + + /** + * Smoke harness for the keyboard accents page. Verifies that with no accent + * set the letters page still routes to digits and shows no accents caption, + * that an injected accent set adds a reachable accents page whose keys render + * and commit, that accent case follows the global uppercase flag, and that + * the home key returns from an extra page to the letters page. + */ + export function runTextEntryAccentsSmokeTest(): void { + function resetLoc(): void { + _loc.table = undefined + _loc.alphabetLower = undefined + _loc.alphabetUpper = undefined + _loc.accentsLower = undefined + _loc.accentsUpper = undefined + _loc.symbols = undefined + } + + const assets = new ControlSmokeAssets() + // A 26-letter base alphabet keeps the action row at indices 26..31: + // shift 26, page 27, space 28, custom 29, backspace 30, enter 31. + const baseLower = "abcdefghijklmnopqrstuvwxyz" + const baseUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + function activateIndex( + modal: UiTextEntryModal, + focus: UiFocusState, + controller: UiFocusInputController, + scopeId: string, + index: number, + ): UiTextEntryResult { + focus.setActiveTarget(scopeId, scopeId + "/" + index) + return modal.handleFocusInput( + controller.handleInput({ action: "activate" }), + ) + } + + // Group 1: no accent set means the letters page routes to digits and + // shows no accents caption. Confirms the default keyboard is unchanged. + resetLoc() + const noAccents = new UiTextEntryModal({ + modalScopeId: "accents-none", + allowDigits: true, + }) + const noAccentsMeasured = new UiMeasuredSize() + noAccents.measure({ maxWidth: 160, maxHeight: 120 }, noAccentsMeasured) + noAccents.arrange( + new Rect(0, 0, 160, noAccentsMeasured.preferredHeight), + ) + const noAccentsFocus = new UiFocusState() + const noAccentsController = new UiFocusInputController(noAccentsFocus) + noAccentsFocus.setScope({ id: "accents-none-parent" }) + noAccentsFocus.setActiveScope("accents-none-parent") + noAccents.open(noAccentsFocus, noAccentsController) + const noAccentsLetters = new ControlSmokeSurface() + noAccents.render(noAccentsLetters, assets, noAccentsFocus) + control.assert( + noAccentsLetters.log.indexOf("text:123;") >= 0, + "accents none letters page routes to digits", + ) + control.assert( + noAccentsLetters.log.indexOf("text:áé;") < 0, + "accents none shows no accents caption", + ) + activateIndex( + noAccents, + noAccentsFocus, + noAccentsController, + "accents-none", + 27, + ) + const noAccentsDigits = new ControlSmokeSurface() + noAccents.render(noAccentsDigits, assets, noAccentsFocus) + control.assert( + noAccentsDigits.log.indexOf("text:1;") >= 0, + "accents none digits page reachable", + ) + + // Group 2: an injected accent set adds a reachable accents page whose + // key renders and commits through the modal value and onResult. + resetLoc() + _loc.alphabetLower = baseLower + _loc.alphabetUpper = baseUpper + _loc.accentsLower = "áé" + _loc.accentsUpper = "ÁÉ" + let accentResult: UiTextEntryResult = undefined + const accents = new UiTextEntryModal({ + modalScopeId: "accents-es", + allowDigits: true, + onResult: result => { + accentResult = result + }, + }) + const accentsMeasured = new UiMeasuredSize() + accents.measure({ maxWidth: 160, maxHeight: 120 }, accentsMeasured) + accents.arrange(new Rect(0, 0, 160, accentsMeasured.preferredHeight)) + const accentsFocus = new UiFocusState() + const accentsController = new UiFocusInputController(accentsFocus) + accentsFocus.setScope({ id: "accents-es-parent" }) + accentsFocus.setActiveScope("accents-es-parent") + accents.open(accentsFocus, accentsController) + const accentsLetters = new ControlSmokeSurface() + accents.render(accentsLetters, assets, accentsFocus) + control.assert( + accentsLetters.log.indexOf("text:áé;") >= 0, + "accents present letters page shows accents caption", + ) + activateIndex( + accents, + accentsFocus, + accentsController, + "accents-es", + 27, + ) + const accentsPage = new ControlSmokeSurface() + accents.render(accentsPage, assets, accentsFocus) + control.assert( + accentsPage.log.indexOf("text:á;") >= 0, + "accents page renders accent key", + ) + activateIndex(accents, accentsFocus, accentsController, "accents-es", 0) + const accentCommit = activateIndex( + accents, + accentsFocus, + accentsController, + "accents-es", + 31, + ) + control.assert( + accentCommit.kind == "completed", + "accents commit completes", + ) + control.assert( + (accentCommit).text == "á", + "accents commit typed value", + ) + control.assert( + accentResult && (accentResult).text == "á", + "accents commit result callback", + ) + + // Group 3: accent case follows the global uppercase flag set on the + // letters page; the accents page has no case-toggle of its own. + const accentsCase = new UiTextEntryModal({ + modalScopeId: "accents-upper", + allowDigits: true, + }) + const accentsCaseMeasured = new UiMeasuredSize() + accentsCase.measure( + { maxWidth: 160, maxHeight: 120 }, + accentsCaseMeasured, + ) + accentsCase.arrange( + new Rect(0, 0, 160, accentsCaseMeasured.preferredHeight), + ) + const accentsCaseFocus = new UiFocusState() + const accentsCaseController = new UiFocusInputController( + accentsCaseFocus, + ) + accentsCaseFocus.setScope({ id: "accents-upper-parent" }) + accentsCaseFocus.setActiveScope("accents-upper-parent") + accentsCase.open(accentsCaseFocus, accentsCaseController) + activateIndex( + accentsCase, + accentsCaseFocus, + accentsCaseController, + "accents-upper", + 26, + ) + activateIndex( + accentsCase, + accentsCaseFocus, + accentsCaseController, + "accents-upper", + 27, + ) + const accentsUpperPage = new ControlSmokeSurface() + accentsCase.render(accentsUpperPage, assets, accentsCaseFocus) + control.assert( + accentsUpperPage.log.indexOf("text:Á;") >= 0, + "accents page follows uppercase flag", + ) + activateIndex( + accentsCase, + accentsCaseFocus, + accentsCaseController, + "accents-upper", + 0, + ) + const accentUpperCommit = activateIndex( + accentsCase, + accentsCaseFocus, + accentsCaseController, + "accents-upper", + 31, + ) + control.assert( + (accentUpperCommit).text == "Á", + "accents commit uppercase value", + ) + + // Group 4: the home key returns from an extra page to the letters page. + const accentsHome = new UiTextEntryModal({ + modalScopeId: "accents-home", + allowDigits: true, + }) + const accentsHomeMeasured = new UiMeasuredSize() + accentsHome.measure( + { maxWidth: 160, maxHeight: 120 }, + accentsHomeMeasured, + ) + accentsHome.arrange( + new Rect(0, 0, 160, accentsHomeMeasured.preferredHeight), + ) + const accentsHomeFocus = new UiFocusState() + const accentsHomeController = new UiFocusInputController( + accentsHomeFocus, + ) + accentsHomeFocus.setScope({ id: "accents-home-parent" }) + accentsHomeFocus.setActiveScope("accents-home-parent") + accentsHome.open(accentsHomeFocus, accentsHomeController) + activateIndex( + accentsHome, + accentsHomeFocus, + accentsHomeController, + "accents-home", + 27, + ) + activateIndex( + accentsHome, + accentsHomeFocus, + accentsHomeController, + "accents-home", + 26, + ) + const accentsHomeLetters = new ControlSmokeSurface() + accentsHome.render(accentsHomeLetters, assets, accentsHomeFocus) + control.assert( + accentsHomeLetters.log.indexOf("text:a;") >= 0, + "accents home key returns to letters page", + ) + + resetLoc() + } + /** * Smoke harness for control-caption localization and the localized default * font. Verifies that with no catalog the English source captions render @@ -1870,6 +2412,8 @@ namespace ui.controls.test { runScreenControllerSmokeTest() runNumericEntrySmokeTest() runTextEntrySmokeTest() + runTextEntryCharsetSmokeTest() + runTextEntryAccentsSmokeTest() runLocalizationSmokeTest() control.__log(1, "All tests passed!") diff --git a/text-entry.ts b/text-entry.ts index 31c7ec5..be96ee7 100644 --- a/text-entry.ts +++ b/text-entry.ts @@ -1,3 +1,37 @@ +/** + * These members extend the `_loc` namespace declared in ui-core and are + * assigned by the consuming app's generated localization file, the same way + * `_loc.table` and `_loc.defaultFont` are. They describe the on-screen keyboard, + * a ui-controls concept, so they are declared here rather than in ui-core. + */ +namespace _loc { + /** + * On-screen keyboard letter set and its case-parallel. String order defines + * the keyboard key order. `alphabetLower` and `alphabetUpper` must have + * equal length, with the character at each index the lower and upper form of + * the same letter; a caseless script assigns them identically (or leaves + * `alphabetUpper` empty). `undefined` means the module default alphabet. + */ + export let alphabetLower: string = undefined + export let alphabetUpper: string = undefined + + /** + * Additional accented letter pairs accepted as text-entry input. Same + * pairing contract as `alphabet*`: equal length, positionally paired + * lower/upper forms. Accepted by the input filter and mapped by case + * normalization. `undefined` means no accented input beyond the base + * alphabet. + */ + export let accentsLower: string = undefined + export let accentsUpper: string = undefined + + /** + * On-screen keyboard symbol-page characters. String order defines the symbol + * key order. `undefined` means the module default symbols. + */ + export let symbols: string = undefined +} + namespace ui { const TEXT_ENTRY_LOWERCASE = "abcdefghijklmnopqrstuvwxyz" const TEXT_ENTRY_UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" @@ -20,7 +54,24 @@ namespace ui { const UI_TEXT_ENTRY_KEY_SPACE = "\u0007" const UI_TEXT_ENTRY_KEY_CUSTOM = "\u0008" const UI_TEXT_ENTRY_KEY_SPACER = "\u0009" + // Vertical tab, chosen to stay clear of any newline handling. + const UI_TEXT_ENTRY_KEY_PAGE_ACCENTS = "\u000B" const UI_TEXT_ENTRY_DIGIT_KEYS = "1234567890" + // Keyboard grid geometry limits. A row shows at most 13 keys and the letter + // pages use at most 3 letter rows plus one action row. Alphabet characters + // beyond `UI_TEXT_ENTRY_MAX_LETTER_ROWS * UI_TEXT_ENTRY_MAX_ROW_KEYS` are + // still accepted by the input filter but are not rendered as keys. + const UI_TEXT_ENTRY_MAX_ROW_KEYS = 13 + const UI_TEXT_ENTRY_MAX_LETTER_ROWS = 3 + // Slots reserved in the trailing action row: shift/page-left, page-toggle, + // space, custom, backspace, enter. + const UI_TEXT_ENTRY_ACTION_ROW_SLOTS = 6 + const UI_TEXT_ENTRY_ACTION_SHIFT = 0 + const UI_TEXT_ENTRY_ACTION_PAGE = 1 + const UI_TEXT_ENTRY_ACTION_SPACE = 2 + const UI_TEXT_ENTRY_ACTION_CUSTOM = 3 + const UI_TEXT_ENTRY_ACTION_BACKSPACE = 4 + const UI_TEXT_ENTRY_ACTION_ENTER = 5 const UI_TEXT_ENTRY_FLAG_ALLOW_EMPTY = 1 const UI_TEXT_ENTRY_FLAG_ALLOW_WHITESPACE = 2 const UI_TEXT_ENTRY_FLAG_ALLOW_DIGITS = 4 @@ -31,6 +82,7 @@ namespace ui { const UI_TEXT_ENTRY_PAGE_LETTERS = 0 const UI_TEXT_ENTRY_PAGE_DIGITS = 1 const UI_TEXT_ENTRY_PAGE_SYMBOLS = 2 + const UI_TEXT_ENTRY_PAGE_ACCENTS = 3 // 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 @@ -48,6 +100,132 @@ namespace ui { textPlacement: "content", } + /** + * Per-field keyboard character-set override for text entry. Each present + * field replaces the corresponding `_loc` charset field (or the module + * default) for this entry only. `lower`/`upper` and + * `accentsLower`/`accentsUpper` are case-parallel: equal length, with the + * character at each index the lower and upper form of the same letter. The + * string order defines keyboard key order. An empty or absent `upper` marks + * a caseless keyboard. + */ + export interface UiTextEntryCharset { + lower?: string + upper?: string + accentsLower?: string + accentsUpper?: string + symbols?: string + } + + /** + * Resolved keyboard character set. Every field is a concrete string. An + * empty `upper` (or `upper` equal to `lower`) marks a caseless keyboard; + * empty `accents*` or `symbols` mark none. + */ + class ResolvedTextEntryCharset { + public lower: string + public upper: string + public accentsLower: string + public accentsUpper: string + public symbols: string + } + + function pickCharsetField( + override: string, + locValue: string, + fallback: string, + ): string { + if (override !== undefined) return override + if (locValue !== undefined) return locValue + return fallback + } + + /** + * Resolves the effective keyboard charset. Each field independently prefers + * the per-modal option override, then the matching `_loc` charset field, + * then the module ASCII default. `_loc` is read here at construction time, + * never at module init, so an app that assigns its generated charset before + * a keyboard is constructed is honored. + */ + function resolveTextEntryCharset( + override: UiTextEntryCharset, + ): ResolvedTextEntryCharset { + const result = new ResolvedTextEntryCharset() + const o = override || {} + result.lower = pickCharsetField( + o.lower, + _loc.alphabetLower, + TEXT_ENTRY_LOWERCASE, + ) + result.upper = pickCharsetField( + o.upper, + _loc.alphabetUpper, + TEXT_ENTRY_UPPERCASE, + ) + result.accentsLower = pickCharsetField( + o.accentsLower, + _loc.accentsLower, + "", + ) + result.accentsUpper = pickCharsetField( + o.accentsUpper, + _loc.accentsUpper, + "", + ) + result.symbols = pickCharsetField(o.symbols, _loc.symbols, TEXT_ENTRY_SYMBOLS) + // Defensive normalization: per-modal overrides and `_loc` assignments + // are not build-validated, so guard the pairing contract here. An empty + // alphabet falls back to the default; a case pair whose halves differ in + // length is treated as caseless rather than casing only some letters. + if (result.lower.length == 0) { + result.lower = TEXT_ENTRY_LOWERCASE + result.upper = TEXT_ENTRY_UPPERCASE + } + if (result.upper.length != result.lower.length) + result.upper = result.lower + if (result.accentsUpper.length != result.accentsLower.length) + result.accentsUpper = result.accentsLower + return result + } + + /** + * Distributes `count` items across exactly `rows` rows as evenly as + * possible. When the count does not divide evenly, the longer rows are the + * last ones (10 across 3 rows is 3, 3, 4), which reads better than a heavy + * top row. distributeEvenly(26, 2) yields the two full rows of 13 used by + * the ASCII default. + */ + function distributeEvenly(count: number, rows: number): number[] { + const base = Math.idiv(count, rows) + const remainder = count - base * rows + const lengths: number[] = [] + for (let i = 0; i < rows; i++) + lengths.push(base + (i >= rows - remainder ? 1 : 0)) + return lengths + } + + /** + * Splits `count` letters across keyboard rows. Rows hold at most + * `UI_TEXT_ENTRY_MAX_ROW_KEYS` keys and there are at most + * `UI_TEXT_ENTRY_MAX_LETTER_ROWS` letter rows, so at most 39 letters render; + * any beyond that are filter-accepted but not shown. Letters fill in string + * order and are distributed as evenly as possible, the last rows taking any + * remainder. A 26-letter alphabet yields the two full rows of 13 used by the + * ASCII default. + */ + function splitLetterRows(count: number): number[] { + const maxShown = UI_TEXT_ENTRY_MAX_LETTER_ROWS * UI_TEXT_ENTRY_MAX_ROW_KEYS + let shown = count + if (shown > maxShown) shown = maxShown + if (shown < 1) shown = 1 + let rows = Math.idiv( + shown + UI_TEXT_ENTRY_MAX_ROW_KEYS - 1, + UI_TEXT_ENTRY_MAX_ROW_KEYS, + ) + if (rows < 1) rows = 1 + return distributeEvenly(shown, rows) + } + /** * Validation result for entry edits. */ @@ -138,6 +316,12 @@ namespace ui { */ validate?: UiTextEntryValidator + /** + * Optional per-field keyboard charset override. Unset fields fall back + * to the matching `_loc` charset field, then the module default. + */ + charset?: UiTextEntryCharset + /** * Optional title content. */ @@ -173,6 +357,7 @@ namespace ui { private minLength_: number private flags_: number private validate_: UiTextEntryValidator + private charset_: ResolvedTextEntryCharset constructor( initialText?: string, @@ -185,7 +370,9 @@ namespace ui { uppercaseOnly?: boolean, cancelEnabled?: boolean, validate?: UiTextEntryValidator, + charset?: UiTextEntryCharset, ) { + this.charset_ = resolveTextEntryCharset(charset) this.flags_ = 0 if (allowEmpty) this.flags_ |= UI_TEXT_ENTRY_FLAG_ALLOW_EMPTY if (allowWhitespace) @@ -334,14 +521,27 @@ namespace ui { } private normalizedCharacter(ch: string): string { - if (ch >= "a" && ch <= "z") { - if (this.flags_ & UI_TEXT_ENTRY_FLAG_UPPERCASE_ONLY) - return TEXT_ENTRY_UPPERCASE.charAt( - TEXT_ENTRY_LOWERCASE.indexOf(ch), - ) + const cs = this.charset_ + const lowerIndex = cs.lower.indexOf(ch) + if (lowerIndex >= 0) { + if ( + this.flags_ & UI_TEXT_ENTRY_FLAG_UPPERCASE_ONLY && + cs.upper.length > lowerIndex + ) + return cs.upper.charAt(lowerIndex) return ch } - if (ch >= "A" && ch <= "Z") return ch + if (cs.upper.indexOf(ch) >= 0) return ch + const accentIndex = cs.accentsLower.indexOf(ch) + if (accentIndex >= 0) { + if ( + this.flags_ & UI_TEXT_ENTRY_FLAG_UPPERCASE_ONLY && + cs.accentsUpper.length > accentIndex + ) + return cs.accentsUpper.charAt(accentIndex) + return ch + } + if (cs.accentsUpper.indexOf(ch) >= 0) return ch if (ch >= "0" && ch <= "9") return this.flags_ & UI_TEXT_ENTRY_FLAG_ALLOW_DIGITS ? ch : "" if (ch == " ") @@ -357,7 +557,7 @@ namespace ui { } private isSymbol(ch: string): boolean { - return TEXT_ENTRY_SYMBOLS.indexOf(ch) >= 0 + return this.charset_.symbols.indexOf(ch) >= 0 } private canComplete(text: string): boolean { @@ -407,10 +607,22 @@ namespace ui { private titleGap_: number private page_: number private flags_: number + private charset_: ResolvedTextEntryCharset + private hasCase_: boolean + private rowLengths_: number[] + private rowStarts_: number[] + private numRows_: number + private slotCount_: number + private shownLetters_: number private onResult_: (result: UiTextEntryResult) => void constructor(options: UiTextEntryModalOptions) { this.modalScopeId_ = options.modalScopeId + this.charset_ = resolveTextEntryCharset(options.charset) + this.hasCase_ = + this.charset_.upper.length > 0 && + this.charset_.upper != this.charset_.lower + this.buildRowStructure() this.entry_ = this.createEntry(options) this.title_ = options.title this.customAction_ = options.customAction @@ -446,7 +658,7 @@ namespace ui { this.keyContent_ = {} this.keyValues_ = [] this.keyXs_ = [] - for (let i = 0; i < 32; i++) { + for (let i = 0; i < this.slotCount_; i++) { this.keyValues_.push(UI_TEXT_ENTRY_KEY_SPACER) this.keyXs_.push(0) } @@ -676,63 +888,159 @@ namespace ui { options.uppercaseOnly, true, options.validate, + this.charset_, ) } + private buildRowStructure(): void { + const letterRows = splitLetterRows(this.charset_.lower.length) + this.rowLengths_ = [] + this.shownLetters_ = 0 + for (let i = 0; i < letterRows.length; i++) { + this.rowLengths_.push(letterRows[i]) + this.shownLetters_ += letterRows[i] + } + this.rowLengths_.push(UI_TEXT_ENTRY_ACTION_ROW_SLOTS) + this.numRows_ = this.rowLengths_.length + this.rowStarts_ = [] + let start = 0 + for (let i = 0; i < this.numRows_; i++) { + this.rowStarts_.push(start) + start += this.rowLengths_[i] + } + this.slotCount_ = start + } + + private actionBase(): number { + return this.rowStarts_[this.numRows_ - 1] + } + private rebuildKeys(): void { for (let i = 0; i < this.keyValues_.length; i++) this.keyValues_[i] = UI_TEXT_ENTRY_KEY_SPACER if (this.page_ == UI_TEXT_ENTRY_PAGE_LETTERS) this.addLetterKeys() + else if (this.page_ == UI_TEXT_ENTRY_PAGE_ACCENTS) + this.addAccentKeys() else if (this.page_ == UI_TEXT_ENTRY_PAGE_DIGITS) this.addDigitKeys() else this.addSymbolKeys() } + // Ordered list of non-letter pages available for the current flags and + // charset: accents (only when the resolved charset carries an accent + // set), then digits, then symbols. Drives both the letters-page page key + // and the cycle between extra pages. + private extraPages(): number[] { + const pages: number[] = [] + if (this.charset_.accentsLower.length > 0) + pages.push(UI_TEXT_ENTRY_PAGE_ACCENTS) + if (this.flags_ & UI_TEXT_ENTRY_FLAG_ALLOW_DIGITS) + pages.push(UI_TEXT_ENTRY_PAGE_DIGITS) + if (this.flags_ & UI_TEXT_ENTRY_FLAG_ALLOW_SYMBOLS) + pages.push(UI_TEXT_ENTRY_PAGE_SYMBOLS) + return pages + } + + private pageKeyFor(page: number): string { + if (page == UI_TEXT_ENTRY_PAGE_ACCENTS) + return UI_TEXT_ENTRY_KEY_PAGE_ACCENTS + if (page == UI_TEXT_ENTRY_PAGE_DIGITS) + return UI_TEXT_ENTRY_KEY_PAGE_DIGITS + if (page == UI_TEXT_ENTRY_PAGE_SYMBOLS) + return UI_TEXT_ENTRY_KEY_PAGE_SYMBOLS + return UI_TEXT_ENTRY_KEY_PAGE_LETTERS + } + private addLetterKeys(): void { - for (let i = 0; i < TEXT_ENTRY_LOWERCASE.length; i++) - this.keyValues_[i] = TEXT_ENTRY_LOWERCASE.charAt(i) - if (!(this.flags_ & UI_TEXT_ENTRY_FLAG_UPPERCASE_ONLY)) - this.keyValues_[26] = UI_TEXT_ENTRY_KEY_SHIFT + for (let i = 0; i < this.shownLetters_; i++) + this.keyValues_[i] = this.charset_.lower.charAt(i) + const base = this.actionBase() if ( - this.flags_ & - (UI_TEXT_ENTRY_FLAG_ALLOW_DIGITS | - UI_TEXT_ENTRY_FLAG_ALLOW_SYMBOLS) + !(this.flags_ & UI_TEXT_ENTRY_FLAG_UPPERCASE_ONLY) && + this.hasCase_ ) - this.keyValues_[27] = UI_TEXT_ENTRY_KEY_PAGE_DIGITS + this.keyValues_[base + UI_TEXT_ENTRY_ACTION_SHIFT] = + UI_TEXT_ENTRY_KEY_SHIFT + const extras = this.extraPages() + if (extras.length > 0) + this.keyValues_[base + UI_TEXT_ENTRY_ACTION_PAGE] = + this.pageKeyFor(extras[0]) this.addActionKeys() } + // Places a page's characters across the content rows (every row but the + // trailing action row), distributed as evenly as possible and left in + // string order. Unlike the letter pages, digit and symbol pages hold + // fewer keys than the grid has content slots, so spreading them keeps + // each row balanced rather than packing the first rows full. Remaining + // slots stay spacers, which the row layout centers around. + private placeContentKeys(source: string): void { + const contentRows = this.numRows_ - 1 + let shown = source.length + if (shown > this.actionBase()) shown = this.actionBase() + const shares = distributeEvenly(shown, contentRows) + let srcIndex = 0 + for (let row = 0; row < contentRows; row++) { + let share = shares[row] + if (share > this.rowLengths_[row]) share = this.rowLengths_[row] + const start = this.rowStarts_[row] + for (let i = 0; i < share; i++) { + this.keyValues_[start + i] = source.charAt(srcIndex) + srcIndex++ + } + } + } + + private addAccentKeys(): void { + this.placeContentKeys(this.charset_.accentsLower) + this.setupExtraPageActionRow(UI_TEXT_ENTRY_PAGE_ACCENTS) + } + private addDigitKeys(): void { - for (let i = 0; i < UI_TEXT_ENTRY_DIGIT_KEYS.length; i++) - this.keyValues_[i] = UI_TEXT_ENTRY_DIGIT_KEYS.charAt(i) - this.keyValues_[26] = UI_TEXT_ENTRY_KEY_PAGE_LETTERS - this.keyValues_[27] = - this.flags_ & UI_TEXT_ENTRY_FLAG_ALLOW_SYMBOLS - ? UI_TEXT_ENTRY_KEY_PAGE_SYMBOLS - : UI_TEXT_ENTRY_KEY_PAGE_LETTERS - this.addActionKeys() + this.placeContentKeys(UI_TEXT_ENTRY_DIGIT_KEYS) + this.setupExtraPageActionRow(UI_TEXT_ENTRY_PAGE_DIGITS) } private addSymbolKeys(): void { - for (let i = 0; i < TEXT_ENTRY_SYMBOLS.length; i++) - this.keyValues_[i] = TEXT_ENTRY_SYMBOLS.charAt(i) - this.keyValues_[26] = UI_TEXT_ENTRY_KEY_PAGE_LETTERS - if (this.flags_ & UI_TEXT_ENTRY_FLAG_ALLOW_DIGITS) - this.keyValues_[27] = UI_TEXT_ENTRY_KEY_PAGE_DIGITS + this.placeContentKeys(this.charset_.symbols) + this.setupExtraPageActionRow(UI_TEXT_ENTRY_PAGE_SYMBOLS) + } + + // Action row shared by every non-letter page. The shift slot always + // returns to the letter page; the page slot advances to the next extra + // page, cycling among the present extras and collapsing to the letter + // page when only one extra exists. + private setupExtraPageActionRow(page: number): void { + const base = this.actionBase() + this.keyValues_[base + UI_TEXT_ENTRY_ACTION_SHIFT] = + UI_TEXT_ENTRY_KEY_PAGE_LETTERS + const extras = this.extraPages() + let target = UI_TEXT_ENTRY_PAGE_LETTERS + if (extras.length > 1) { + const i = extras.indexOf(page) + target = extras[(i + 1) % extras.length] + } + this.keyValues_[base + UI_TEXT_ENTRY_ACTION_PAGE] = + this.pageKeyFor(target) this.addActionKeys() } private addActionKeys(): void { + const base = this.actionBase() if (this.flags_ & UI_TEXT_ENTRY_FLAG_ALLOW_WHITESPACE) - this.keyValues_[28] = UI_TEXT_ENTRY_KEY_SPACE + this.keyValues_[base + UI_TEXT_ENTRY_ACTION_SPACE] = + UI_TEXT_ENTRY_KEY_SPACE if (this.customAction_) - this.keyValues_[29] = UI_TEXT_ENTRY_KEY_CUSTOM - this.keyValues_[30] = UI_TEXT_ENTRY_KEY_BACKSPACE - this.keyValues_[31] = UI_TEXT_ENTRY_KEY_ENTER + this.keyValues_[base + UI_TEXT_ENTRY_ACTION_CUSTOM] = + UI_TEXT_ENTRY_KEY_CUSTOM + this.keyValues_[base + UI_TEXT_ENTRY_ACTION_BACKSPACE] = + UI_TEXT_ENTRY_KEY_BACKSPACE + this.keyValues_[base + UI_TEXT_ENTRY_ACTION_ENTER] = + UI_TEXT_ENTRY_KEY_ENTER } private arrangeKeys(): void { - for (let row = 0; row < 3; row++) { + for (let row = 0; row < this.numRows_; row++) { const start = this.rowStart(row) const length = this.rowLength(row) let x = this.rowX(row) @@ -834,6 +1142,9 @@ namespace ui { case UI_TEXT_ENTRY_KEY_PAGE_SYMBOLS: this.setPage(UI_TEXT_ENTRY_PAGE_SYMBOLS) return undefined + case UI_TEXT_ENTRY_KEY_PAGE_ACCENTS: + this.setPage(UI_TEXT_ENTRY_PAGE_ACCENTS) + return undefined case UI_TEXT_ENTRY_KEY_SPACE: return this.entry_.inputCharacter(" ") case UI_TEXT_ENTRY_KEY_CUSTOM: @@ -880,18 +1191,35 @@ namespace ui { 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_PAGE_ACCENTS) + return this.accentsLabel() if (key == UI_TEXT_ENTRY_KEY_SPACE) return loc("space") if ( this.flags_ & (UI_TEXT_ENTRY_FLAG_UPPERCASE | UI_TEXT_ENTRY_FLAG_UPPERCASE_ONLY) ) { - const index = TEXT_ENTRY_LOWERCASE.indexOf(key) - if (index >= 0) return TEXT_ENTRY_UPPERCASE.charAt(index) + const index = this.charset_.lower.indexOf(key) + if (index >= 0 && this.charset_.upper.length > index) + return this.charset_.upper.charAt(index) + const accentIndex = this.charset_.accentsLower.indexOf(key) + if ( + accentIndex >= 0 && + this.charset_.accentsUpper.length > accentIndex + ) + return this.charset_.accentsUpper.charAt(accentIndex) } return key } + // Illustrative caption for the accents page key: the leading one or two + // accented letters of the resolved set. Not localized -- these are the + // charset's own glyphs, not a translatable word. + private accentsLabel(): string { + const accents = this.charset_.accentsLower + return accents.length > 2 ? accents.substr(0, 2) : accents + } + private keyStyleForKey(key: string): UiButtonStyle { if (key == UI_TEXT_ENTRY_KEY_ENTER) return UI_TEXT_ENTRY_MODAL_ENTER_STYLE @@ -901,20 +1229,17 @@ namespace ui { } private rowForIndex(index: number): number { - if (index < 13) return 0 - if (index < 26) return 1 - return 2 + for (let row = this.numRows_ - 1; row >= 0; row--) + if (index >= this.rowStarts_[row]) return row + return 0 } private rowStart(row: number): number { - if (row == 0) return 0 - if (row == 1) return 13 - return 26 + return this.rowStarts_[row] } private rowLength(row: number): number { - if (row == 2) return 6 - return 13 + return this.rowLengths_[row] } private keyIndexForTargetId(targetId: UiFocusId): number { @@ -982,7 +1307,7 @@ namespace ui { Math.idiv(this.keyWidth(this.keyValues_[currentIndex]), 2) const step = direction == "up" ? -1 : 1 let row = currentRow + step - while (row >= 0 && row < 3) { + while (row >= 0 && row < this.numRows_) { const start = this.rowStart(row) const length = this.rowLength(row) let bestIndex = -1 @@ -1049,8 +1374,8 @@ namespace ui { private gridHeight(): number { return ( - UI_TEXT_ENTRY_MODAL_KEY_HEIGHT * 3 + - UI_TEXT_ENTRY_MODAL_KEY_GAP * 2 + UI_TEXT_ENTRY_MODAL_KEY_HEIGHT * this.numRows_ + + UI_TEXT_ENTRY_MODAL_KEY_GAP * (this.numRows_ - 1) ) }