diff --git a/HISTORY.md b/HISTORY.md index f3c383d7b3..63340a2f0a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,30 @@ # Keyman Version History +## 17.0.248 alpha 2024-01-19 + +* chore(android): Update targetSdkVersion to 34 (#10393) +* (#10352) +* fix(web): keyboard-documentation rendering mode (#10417) +* fix(ios): multiple keyboard slide-in animations on app start (#10362) +* fix(web): U_ key id -> text for all subkeys; is now preprocessed (#10434) +* chore(android/app): Update whatsnew for 17.0 (#10395) +* fix(web): bulk renderer interface for recent-version targeting keyboards (#10427) +* feat(core): cross-segment markers (#10394) + +## 17.0.247 alpha 2024-01-18 + +* fix(ios): banner image management (#10337) +* feat(core): unescape u (#10356) +* chore(developer,core): change sample and test files to use \u{…} (#10391) + +## 17.0.246 alpha 2024-01-17 + +* fix(web): Add null check for changing the keyboard during typing (#10346) +* chore(common): Update crowdin strings for Khmer (#10411) +* docs(common): Update website README.md (#10399) +* docs(linux): Add documentation for Core API verification (#10409) +* fix(web): right-flick gesture-preview positioning (#10406) + ## 17.0.245 alpha 2024-01-16 * chore(core): Ignore C++ symbols (#10386) diff --git a/VERSION.md b/VERSION.md index 8c12aa78ac..2cefd7daf4 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -17.0.246 \ No newline at end of file +17.0.249 \ No newline at end of file diff --git a/android/KMAPro/kMAPro/build.gradle b/android/KMAPro/kMAPro/build.gradle index e12b928a9c..226e912fbe 100644 --- a/android/KMAPro/kMAPro/build.gradle +++ b/android/KMAPro/kMAPro/build.gradle @@ -10,7 +10,7 @@ ext.rootPath = '../../' apply from: "$rootPath/version.gradle" android { - compileSdkVersion 33 + compileSdk 34 namespace="com.tavultesoft.kmapro" // Don't compress kmp files so they can be copied via AssetManager @@ -21,7 +21,7 @@ android { defaultConfig { applicationId "com.tavultesoft.kmapro" minSdkVersion 21 - targetSdkVersion 33 + targetSdkVersion 34 //println "===DUMPING PROPERTIES===" //dumpProperties(project) // Use this to dump all external properties for debugging TeamCity integration diff --git a/android/KMEA/app/build.gradle b/android/KMEA/app/build.gradle index 423862b79b..feed5dd66c 100644 --- a/android/KMEA/app/build.gradle +++ b/android/KMEA/app/build.gradle @@ -7,12 +7,12 @@ ext.rootPath = '../../' apply from: "$rootPath/version.gradle" android { - compileSdkVersion 33 + compileSdk 34 namespace "com.keyman.engine" defaultConfig { minSdkVersion 21 - targetSdkVersion 33 + targetSdkVersion 34 // VERSION_CODE and VERSION_NAME from version.gradle but Gradle removes them for libraries buildConfigField "String", "KEYMAN_ENGINE_VERSION_NAME", "\""+VERSION_NAME+"\"" diff --git a/android/Samples/KMSample1/app/build.gradle b/android/Samples/KMSample1/app/build.gradle index 7be5ef6b1b..4f5f778c35 100644 --- a/android/Samples/KMSample1/app/build.gradle +++ b/android/Samples/KMSample1/app/build.gradle @@ -3,7 +3,7 @@ plugins { } android { - compileSdkVersion 33 + compileSdk 34 namespace="com.keyman.kmsample1" // Don't compress kmp files so they can be copied via AssetManager @@ -14,7 +14,7 @@ android { defaultConfig { applicationId "com.keyman.kmsample1" minSdkVersion 21 - targetSdkVersion 33 + targetSdkVersion 34 versionCode 1 versionName "1.0" } diff --git a/android/Samples/KMSample2/app/build.gradle b/android/Samples/KMSample2/app/build.gradle index fc4ca11b94..04e73b8405 100644 --- a/android/Samples/KMSample2/app/build.gradle +++ b/android/Samples/KMSample2/app/build.gradle @@ -3,7 +3,7 @@ plugins { } android { - compileSdkVersion 33 + compileSdk 34 namespace="com.keyman.kmsample2" // Don't compress kmp files so they can be copied via AssetManager @@ -14,7 +14,7 @@ android { defaultConfig { applicationId "com.keyman.kmsample2" minSdkVersion 21 - targetSdkVersion 33 + targetSdkVersion 34 versionCode 1 versionName "1.0" } diff --git a/android/Tests/KeyboardHarness/app/build.gradle b/android/Tests/KeyboardHarness/app/build.gradle index 29dd7de081..61fe8a53fa 100644 --- a/android/Tests/KeyboardHarness/app/build.gradle +++ b/android/Tests/KeyboardHarness/app/build.gradle @@ -6,7 +6,7 @@ ext.rootPath = '../../../' apply from: "$rootPath/version.gradle" android { - compileSdkVersion 33 + compileSdk 34 namespace="com.keyman.android.tests.keyboardHarness" // Don't compress kmp files so they can be copied via AssetManager @@ -21,7 +21,7 @@ android { defaultConfig { applicationId "com.keyman.android.tests.keyboardHarness" minSdkVersion 21 - targetSdkVersion 33 + targetSdkVersion 34 // VERSION_CODE and VERSION_NAME from version.gradle versionCode VERSION_CODE as Integer diff --git a/android/Tests/keycode/app/build.gradle b/android/Tests/keycode/app/build.gradle index f5501020e6..0db69e9da2 100644 --- a/android/Tests/keycode/app/build.gradle +++ b/android/Tests/keycode/app/build.gradle @@ -4,13 +4,12 @@ ext.rootPath = '../../../' apply from: "$rootPath/version.gradle" android { - compileSdkVersion 33 namespace="com.keyman.android.tests.keycode" defaultConfig { applicationId "com.keyman.android.tests.keycode" minSdkVersion 21 - targetSdkVersion 33 + targetSdkVersion 34 // VERSION_CODE and VERSION_NAME from version.gradle versionCode VERSION_CODE as Integer diff --git a/android/help/about/whatsnew.md b/android/help/about/whatsnew.md index 2714defd81..9e9dc7e117 100644 --- a/android/help/about/whatsnew.md +++ b/android/help/about/whatsnew.md @@ -2,3 +2,13 @@ title: What's New --- Here are some of the new features we have added to Keyman 17.0 for Android: + +* New gesture support (#5029) +* When suggestions aren't enabled, display a themed banner (#9696) +* Smoother keyboard initialization (#10022) + +Additional changes: + +* Remove built-in browser (#8428) +* Use web-based popup key longpresses (#9591) +* Performance improvements diff --git a/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts b/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts index 86fd645d0a..d172255122 100644 --- a/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts +++ b/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts @@ -33,6 +33,11 @@ export class GestureRecognizer extends Touchp } public destroy() { + // When shutting down the gesture engine, we should go ahead and clear out all related + // gesture-source tracking. + this.activeGestures.forEach((sequence) => sequence.cancel()); + this.activeSources.forEach((source) => source.terminate(true)); + this.mouseEngine.unregisterEventHandlers(); this.touchEngine.unregisterEventHandlers(); diff --git a/common/web/keyboard-processor/src/keyboards/activeLayout.ts b/common/web/keyboard-processor/src/keyboards/activeLayout.ts index 6e84171e7d..e9042996c9 100644 --- a/common/web/keyboard-processor/src/keyboards/activeLayout.ts +++ b/common/web/keyboard-processor/src/keyboards/activeLayout.ts @@ -331,6 +331,10 @@ export class ActiveKeyBase { } } + if(!key.text && typeof key.id == 'string') { + key.text = ActiveKey.unicodeIDToText(key.id); + } + // Ensure subkeys are also properly extended. if(key.sk) { analysisFlagObj.hasLongpresses = true; diff --git a/common/web/types/src/ldml-keyboard/pattern-parser.ts b/common/web/types/src/ldml-keyboard/pattern-parser.ts index 055926960a..a76831e670 100644 --- a/common/web/types/src/ldml-keyboard/pattern-parser.ts +++ b/common/web/types/src/ldml-keyboard/pattern-parser.ts @@ -3,7 +3,7 @@ */ import { constants } from "@keymanapp/ldml-keyboard-constants"; -import { MATCH_QUAD_ESCAPE, isOneChar, unescapeOneQuadString, unescapeString } from "../util/util.js"; +import { MATCH_QUAD_ESCAPE, isOneChar, unescapeOneQuadString, unescapeString, hexQuad } from "../util/util.js"; /** @@ -51,10 +51,12 @@ export class MarkerParser { * Marker sentinel as a string - U+FFFF */ public static readonly SENTINEL = String.fromCodePoint(constants.uc_sentinel); + static readonly SENTINEL_MATCH = '\\u' + hexQuad(constants.uc_sentinel); /** * Marker code as a string - U+0008 */ public static readonly MARKER_CODE = String.fromCodePoint(constants.marker_code); + static readonly MARKER_CODE_MATCH = '\\u' + hexQuad(constants.marker_code); /** Minimum ID (trailing code unit) */ public static readonly MIN_MARKER_INDEX = constants.marker_min_index; @@ -65,18 +67,10 @@ export class MarkerParser { /** Max count of markers */ public static readonly MAX_MARKER_COUNT = constants.marker_max_count; - /** 0000 … FFFF */ - private static hexQuad(n: number): string { - if (n < 0x000 || n > 0xFFFF) { - throw RangeError(`${n} not in [0x0000,0xFFFF]`); - } - return n.toString(16).padStart(4, '0'); - } - private static anyMarkerMatch() : string { - const start = MarkerParser.hexQuad(this.MIN_MARKER_INDEX); - const end = MarkerParser.hexQuad(this.MAX_MARKER_INDEX); - return `${this.SENTINEL}${this.MARKER_CODE}[\\u${start}-\\u${end}]`; // TODO-LDML: #9121 wrong escape format + const start = hexQuad(this.MIN_MARKER_INDEX); + const end = hexQuad(this.MAX_MARKER_INDEX); + return `${this.SENTINEL_MATCH}${this.MARKER_CODE_MATCH}[\\u${start}-\\u${end}]`; // TODO-LDML: #9121 wrong escape format } /** Expression that matches any marker */ @@ -103,7 +97,7 @@ export class MarkerParser { if (!forMatch) { return String.fromCharCode(n); } else { - return `\\u${MarkerParser.hexQuad(n)}`; // TODO-LDML: #9121 wrong escape format + return `\\u${hexQuad(n)}`; // TODO-LDML: #9121 wrong escape format } } @@ -112,7 +106,11 @@ export class MarkerParser { if (n < MarkerParser.MIN_MARKER_INDEX || n > MarkerParser.ANY_MARKER_INDEX) { throw RangeError(`Internal Error: marker index out of range ${n}`); } - return this.SENTINEL + this.MARKER_CODE + this.markerCodeToString(n, forMatch); + if (forMatch) { + return this.SENTINEL_MATCH + this.MARKER_CODE_MATCH + this.markerCodeToString(n, forMatch); + } else { + return this.SENTINEL + this.MARKER_CODE + this.markerCodeToString(n, forMatch); + } } /** @returns all marker strings as sentinel values */ diff --git a/common/web/types/src/util/util.ts b/common/web/types/src/util/util.ts index 5c8eb70810..d86bd6f58f 100644 --- a/common/web/types/src/util/util.ts +++ b/common/web/types/src/util/util.ts @@ -22,8 +22,11 @@ export function boxXmlArray(o: any, x: string): void { export const MATCH_HEX_ESCAPE = /\\u{([0-9a-fA-F ]{1,})}/g; // const MATCH_HEX_ESCAPE = /\\u{((?:(?:[0-9a-fA-F]{1,5})|(?:10[0-9a-fA-F]{4})(?: (?!}))?)+)}/g; +/** regex for single quad escape such as \u0127 or \U00000000 */ +export const CONTAINS_QUAD_ESCAPE = /(?:\\u([0-9a-fA-F]{4})|\\U([0-9a-fA-F]{8}))/; + /** regex for single quad escape such as \u0127 */ -export const MATCH_QUAD_ESCAPE = /\\u([0-9a-fA-F]{4})/g; +export const MATCH_QUAD_ESCAPE = new RegExp(CONTAINS_QUAD_ESCAPE, 'g'); export class UnescapeError extends Error { } @@ -39,8 +42,10 @@ function unescapeOne(hex: string): string { } /** - * Unescape one single quad string such as \u0127. + * Unescape one single quad string such as \u0127 / \U00000000 * Throws exception if the string doesn't match MATCH_QUAD_ESCAPE + * Note this does not attempt to handle or reject surrogates. + * So, `\\uD838\\uDD09` will work but other combinations may not. * @param s input string * @returns output */ @@ -48,13 +53,20 @@ export function unescapeOneQuadString(s: string): string { if (!s || !s.match(MATCH_QUAD_ESCAPE)) { throw new UnescapeError(`Not a quad escape: ${s}`); } - function processMatch(str: string, matched: string): string { - return unescapeOne(matched); + function processMatch(str: string, m16: string, m32: string): string { + return unescapeOne(m16 || m32); // either \u or \U } s = s.replace(MATCH_QUAD_ESCAPE, processMatch); return s; } +/** unscape multiple occurences of \u0127 style strings */ +export function unescapeQuadString(s: string): string { + s = s.replaceAll(MATCH_QUAD_ESCAPE, (quad) => unescapeOneQuadString(quad)); + return s; +} + + /** * Unescapes a string according to UTS#18§1.1, see * @param s escaped string @@ -88,6 +100,87 @@ export function unescapeString(s: string): string { return s; } +/** 0000 … FFFF */ +export function hexQuad(n: number): string { + if (n < 0x0000 || n > 0xFFFF) { + throw RangeError(`${n} not in [0x0000,0xFFFF]`); + } + return n.toString(16).padStart(4, '0'); +} + +/** 00000000 … FFFFFFFF */ +export function hexOcts(n: number): string { + if (n < 0x0000 || n > 0xFFFFFFFF) { + throw RangeError(`${n} not in [0x00000000,0xFFFFFFFF]`); + } + return n.toString(16).padStart(8, '0'); +} + +/** escape one char for regex in \uXXXX form */ +function escapeRegexChar(ch: string) { + const code = ch.codePointAt(0); + if (code <= 0xFFFF) { + return '\\u' + hexQuad(code); + } else { + return '\\U' + hexOcts(code); + } +} + +/** chars that must be escaped: syntax, C0 + C1 controls */ +const REGEX_SYNTAX_CHAR = /^[\u0000-\u001F\u007F-\u009F{}\[\]\\?.^$*()/-]$/; + +function escapeRegexCharIfSyntax(ch: string) { + // escape if syntax or not valid + if (REGEX_SYNTAX_CHAR.test(ch) || !isValidUnicode(ch.codePointAt(0))) { + return escapeRegexChar(ch); + } else { + return ch; // leave unescaped + } +} + +/** + * Unescape one codepoint to \u or \U format + * @param hex one codepoint in hex, such as '0127' + * @returns the unescaped codepoint + */ +function regexOne(hex: string): string { + const unescaped = unescapeOne(hex); + // re-escape as 16 or 32 bit code units + return Array.from(unescaped).map(ch => escapeRegexCharIfSyntax(ch)).join(''); +} +/** + * Unescapes a string according to UTS#18§1.1, see + * @param s escaped string + * @returns + */ +export function unescapeStringToRegex(s: string): string { + if(!s) { + return s; + } + try { + /** + * process one regex match + * @param str ignored + * @param matched the entire match such as '0127' or '22 22' + * @returns the unescaped match + */ + function processMatch(str: string, matched: string) : string { + const codepoints = matched.split(' '); + const unescaped = codepoints.map(regexOne); + return unescaped.join(''); + } + s = s.replaceAll(MATCH_HEX_ESCAPE, processMatch); + } catch(e) { + if (e instanceof RangeError) { + throw new UnescapeError(`Out of range while unescaping '${s}': ${e.message}`, { cause: e }); + /* c8 ignore next 3 */ + } else { + throw e; // pass through some other error + } + } + return s; +} + /** True if this string *could* be a UTF-32 single char */ export function isOneChar(value: string) : boolean { diff --git a/common/web/types/test/ldml-keyboard/test-pattern-parser.ts b/common/web/types/test/ldml-keyboard/test-pattern-parser.ts index 0274f2a5c2..4af2c29459 100644 --- a/common/web/types/test/ldml-keyboard/test-pattern-parser.ts +++ b/common/web/types/test/ldml-keyboard/test-pattern-parser.ts @@ -92,6 +92,10 @@ describe('Test of Pattern Parsers', () => { `Give me \\m{a} and \\m{c}, or \\m{.}.`, markers), `Give me \uFFFF\u0008\u0001 and \uFFFF\u0008\u0003, or \uFFFF\u0008\uD7FF.` ); + assert.equal(MarkerParser.toSentinelString( + `Give me \\m{a} and \\m{c}, or \\m{.}.`, markers, true), + `Give me \\uffff\\u0008\\u0001 and \\uffff\\u0008\\u0003, or ${MarkerParser.ANY_MARKER_MATCH}.` + ); assert.throws(() => MarkerParser.toSentinelString( `Want to see something funny? \\m{zzz}`, // out of range diff --git a/common/web/types/test/util/test-unescape.ts b/common/web/types/test/util/test-unescape.ts index 732e9af2bb..989951ae5c 100644 --- a/common/web/types/test/util/test-unescape.ts +++ b/common/web/types/test/util/test-unescape.ts @@ -1,6 +1,6 @@ import 'mocha'; import {assert} from 'chai'; -import {unescapeString, UnescapeError, isOneChar, toOneChar, unescapeOneQuadString, BadStringAnalyzer, isValidUnicode, describeCodepoint, isPUA, BadStringType} from '../../src/util/util.js'; +import {unescapeString, UnescapeError, isOneChar, toOneChar, unescapeOneQuadString, BadStringAnalyzer, isValidUnicode, describeCodepoint, isPUA, BadStringType, unescapeStringToRegex, unescapeQuadString} from '../../src/util/util.js'; describe('test UTF32 functions()', function() { it('should properly categorize strings', () => { @@ -57,16 +57,40 @@ describe('test unescapeString()', function() { }); }); +describe('test unescapeRegex()', () => { + it("should correctly handle 1..6 char escapes", function() { + assert.equal(unescapeStringToRegex('\\u{9}'), '\\u0009'); // TAB + assert.equal(unescapeStringToRegex('\\u{5b}'), '\\u005b'); // [ + assert.equal(unescapeStringToRegex('\\u{005b}'), '\\u005b'); // [ + assert.equal(unescapeStringToRegex('\\u{4a}'), 'J'); // J + assert.equal(unescapeStringToRegex('\\u{8a}'), '\\u008a'); // J + assert.equal(unescapeStringToRegex('\\u{3c8}'), 'ψ'); // ψ + assert.equal(unescapeStringToRegex('\\u{304B}'), 'か'); // か + assert.equal(unescapeStringToRegex('\\u{ffff}'), '\\uffff'); // noncharacter + assert.equal(unescapeStringToRegex('\\u{1e109}'), '𞄉'); // 𞄉 + assert.equal(unescapeStringToRegex('\\u{1ffff}'), '\\U0001ffff'); // nonchar + assert.equal(unescapeStringToRegex('\\u{10fff0}'), '\u{10fff0}'); // Plane 16 Private Use + assert.equal(unescapeStringToRegex('\\u{10ffff}'), '\\U0010ffff'); // nonchar + }); +}); + describe('test unescapeOneQuadString()', () => { it('should be able to convert', () => { // testing that `\u0127` is unescaped correctly (to U+0127: 'ħ') assert.equal(unescapeOneQuadString('\\u0127'), '\u{0127}'); + assert.equal(unescapeOneQuadString('\\U0010FFF0'), '\u{10fff0}'); // test the fail cases }); it('should fail when it needs to fail', () => { assert.throws(() => unescapeOneQuadString(null), null); assert.throws(() => unescapeOneQuadString('\uFFFFFFFFFFFF')); }); + const PAIRED=`\\uD838\\uDD09`; + it('test of paired surrogates ${UNPAIRED}', () => { + const s = unescapeQuadString(PAIRED); + assert.equal(s, '\u{1e109}'); + assert.equal(s, '\u{d838}\u{dd09}'); + }); }); function titleize(o : any) { diff --git a/core/src/ldml/ldml_markers.cpp b/core/src/ldml/ldml_markers.cpp index 3230614ab4..fa0ac440af 100644 --- a/core/src/ldml/ldml_markers.cpp +++ b/core/src/ldml/ldml_markers.cpp @@ -19,6 +19,12 @@ namespace core { namespace ldml { +// the 'prefix part' of a regex marker sequence, followed by RAW_PREFIX or REGEX_ANY_MATCH +const std::u32string REGEX_PREFIX = U"\\uffff\\u0008"; +const std::u32string RAW_PREFIX = U"\uffff\u0008"; +const std::u32string REGEX_ANY_MATCH = U"[\\u0001-\\ud7fe]"; +const KMX_DWORD LDML_MARKER_NO_INDEX = 0; // TODO: move to .ts + // string manipulation /** internal function to normalize with a specified mode */ @@ -51,52 +57,51 @@ bool normalize_nfd(std::u16string &str) { return normalize(nfd, str, status); } -bool normalize_nfd_markers_segment(std::u16string &str, marker_map &map, marker_encoding encoding) { - std::u32string rstr = km::core::kmx::u16string_to_u32string(str); - if(!normalize_nfd_markers_segment(rstr, map, encoding)) { - return false; - } else { - str = km::core::kmx::u32string_to_u16string(rstr); - return true; - } -} - -static void add_back_markers(std::u32string &str, const std::u32string &src, const marker_map &map, marker_encoding encoding) { +static void add_back_markers(std::u32string &str, const std::u32string &src, marker_map &map, marker_encoding encoding) { // need to reconstitute. marker_map map2(map); // make a copy of the map // clear the string str.clear(); - // add the end-of-text marker - { - const auto ch = MARKER_BEFORE_EOT; - const auto m = map2.find(ch); - if (m != map2.end()) { - for (auto q = (m->second).rbegin(); q < (m->second).rend(); q++) { - prepend_marker(str, *q, encoding); - } - map2.erase(ch); // remove it - } + // iterator over the marker map + auto marki = map2.rbegin(); + // number of markers left to process + size_t processed_count = map2.size(); + + // add any end-of-text markers + while(marki != map2.rend() && marki->first == MARKER_BEFORE_EOT) { + prepend_marker(str, marki->second, encoding); + processed_count--; + marki->second = 0; // mark as done + marki++; } + // go from end to beginning of string for (auto p = src.rbegin(); p != src.rend(); p++) { const auto ch = *p; str.insert(0, 1, ch); // prepend - const auto m = map2.find(ch); - if (m != map2.end()) { - for (auto q = (m->second).rbegin(); q < (m->second).rend(); q++) { - prepend_marker(str, *q, encoding); + // add the markers at the end of the list first. + for (; marki != map2.rend() && marki->first == ch; marki++) { + if (marki->second != 0) { + // set to '0' if already applied + prepend_marker(str, marki->second, encoding); + marki->second = 0; // mark as already applied + processed_count--; + } + } + + // now, add any out of order markers. + for (auto marki2 = marki; marki2 != map2.rend(); marki2++) { + if (marki2->second != 0 && marki2->first == ch) { + prepend_marker(str, marki2->second, encoding); + marki2->second = 0; // mark as already applied + processed_count--; } - map2.erase(ch); // remove it } } + assert(processed_count == 0); // assert that we consumed all marks } -/** - * TODO-LDML: - * - doesn't support >1 marker per char - may need a set instead of a map! - * - ideally this should be used on a normalization safe subsequence - */ bool normalize_nfd_markers_segment(std::u32string &str, marker_map &map, marker_encoding encoding) { /** original string, but no markers */ std::u32string str_unmarked = remove_markers(str, map, encoding); @@ -116,16 +121,160 @@ bool normalize_nfd_markers_segment(std::u32string &str, marker_map &map, marker_ return true; // all OK } -bool normalize_nfd_markers(std::u16string &str, marker_encoding encoding) { - marker_map m; - // TODO-LDML: split segments - return normalize_nfd_markers_segment(str, m, encoding); +/** + * @param i iteration point. will always advance unless at end + * @param end end of input + * @returns marker number, or LDML_MARKER_NO_INDEX if no marker (and i will be unmoved) + */ +marker_num parse_next_marker(std::u32string::const_iterator &i, const std::u32string::const_iterator &end, marker_encoding encoding) { + if (i == end) { + return LDML_MARKER_NO_INDEX; + } + const auto &lookfor_str = (encoding == regex_sentinel) ? REGEX_PREFIX : RAW_PREFIX; + std::u32string rest(i, end); + if (rest.length() <= lookfor_str.length()) { // <= because we need at least 1 char for the rest of the marker payload + // input too short + i++; + return LDML_MARKER_NO_INDEX; + } + if (0 != rest.compare(0, lookfor_str.length(), lookfor_str)) { + // advance past initial char + i++; + return LDML_MARKER_NO_INDEX; // prefix mismatch + } + i += lookfor_str.length(); // matches, so advance + + // handle the plain_sentinel option here + if (encoding == plain_sentinel) { + marker_num marker_no = *(i++); // advance past marker no + assert(marker_no >= LDML_MARKER_MIN_INDEX && marker_no <= LDML_MARKER_ANY_INDEX); + return marker_no; + } + + assert(encoding == regex_sentinel); + + // TODO-LDML: could change this from iterator to using 'rest' + if (*i == U'\\') { + // single marker + if (++i == end) { + return LDML_MARKER_NO_INDEX; + } + assert(*i == U'u'); + if (++i == end) { + return LDML_MARKER_NO_INDEX; + } + km_core_usv markno[4]; + + markno[0] = *(i++); + if (i == end) { + return LDML_MARKER_NO_INDEX; + } + markno[1] = *(i++); + if (i == end) { + return LDML_MARKER_NO_INDEX; + } + markno[2] = *(i++); + if (i == end) { + return LDML_MARKER_NO_INDEX; + } + markno[3] = *(i++); + auto marker_no = parse_hex_quad(markno); + assert(marker_no >= LDML_MARKER_MIN_INDEX && marker_no <= LDML_MARKER_MAX_INDEX); + return marker_no; + } else if (*i == REGEX_ANY_MATCH.at(0)) { // '[' + std::u32string rest2(i, end); // TODO-LDML: could use 'rest' above + if (rest2.length() < REGEX_ANY_MATCH.length()) { + // not enough left so it can't match, so continue + return LDML_MARKER_NO_INDEX; + } + i += REGEX_ANY_MATCH.length(); + return LDML_MARKER_ANY_INDEX; + } + return LDML_MARKER_NO_INDEX; } bool normalize_nfd_markers(std::u32string &str, marker_encoding encoding) { - marker_map m; - // TODO-LDML: split segments - return normalize_nfd_markers_segment(str, m, encoding); + // quick check - don't bother if the string is empty + if(str.empty()) return true; + + // we're going to need an NFD normalizer + UErrorCode status = U_ZERO_ERROR; + const icu::Normalizer2 *nfd = icu::Normalizer2::getNFDInstance(status); + if (!UASSERT_SUCCESS(status)) { + return false; + } + + /** + * output string, we'll accumulate the normalized string here + */ + std::u32string out; + /** + * this is the beginning of the current segment to process. + * it will also be the beginning of the string OR the end of the previous segment. + */ + std::u32string::const_iterator seg_start = str.begin(); + /** end of the current segment. This will be == seg_start unless a new segment is identified. */ + std::u32string::const_iterator seg_end = str.begin(); + /** iterator through this loop */ + std::u32string::const_iterator i = str.begin(); + + // now we'll loop through looking for normalization-safe subsegments of [seg_start, seg_end) + // For example (sentinel mode) the following would be segments: + // - A + // - E\u0320\u0302 + // - U\u0320\m{marker}\u0300 + // + // The marker will typically 'look' like an NFD-safe boundary, but it's not! It's part of the + // subsegment, we want to skip over it. This is why we use parse_next_marker to look-ahead to see + // if there is actually a marker under the iterator. + // + // We don't assume that the marker sentinel or regex appears as an NFD boundary, that is why + // the parse function and the nfd function are called in parallel. + + do { + + // First, check if there's a marker. + + // temporary iterator so we don't move 'i' unnecessarily. points to end of marker sequence. + std::u32string::const_iterator marker_end = i; + // true if marker detected + bool have_marker = parse_next_marker(marker_end, str.end(), encoding) != LDML_MARKER_NO_INDEX; + + + // Now, categorize the string. Is there a segment boundary BEFORE 'i'? + if (i == str.end()) { + // end of string, mark as the end of a segment + // this will cause the final segment to be processed and the loop exitted. + seg_end = i; + } else if (nfd->hasBoundaryBefore(*i) && !have_marker) { + // 'i' is the beginning of an NFD safe boundary (such as a base character). + // but it's also not on a marker (which would be included in the segment) + seg_end = i; + // we also need to advance so we can pick up the next char. + i++; + } else if (have_marker) { + // it's actually a marker. so, advance over it. + i = marker_end; + } else { + // some other non boundary char. + // advance without further drama + i++; + } + + // Finally, process any identified segment. + if (seg_end != seg_start) { // is the segment non-empty? + std::u32string segment(seg_start, seg_end); + marker_map m; + if (!normalize_nfd_markers_segment(segment, m, encoding)) { + return false; + } + out.append(segment); + seg_start = seg_end; + } + } while(seg_end != str.end()); // repeat until the last codepoint has been processed in a segment + // update output string + str = out; + return true; } // TODO-LDML: cleanup @@ -176,23 +325,15 @@ prepend_marker(std::u32string &str, marker_num marker, marker_encoding encoding) assert(encoding == regex_sentinel); if (marker == LDML_MARKER_ANY_INDEX) { // recreate the regex from back to front - str.insert(0, 1, U']'); - prepend_hex_quad(str, LDML_MARKER_MAX_INDEX); - str.insert(0, 1, U'u'); - str.insert(0, 1, U'\\'); - str.insert(0, 1, U'-'); - prepend_hex_quad(str, LDML_MARKER_MIN_INDEX); - str.insert(0, 1, U'u'); - str.insert(0, 1, U'\\'); - str.insert(0, 1, U'['); - str.insert(0, 1, LDML_MARKER_CODE); - str.insert(0, 1, LDML_UC_SENTINEL); + str.insert(0, REGEX_ANY_MATCH); + str.insert(0, REGEX_PREFIX); } else { // add hex part prepend_hex_quad(str, marker); // add static part - km_core_usv markstr[] = {LDML_UC_SENTINEL, LDML_MARKER_CODE, u'\\', u'u'}; - str.insert(0, markstr, 4); + km_core_usv markstr[] = {u'\\', u'u'}; + str.insert(0, markstr, 2); + str.insert(0, REGEX_PREFIX); } } } @@ -239,175 +380,77 @@ KMX_DWORD parse_hex_quad(const km_core_usv hex_str[]) { } /** add the list to the map */ -void add_markers_to_map(marker_map &markers, char32_t marker_ch, const marker_list &list) { - auto rep = markers.emplace(marker_ch, list); - if (!rep.second) { - // already existed. - auto existing = rep.first; - // append all additional ones - for(auto m = list.begin(); m < list.end(); m++) { - existing->second.emplace_back(*m); - } +static void add_markers_to_map(marker_map &markers, char32_t marker_ch, const marker_list &list) { + for (auto i = list.begin(); i < list.end(); i++) { + // marker_ch is duplicate, but keeps the structure more shallow. + markers.emplace_back(marker_ch, *i); } } -std::u32string remove_markers(const std::u32string &str, marker_map *markers, marker_encoding encoding) { +/** + * Add any markers, if needed + * @param markers marker map or nullptr + * @param last the 'last' parameter past the prior parsing + * @param end end of the input string + */ +inline void +add_pending_markers( + marker_map *markers, + marker_list &last_markers, + const std::u32string::const_iterator &last, + const std::u32string::const_iterator &end) { + if(markers == nullptr || last_markers.empty()) { + return; + } + char32_t marker_ch; + if (last == end) { + marker_ch = MARKER_BEFORE_EOT; + } else { + marker_ch = *last; + } + add_markers_to_map(*markers, marker_ch, last_markers); + last_markers.clear(); // mark as already recorded +} + +std::u32string +remove_markers(const std::u32string &str, marker_map *markers, marker_encoding encoding) { std::u32string out; - auto i = str.begin(); - auto last = i; marker_list last_markers; - for (i = find(i, str.end(), LDML_UC_SENTINEL); i != str.end(); i = find(i, str.end(), LDML_UC_SENTINEL)) { - // append any prefix (from prior pos'n to here) - out.append(last, i); - // #1: LDML_UC_SENTINEL (what we searched for) - assert(*i == LDML_UC_SENTINEL); // assert that find() worked - i++; - last = i; - if (i == str.end()) { - break; // hit end - } - - // #2 LDML_MARKER_CODE - if (*i != LDML_MARKER_CODE) { - continue; // can't process this, get out - } - i++; - last = i; - if (i == str.end()) { - break; // hit end - } - - KMX_DWORD marker_no; - if (encoding == plain_sentinel) { - // #3 marker number - marker_no = *i; - i++; // if end, we'll break out of the loop + auto last = str.begin(); // points to the part of the string after the last matched marker + for (auto i = str.begin(); i != str.end();) { + auto marker_no = parse_next_marker(i, str.end(), encoding); + if (marker_no == LDML_MARKER_NO_INDEX) { + // add any markers found before this entry, but only if there is intervening + // text. This prevents the sentinel or the '\u' from becoming the attachment char. + if (i != last) { + add_pending_markers(markers, last_markers, last, str.end()); + out.append(last, i); // append any non-marker text since the end of the last marker + last = i; // advance over text we've already appended + } } else { - assert(encoding == regex_sentinel); - // is it an escape or a range? - if (*i == U'\\') { - if (++i == str.end()) { - break; - } - assert(*i == U'u'); - if (++i == str.end()) { - break; - } - km_core_usv markno[4]; + assert(marker_no >= LDML_MARKER_MIN_INDEX && marker_no <= LDML_MARKER_ANY_INDEX); + // The marker number is good, add it to the list + if (marker_no >= LDML_MARKER_MIN_INDEX && markers != nullptr) { + // add it to the list + last_markers.emplace_back(marker_no); + } + last = i; // skip over marker + } + } - markno[0] = *(i++); - if (i == str.end()) { - break; - } - markno[1] = *(i++); - if (i == str.end()) { - break; - } - markno[2] = *(i++); - if (i == str.end()) { - break; - } - markno[3] = *(i++); - marker_no = parse_hex_quad(markno); - assert (marker_no != 0); // illegal marker number - } else if (*i == U'[') { - if (++i == str.end()) { - break; - } - assert(*i == U'\\'); - if (++i == str.end()) { - break; - } - assert(*i == U'u'); - if (++i == str.end()) { - break; - } - assert(xdigitval(*i) != -1); - if (++i == str.end()) { - break; - } - assert(xdigitval(*i) != -1); - if (++i == str.end()) { - break; - } - assert(xdigitval(*i) != -1); - if (++i == str.end()) { - break; - } - assert(xdigitval(*i) != -1); - if (++i == str.end()) { - break; - } - assert(*i == U'-'); - if (++i == str.end()) { - break; - } - assert(*i == U'\\'); - if (++i == str.end()) { - break; - } - assert(*i == U'u'); - if (++i == str.end()) { - break; - } - assert(xdigitval(*i) != -1); - if (++i == str.end()) { - break; - } - assert(xdigitval(*i) != -1); - if (++i == str.end()) { - break; - } - assert(xdigitval(*i) != -1); - if (++i == str.end()) { - break; - } - assert(xdigitval(*i) != -1); - if (++i == str.end()) { - break; - } - assert(*i == U']'); - i++; - marker_no = LDML_MARKER_ANY_INDEX; - } else { - assert(*i == U'\\' || *i == U'['); // error. - marker_no = 0; // error, don't record - } - } - assert(marker_no >= LDML_MARKER_MIN_INDEX && marker_no <= LDML_MARKER_ANY_INDEX); - // The marker number is good, add it to the list - last = i; - // record the marker - if (marker_no >= LDML_MARKER_MIN_INDEX && markers != nullptr) { - // add it to the list - last_markers.emplace_back(marker_no); - char32_t marker_ch; - if (i == str.end()) { - // Hit end, so mark it as the end - marker_ch = MARKER_BEFORE_EOT; - } else if (*i == LDML_UC_SENTINEL) { - // it's another marker (presumably) - continue; // loop around - } else { - marker_ch = *i; - } - add_markers_to_map(*markers, marker_ch, last_markers); - last_markers.clear(); // mark as already recorded - } - } - // get the suffix between the last marker and the end + // add any remaining pending markers. + // if last == str.end() then this wil be MARKER_BEFORE_EOT + // otherwise it will be the glue character + add_pending_markers(markers, last_markers, last, str.end()); + // get the suffix between the last marker and the end (could be nothing) out.append(last, str.end()); - if (!last_markers.empty() && markers != nullptr) { - // we had markers but couldn't find the base. - // it's possible that there was a malformed UC_SENTINEL string in between. - // Add it to the end. - add_markers_to_map(*markers, MARKER_BEFORE_EOT, last_markers); - } return out; } + +// --- end namespaces } } } diff --git a/core/src/ldml/ldml_markers.hpp b/core/src/ldml/ldml_markers.hpp index 68c7aa2f2d..7212330656 100644 --- a/core/src/ldml/ldml_markers.hpp +++ b/core/src/ldml/ldml_markers.hpp @@ -48,11 +48,14 @@ enum marker_encoding { /** a marker ID (1-based) */ typedef KMX_DWORD marker_num; -/** list of markers */ +/** list of marker numbers */ typedef std::deque marker_list; -/** map from following-char to marker numbers. */ -typedef std::map marker_map; +/** map from one char to one entry */ +typedef std::pair marker_entry; + +/** map from following-char to marker numbers, in front to back order */ +typedef std::deque marker_map; /** Normalize a u32string inplace to NFD. @return false on failure */ bool normalize_nfd(std::u32string &str); @@ -63,9 +66,7 @@ bool normalize_nfd(std::u16string &str); * @return false on failure **/ bool normalize_nfd_markers_segment(std::u32string &str, marker_map &markers, marker_encoding encoding = plain_sentinel); -bool normalize_nfd_markers_segment(std::u16string &str, marker_map &markers, marker_encoding encoding = plain_sentinel); bool normalize_nfd_markers(std::u32string &str, marker_encoding encoding = plain_sentinel); -bool normalize_nfd_markers(std::u16string &str, marker_encoding encoding = plain_sentinel); // /** Normalize a u32string inplace to NFC, retaining markers. // * @param markers will be populated with marker chars diff --git a/core/src/ldml/ldml_processor.cpp b/core/src/ldml/ldml_processor.cpp index 42baaed0a4..6e2bd4b652 100644 --- a/core/src/ldml/ldml_processor.cpp +++ b/core/src/ldml/ldml_processor.cpp @@ -283,19 +283,23 @@ ldml_processor::process_key_string(km_core_state *state, const std::u16string &k size_t ldml_processor::process_output(km_core_state *state, const std::u32string &str, ldml::transforms *with_transforms) const { std::u32string nfd_str = str; - assert(ldml::normalize_nfd_markers(nfd_str)); // TODO-LDML: else fail? + // Note: + // The normalize functions have assert and Debuglog at the bottom. + // so we do not need to assert the status here unless we're going to do something + // different with control flow. + (void)ldml::normalize_nfd_markers(nfd_str); + // extract context string, in NFD std::u32string old_ctxtstr_nfd; (void)context_to_string(state, old_ctxtstr_nfd, true); - assert(ldml::normalize_nfd_markers(old_ctxtstr_nfd)); // TODO-LDML: else fail? + (void)ldml::normalize_nfd_markers(old_ctxtstr_nfd); // context string in NFD std::u32string ctxtstr; (void)context_to_string(state, ctxtstr, true); // with markers // add the newly added key output to ctxtstr ctxtstr.append(nfd_str); - assert(ldml::normalize_nfd_markers(ctxtstr)); // TODO-LDML: else fail? - + (void)ldml::normalize_nfd_markers(ctxtstr); /** transform output string */ std::u32string outputString; /** how many chars of the ctxtstr to replace */ @@ -305,9 +309,7 @@ size_t ldml_processor::process_output(km_core_state *state, const std::u32string if(with_transforms != nullptr) { matchedContext = with_transforms->apply(ctxtstr, outputString); - } else { - // no transforms, no output - } + } // else: no transforms, no output // Short Circuit: if no transforms matched, and no new text is being output, // just return. @@ -318,27 +320,13 @@ size_t ldml_processor::process_output(km_core_state *state, const std::u32string // drop last 'matchedContext': ctxtstr.resize(ctxtstr.length() - matchedContext); ctxtstr.append(outputString); // TODO-LDML: should be able to do a normalization-safe append here. - { - const auto normalize_ok = ldml::normalize_nfd_markers(ctxtstr); - assert(normalize_ok); - if(!normalize_ok) { - DebugLog("ldml_processor::process_output: failed ldml::normalize_nfd_markers(ctxtstr)"); - } - } + (void)ldml::normalize_nfd_markers(ctxtstr); // Ok. We've done all the happy manipulations. /** NFD w/ markers */ std::u32string ctxtstr_cleanedup = ctxtstr; - { - const auto normalize_ok = ldml::normalize_nfd_markers(ctxtstr_cleanedup); - assert(normalize_ok); - if(!normalize_ok) { - DebugLog("ldml_processor::process_output: failed ldml::normalize_nfd_markers(ctxtstr_cleanedup)"); - } - } - - assert(ldml::normalize_nfd_markers(ctxtstr_cleanedup)); + (void)ldml::normalize_nfd_markers(ctxtstr_cleanedup); // find common prefix. // For example, if the context previously had "aaBBBBB" and it is changing to "aaCCC" then we will have: @@ -346,6 +334,32 @@ size_t ldml_processor::process_output(km_core_state *state, const std::u32string // - new_ctxtstr_changed = "CCC" // So the BBBBB needs to be removed and then CCC added. auto ctxt_prefix = mismatch(old_ctxtstr_nfd.begin(), old_ctxtstr_nfd.end(), ctxtstr_cleanedup.begin(), ctxtstr_cleanedup.end()); + + // handle a special case where we're simply changing from one marker to another. + // Example: + // 0. old_ctxtstr_changed ends with … U+FFFF U+0008 | U+0001 … + // 1. ctxtstr_cleanedup ends with … U+FFFF U+0008 | U+0002 … + // Pipe symbol shows where the difference starts. + // As you can see, the different starts in the MIDDLE of a marker sequence. + // so, old_ctxtstr_changed will start with U+0001 + // and new_ctxtstr_changed will start with U+0002 + // remove_text will only be able to delete up to and through the U+0001 + // and it will need to emit a push_backspace(KM_CORE_BT_MARKER,…) due to the + // marker change. + // We can detect this because the unchanged_prefix will end with u+FFFF U+0008 + // + // Oh, and yes, test case 'regex-test-8a-0' hits this. + std::u32string common_prefix(old_ctxtstr_nfd.begin(), ctxt_prefix.first); + if (common_prefix.length() >= 2) { + auto iter = common_prefix.rbegin(); + if (*(iter++) == LDML_MARKER_CODE && *(iter++) == UC_SENTINEL) { + // adjust the iterator so that the "U+FFFF U+0008" is not a part of the common prefix. + ctxt_prefix.first -= 2; + ctxt_prefix.second += 2; + // Now, old_ctxtstr_changed and new_ctxtstr_changed will start with U+FFFF U+0008 … + } + } + /** The part of the old string to be removed */ std::u32string old_ctxtstr_changed(ctxt_prefix.first,old_ctxtstr_nfd.end()); /** The new context to be added */ @@ -367,6 +381,8 @@ size_t ldml_processor::process_output(km_core_state *state, const std::u32string void ldml_processor::remove_text(km_core_state *state, std::u32string &str, size_t length) { + // str is the string to remove, so it should be at least as long as length + assert(length <= str.length()); /** track how many context items have been removed, via push_backspace() */ size_t contextRemoved = 0; for (auto c = state->context().rbegin(); length > 0 && c != state->context().rend(); c++, contextRemoved++) { @@ -382,23 +398,23 @@ ldml_processor::remove_text(km_core_state *state, std::u32string &str, size_t le // Cause prior char to be removed state->actions().push_backspace(KM_CORE_BT_CHAR, c->character); } else if (type == KM_CORE_BT_MARKER) { - // It's a marker. - // need to be able to drop 3 chars assert(length >= 3); - length -= 3; + state->actions().push_backspace(KM_CORE_BT_MARKER, c->marker); // #3 - the marker. assert(lastCtx == c->marker); str.pop_back(); + length--; // #2 - the code assert(str.back() == LDML_MARKER_CODE); str.pop_back(); + length--; // #1 - the sentinel assert(str.back() == UC_SENTINEL); str.pop_back(); - // cause marker to be removed - state->actions().push_backspace(KM_CORE_BT_MARKER, c->marker); + length--; } } + assert(length == 0); // now, pop the context items for (size_t i = 0; i < contextRemoved; i++) { // we don't pop during the above loop because the iterator gets confused diff --git a/core/src/ldml/ldml_transforms.cpp b/core/src/ldml/ldml_transforms.cpp index c07f1d8cfc..9c0fd1f8d4 100644 --- a/core/src/ldml/ldml_transforms.cpp +++ b/core/src/ldml/ldml_transforms.cpp @@ -519,9 +519,10 @@ transform_entry::init() { return false; } // TODO-LDML: if we have mapFrom, may need to do other processing. - std::u16string patstr = km::core::kmx::u32string_to_u16string(fFrom); + std::u32string from2 = fFrom; + normalize_nfd_markers(from2, regex_sentinel); + std::u16string patstr = km::core::kmx::u32string_to_u16string(from2); // normalize, including markers, for regex - normalize_nfd_markers(patstr, regex_sentinel); UErrorCode status = U_ZERO_ERROR; /* const */ icu::UnicodeString patustr = icu::UnicodeString(patstr.data(), (int32_t)patstr.length()); // add '$' to match to end diff --git a/core/tests/unit/ldml/keyboards/k_020_fr.xml b/core/tests/unit/ldml/keyboards/k_020_fr.xml index a5fd85f1a3..297062e5d1 100644 --- a/core/tests/unit/ldml/keyboards/k_020_fr.xml +++ b/core/tests/unit/ldml/keyboards/k_020_fr.xml @@ -16,7 +16,7 @@ - - - - - - - + + + + + + diff --git a/core/tests/unit/ldml/test_transforms.cpp b/core/tests/unit/ldml/test_transforms.cpp index c541f038e1..89c8bbf59f 100644 --- a/core/tests/unit/ldml/test_transforms.cpp +++ b/core/tests/unit/ldml/test_transforms.cpp @@ -1,11 +1,11 @@ -#include "../../../src/ldml/ldml_transforms.hpp" #include "../../../src/ldml/ldml_markers.hpp" +#include "../../../src/ldml/ldml_transforms.hpp" #include "kmx/kmx_plus.h" #include "kmx/kmx_xstring.h" +#include "test_color.h" #include #include #include -#include "test_color.h" // TODO-LDML: normal asserts wern't working, so using some hacks. // #include "ldml_test_utils.hpp" @@ -13,18 +13,19 @@ // #include "debuglog.h" #ifndef zassert_string_equal -#define zassert_string_equal(actual, expected) \ - { \ - if (actual != expected) { \ - std::wcerr << __FILE__ << ":" << __LINE__ << ": " << console_color::fg(console_color::BRIGHT_RED) << "got: " << km::core::kmx::Debug_UnicodeString(actual, 0) \ - << " expected " << km::core::kmx::Debug_UnicodeString(expected, 1) << console_color::reset() << std::endl; \ - return EXIT_FAILURE; \ - } \ +#define zassert_string_equal(actual, expected) \ + { \ + if (actual != expected) { \ + std::wcerr << __FILE__ << ":" << __LINE__ << ": " << console_color::fg(console_color::BRIGHT_RED) \ + << "got: " << km::core::kmx::Debug_UnicodeString(actual, 0) << " expected " \ + << km::core::kmx::Debug_UnicodeString(expected, 1) << console_color::reset() << std::endl; \ + return EXIT_FAILURE; \ + } \ } #endif #ifndef zassert_equal -#define zassert_equal(actual, expected) \ +#define zassert_equal(actual, expected) \ { \ if (actual != expected) { \ std::wcerr << __FILE__ << ":" << __LINE__ << ": " << console_color::fg(console_color::BRIGHT_RED) << "got: " << actual \ @@ -34,33 +35,48 @@ } #endif - // needed for streaming operators #include "utfcodec.hpp" using namespace km::core::ldml; using namespace km::core::kmx; -std::u32string marker_list_to_string(const marker_list &m) { +void +prepend_hex_oct(std::u32string &str, char32_t x) { + for (auto i = 0; i < 8; i++) { + KMX_DWORD remainder = x & 0xF; // get the last nibble + const char32_t ch = remainder < 0xA ? U'0' + remainder : U'A' + (remainder - 0xA); + str.insert(0, 1, ch); // prepend + x >>= 4; + } +} + +std::u32string +marker_map_to_string(const marker_map &m) { std::u32string s; for (auto i = m.rbegin(); i < m.rend(); i++) { - prepend_hex_quad(s, *i); + prepend_hex_oct(s, i->first); + s.insert(0, U"=U+"); + + prepend_hex_quad(s, i->second); s.insert(0, U" \\m0x"); } return s; } -bool _assert_marker_list_equal(const char *f, int l, const marker_list a, const marker_list x) { - if (a == x) return true; +bool +_assert_marker_map_equal(const char *f, int l, const marker_map a, const marker_map x) { + if (a == x) + return true; std::wcerr << f << ":" << l << ": " << console_color::fg(console_color::BRIGHT_RED); - std::wcerr << "got: " << marker_list_to_string(a); - std::wcerr << " expected: " << marker_list_to_string(x); + std::wcerr << "got: " << marker_map_to_string(a); + std::wcerr << " expected: " << marker_map_to_string(x); std::wcerr << console_color::reset() << std::endl; return false; } -#define assert_marker_list_equal(actual, expected) \ - if (!_assert_marker_list_equal(__FILE__, __LINE__, (actual), (expected))) \ +#define assert_marker_map_equal(actual, expected) \ + if (!_assert_marker_map_equal(__FILE__, __LINE__, (actual), (expected))) \ return EXIT_FAILURE; // using km::core::kmx::u16cmp; @@ -222,7 +238,7 @@ test_reorder_standalone() { std::cout << __FILE__ << ":" << __LINE__ << " - element API test " << std::endl; // element test { - element es(U'a', (80 << LDML_ELEM_FLAGS_ORDER_BITSHIFT) | LDML_ELEM_FLAGS_PREBASE); // tertiary -12, primary 80 + element es(U'a', (80 << LDML_ELEM_FLAGS_ORDER_BITSHIFT) | LDML_ELEM_FLAGS_PREBASE); // tertiary -12, primary 80 std::cout << "es flags" << std::hex << es.get_flags() << std::dec << std::endl; // verify element metadata assert_equal(es.is_uset(), false); @@ -386,19 +402,19 @@ test_reorder_standalone() { std::cout << __FILE__ << ":" << __LINE__ << " - trying roast #" << r << "=" << roast << std::endl; // try apply with string { - std::cout << "- try apply(text, output)" << std::endl; - std::u32string text = roast; - std::u32string output; - size_t len = tr.apply(text, output); - if (len == 0) { - std::cout << " (did not apply)" << std::endl; - } else { - std::cout << " applied, matchLen= " << len << std::endl; - text.resize(text.size()-len); // shrink - text.append(output); - std::cout << " = " << text << std::endl; - } - zassert_string_equal(text, expect); + std::cout << "- try apply(text, output)" << std::endl; + std::u32string text = roast; + std::u32string output; + size_t len = tr.apply(text, output); + if (len == 0) { + std::cout << " (did not apply)" << std::endl; + } else { + std::cout << " applied, matchLen= " << len << std::endl; + text.resize(text.size() - len); // shrink + text.append(output); + std::cout << " = " << text << std::endl; + } + zassert_string_equal(text, expect); } // try all-at-once { @@ -435,7 +451,7 @@ test_reorder_standalone() { // special test { std::cout << __FILE__ << ":" << __LINE__ << " - special test " << std::endl; - const std::u32string expect = U"\u1A21\u1A60\u1A45"; // this string shouldn't mutate at all. + const std::u32string expect = U"\u1A21\u1A60\u1A45"; // this string shouldn't mutate at all. { std::u32string text = expect; tr.apply(text); @@ -454,7 +470,6 @@ test_reorder_standalone() { return EXIT_SUCCESS; } - // this test case is also in XML form under 'k_201_*' int test_reorder_esk() { @@ -516,34 +531,34 @@ test_reorder_esk() { // now actually test it std::cout << __FILE__ << ":" << __LINE__ << " - cases " << std::endl; const std::u32string orig_expect[] = { - // 1short - U"ax\u0305", // orig - U"a\u0305x", // expect + // 1short + U"ax\u0305", // orig + U"a\u0305x", // expect - // 2longer - U"az\u0305x\u0332", // orig - U"a\u0332\u0305xz", // expect + // 2longer + U"az\u0305x\u0332", // orig + U"a\u0332\u0305xz", // expect }; // TODO-LDML: move this into test code perhaps - for (size_t r = 0; r < sizeof(orig_expect) / sizeof(orig_expect[0]); r+= 2) { + for (size_t r = 0; r < sizeof(orig_expect) / sizeof(orig_expect[0]); r += 2) { const auto &orig = orig_expect[r + 0]; const auto &expect = orig_expect[r + 1]; - std::cout << __FILE__ << ":" << __LINE__ << " - trying str #" << r+1 << "=" << orig << std::endl; + std::cout << __FILE__ << ":" << __LINE__ << " - trying str #" << r + 1 << "=" << orig << std::endl; // try apply with string { - std::cout << "- try apply(text, output)" << std::endl; - std::u32string text = orig; - std::u32string output; - size_t len = tr.apply(text, output); - if (len == 0) { - std::cout << " (did not apply)" << std::endl; - } else { - std::cout << " applied, matchLen= " << len << std::endl; - text.resize(text.size()-len); // shrink - text.append(output); - std::cout << " = " << text << std::endl; - } - zassert_string_equal(text, expect); + std::cout << "- try apply(text, output)" << std::endl; + std::u32string text = orig; + std::u32string output; + size_t len = tr.apply(text, output); + if (len == 0) { + std::cout << " (did not apply)" << std::endl; + } else { + std::cout << " applied, matchLen= " << len << std::endl; + text.resize(text.size() - len); // shrink + text.append(output); + std::cout << " = " << text << std::endl; + } + zassert_string_equal(text, expect); } // try all-at-once { @@ -581,7 +596,8 @@ test_reorder_esk() { return EXIT_SUCCESS; } -int test_map() { +int +test_map() { std::cout << "== " << __FUNCTION__ << std::endl; std::cout << __FILE__ << ":" << __LINE__ << " transform_entry::findIndex" << std::endl; @@ -602,91 +618,96 @@ int test_map() { return EXIT_SUCCESS; } -int test_strutils() { +int +test_strutils() { std::cout << "== " << __FUNCTION__ << std::endl; std::cout << __FILE__ << ":" << __LINE__ << " * remove_markers" << std::endl; - { std::cout << __FILE__ << ":" << __LINE__ << " - basic test0" << std::endl; const std::u32string src = U"abc"; const std::u32string dst = remove_markers(src); - zassert_string_equal(dst, src); // unchanged + zassert_string_equal(dst, src); // unchanged } { marker_map map; std::cout << __FILE__ << ":" << __LINE__ << " - basic test" << std::endl; const std::u32string src = U"abc"; const std::u32string dst = remove_markers(src, map); - zassert_string_equal(dst, src); // unchanged + zassert_string_equal(dst, src); // unchanged assert_equal(map.size(), 0); } { marker_map map; std::cout << __FILE__ << ":" << __LINE__ << " - marker test" << std::endl; - const std::u32string src = U"6\U0000ffff\U00000008\U00000001e"; - const std::u32string dst = remove_markers(src, map); + const std::u32string src = U"6\U0000ffff\U00000008\U00000001e"; + const std::u32string dst = remove_markers(src, map); const std::u32string expect = U"6e"; zassert_string_equal(dst, expect); + marker_map expm = {{U'e', 0x1L}}; + assert_marker_map_equal(map, expm); // marker 1 @ e assert_equal(map.size(), 1); - marker_list exp_e = { 0x1L }; - assert_marker_list_equal(map[U'e'], exp_e); // marker 1 @ e } { marker_map map; std::cout << __FILE__ << ":" << __LINE__ << " - bad0" << std::endl; - const std::u32string src = U"6\U0000ffff\U00000008"; // missing trailing marker # - const std::u32string dst = remove_markers(src, map); - const std::u32string expect = U"6"; + const std::u32string src = U"6\U0000ffff\U00000008"; // missing trailing marker # + const std::u32string dst = remove_markers(src, map); + const std::u32string expect = src; zassert_string_equal(dst, expect); assert_equal(map.size(), 0); } { marker_map map; std::cout << __FILE__ << ":" << __LINE__ << " - bad1" << std::endl; - const std::u32string src = U"6\U0000ffffq"; // missing code - const std::u32string dst = remove_markers(src, map); - const std::u32string expect = U"6q"; + const std::u32string src = U"6\U0000ffffq"; // missing sentinel subtype + const std::u32string dst = remove_markers(src, map); + const std::u32string expect = src; // 'q' removed zassert_string_equal(dst, expect); assert_equal(map.size(), 0); } { marker_map map; - std::cout << __FILE__ << ":" << __LINE__ << " - bad1" << std::endl; - const std::u32string src = U"6\U0000ffff"; // missing code - const std::u32string dst = remove_markers(src, map); - const std::u32string expect = U"6"; + std::cout << __FILE__ << ":" << __LINE__ << " - bad1b" << std::endl; + const std::u32string src = U"6\U0000ffff"; // missing code + const std::u32string dst = remove_markers(src, map); + const std::u32string expect = src; + zassert_string_equal(dst, expect); + assert_equal(map.size(), 0); + } + { + marker_map map; + std::cout << __FILE__ << ":" << __LINE__ << " - bad1c" << std::endl; + const std::u32string src = U"6\U0000ffffzz"; // missing code + const std::u32string dst = remove_markers(src, map); + const std::u32string expect = src; zassert_string_equal(dst, expect); assert_equal(map.size(), 0); } { marker_map map; std::cout << __FILE__ << ":" << __LINE__ << " - marker end test" << std::endl; - const std::u32string src = U"6\U0000ffff\U00000008\U00000001"; - const std::u32string dst = remove_markers(src, map); + const std::u32string src = U"6\U0000ffff\U00000008\U00000001"; + const std::u32string dst = remove_markers(src, map); const std::u32string expect = U"6"; zassert_string_equal(dst, expect); + marker_map expm({{MARKER_BEFORE_EOT, 0x1L}}); + assert_marker_map_equal(map, expm); assert_equal(map.size(), 1); - marker_list exp_end = { 0x1L }; - assert_marker_list_equal(map[MARKER_BEFORE_EOT], exp_end); // marker 1 @ e } { marker_map map; std::cout << __FILE__ << ":" << __LINE__ << " - complex test" << std::endl; - const std::u32string src = U"6\U0000ffff\U00000008\U00000001e\U0000ffff\U00000008\U00000002\U00000320\U0000ffff\U00000008\U00000003\U00000300\U0000ffff\U00000008\U00000004"; - const std::u32string dst = remove_markers(src, map); + const std::u32string src = + U"6\U0000ffff\U00000008\U00000001e\U0000ffff\U00000008\U00000002\U00000320\U0000ffff\U00000008\U00000003\U00000300" + U"\U0000ffff\U00000008\U00000004"; + const std::u32string dst = remove_markers(src, map); const std::u32string expect = U"6e\U00000320\U00000300"; zassert_string_equal(dst, expect); + marker_map expm({{U'e', 0x1L}, {0x0320, 0x2L}, {0x0300, 0x3L}, {MARKER_BEFORE_EOT, 0x4L}}); + assert_marker_map_equal(map, expm); assert_equal(map.size(), 4); - marker_list exp_e = { 0x1L }; - assert_marker_list_equal(map[U'e'], exp_e); - marker_list exp_320 = { 0x2L }; - assert_marker_list_equal(map[0x0320], exp_320); - marker_list exp_300 = { 0x3L }; - assert_marker_list_equal(map[0x0300], exp_300); - marker_list exp_end = { 0x4L }; - assert_marker_list_equal(map[MARKER_BEFORE_EOT], exp_end); } { std::cout << __FILE__ << ":" << __LINE__ << " - prepend hex quad" << std::endl; @@ -712,17 +733,17 @@ int test_strutils() { assert_equal(parse_hex_quad(U"CAFE"), 0xCAFE); assert_equal(parse_hex_quad(U"D00d"), 0xD00D); assert_equal(parse_hex_quad(U"FFFF"), 0xFFFF); - assert_equal(parse_hex_quad(U"zzzz"), 0); // err + assert_equal(parse_hex_quad(U"zzzz"), 0); // err } return EXIT_SUCCESS; } -int test_normalize() { +int +test_normalize() { std::cout << "== " << __FUNCTION__ << std::endl; std::cout << __FILE__ << ":" << __LINE__ << " * normalize_nfd_markers" << std::endl; - { marker_map map; std::cout << __FILE__ << ":" << __LINE__ << " - noop test" << std::endl; @@ -751,38 +772,37 @@ int test_normalize() { U"6\U0000ffff\U00000008\U00000001e\U0000ffff\U00000008\U00000002\U00000320\U0000ffff\U00000008\U00000003\U00000300" U"\U0000ffff\U00000008\U00000004"; const std::u32string expect = src; - std::u32string dst = src; + std::u32string dst = src; assert(normalize_nfd_markers_segment(dst, map)); zassert_string_equal(dst, expect); + marker_map expm({{U'e', 0x1L}, {0x320, 0x2L}, {0x300, 0x3L}, {MARKER_BEFORE_EOT, 0x4L}}); + assert_marker_map_equal(map, expm); assert_equal(map.size(), 4); - assert_marker_list_equal(map[U'e'], marker_list({0x1L})); - assert_marker_list_equal(map[0x0320], marker_list({0x2L})); - assert_marker_list_equal(map[0x0300], marker_list({0x3L})); - assert_marker_list_equal(map[MARKER_BEFORE_EOT],marker_list({0x4L})); } { marker_map map; std::cout << __FILE__ << ":" << __LINE__ << " - complex test" << std::endl; - const std::u32string src = // already in order: 320+300 - U"6\U0000ffff\U00000008\U00000001e\U0000ffff\U00000008\U00000002\U00000320\U0000ffff\U00000008\U00000003\U00000300\U0000ffff\U00000008\U00000004"; + const std::u32string src = // already in order: 320+300 + U"6\U0000ffff\U00000008\U00000001e\U0000ffff\U00000008\U00000002\U00000320\U0000ffff\U00000008\U00000003\U00000300" + U"\U0000ffff\U00000008\U00000004"; const std::u32string expect = src; - std::u32string dst = src; + std::u32string dst = src; assert(normalize_nfd_markers_segment(dst, map)); zassert_string_equal(dst, expect); + marker_map expm({{U'e', 0x1L}, {0x320, 0x2L}, {0x300, 0x3L}, {MARKER_BEFORE_EOT, 0x4L}}); + assert_marker_map_equal(map, expm); assert_equal(map.size(), 4); - assert_marker_list_equal(map[U'e'], marker_list({0x1L})); - assert_marker_list_equal(map[0x0320], marker_list({0x2L})); - assert_marker_list_equal(map[0x0300], marker_list({0x3L})); - assert_marker_list_equal(map[MARKER_BEFORE_EOT], marker_list({0x4L})); } { marker_map map; std::cout << __FILE__ << ":" << __LINE__ << " - complex test2" << std::endl; - const std::u32string src = // out of order, 300-320 - U"6\U0000ffff\U00000008\U00000001e\U0000ffff\U00000008\U00000002\U00000300\U0000ffff\U00000008\U00000003\U00000320\U0000ffff\U00000008\U00000004"; + const std::u32string src = // out of order, 300-320 + U"6\U0000ffff\U00000008\U00000001e\U0000ffff\U00000008\U00000002\U00000300\U0000ffff\U00000008\U00000003\U00000320" + U"\U0000ffff\U00000008\U00000004"; const std::u32string expect = - U"6\U0000ffff\U00000008\U00000001e\U0000ffff\U00000008\U00000003\U00000320\U0000ffff\U00000008\U00000002\U00000300\U0000ffff\U00000008\U00000004"; + U"6\U0000ffff\U00000008\U00000001e\U0000ffff\U00000008\U00000003\U00000320\U0000ffff\U00000008\U00000002\U00000300" + U"\U0000ffff\U00000008\U00000004"; std::u32string dst = src; assert(normalize_nfd_markers_segment(dst, map)); if (dst != expect) { @@ -790,11 +810,9 @@ int test_normalize() { std::cout << "exp: " << Debug_UnicodeString(expect) << std::endl; } zassert_string_equal(dst, expect); + marker_map expm({{U'e', 0x1L}, {0x300, 0x2L}, {0x320, 0x3L}, {MARKER_BEFORE_EOT, 0x4L}}); + assert_marker_map_equal(map, expm); assert_equal(map.size(), 4); - assert_marker_list_equal(map[U'e'], marker_list({0x1L})); - assert_marker_list_equal(map[0x0320], marker_list({0x3L})); - assert_marker_list_equal(map[0x0300], marker_list({0x2L})); - assert_marker_list_equal(map[MARKER_BEFORE_EOT], marker_list({0x4L})); } { @@ -803,15 +821,16 @@ int test_normalize() { std::cout << __FILE__ << ":" << __LINE__ << " - complex test 4a" << std::endl; const std::u32string src = U"4e\u0300\uFFFF\u0008\u0001\u0320"; const std::u32string expect = U"4e\uFFFF\u0008\u0001\u0320\u0300"; - std::u32string dst = src; + std::u32string dst = src; assert(normalize_nfd_markers_segment(dst, map)); if (dst != expect) { std::cout << "dst: " << Debug_UnicodeString(dst) << std::endl; std::cout << "exp: " << Debug_UnicodeString(expect) << std::endl; } zassert_string_equal(dst, expect); + marker_map expm({{0x320, 0x1L}}); + assert_marker_map_equal(map, expm); assert_equal(map.size(), 1); - assert_marker_list_equal(map[0x0320], marker_list({0x1L})); } { @@ -820,51 +839,53 @@ int test_normalize() { std::cout << __FILE__ << ":" << __LINE__ << " - complex test 9c" << std::endl; const std::u32string src = U"9ce\u0300\uFFFF\u0008\u0002\u0320\uFFFF\u0008\u0001"; const std::u32string expect = U"9ce\uFFFF\u0008\u0002\u0320\u0300\uFFFF\u0008\u0001"; - std::u32string dst = src; + std::u32string dst = src; assert(normalize_nfd_markers_segment(dst, map)); if (dst != expect) { std::cout << "dst: " << Debug_UnicodeString(dst) << std::endl; std::cout << "exp: " << Debug_UnicodeString(expect) << std::endl; } zassert_string_equal(dst, expect); + marker_map expm({{0x320, 0x2L}, {MARKER_BEFORE_EOT, 0x1L}}); + assert_marker_map_equal(map, expm); assert_equal(map.size(), 2); - assert_marker_list_equal(map[0x0320], marker_list({0x2L})); - assert_marker_list_equal(map[MARKER_BEFORE_EOT], marker_list({0x1L})); } { // from tests - regex edition marker_map map; std::cout << __FILE__ << ":" << __LINE__ << " - complex test 9c+regex" << std::endl; - const std::u32string src = U"9ce\u0300\uFFFF\u0008\\u0002\u0320\uFFFF\u0008\\u0001"; - const std::u32string expect = U"9ce\uFFFF\u0008\\u0002\u0320\u0300\uFFFF\u0008\\u0001"; - std::u32string dst = src; - assert(normalize_nfd_markers_segment(dst, map, regex_sentinel)); // TODO-LDML: need regex flag + const std::u32string src = U"9ce\u0300\\uffff\\u0008\\u0002\u0320\\uffff\\u0008\\u0001"; + const std::u32string expect = U"9ce\\uffff\\u0008\\u0002\u0320\u0300\\uffff\\u0008\\u0001"; + std::u32string dst = src; + assert(normalize_nfd_markers_segment(dst, map, regex_sentinel)); if (dst != expect) { std::cout << "dst: " << Debug_UnicodeString(dst) << std::endl; + std::cout << " " << dst << std::endl; std::cout << "exp: " << Debug_UnicodeString(expect) << std::endl; + std::cout << " " << expect << std::endl; } zassert_string_equal(dst, expect); + marker_map expm({{0x320, 0x2L}, {MARKER_BEFORE_EOT, 0x1L}}); + assert_marker_map_equal(map, expm); assert_equal(map.size(), 2); - assert_marker_list_equal(map[0x0320], marker_list({0x2L})); - assert_marker_list_equal(map[MARKER_BEFORE_EOT], marker_list({0x1L})); } { // from tests - regex edition marker_map map; std::cout << __FILE__ << ":" << __LINE__ << " - complex test \\m{.}" << std::endl; - const std::u32string src = U"9ce\u0300\uFFFF\u0008[\\u0001-\\uD7FE]\u0320\uFFFF\u0008\\u0001"; - const std::u32string expect = U"9ce\uFFFF\u0008[\\u0001-\\uD7FE]\u0320\u0300\uFFFF\u0008\\u0001"; - std::u32string dst = src; + const std::u32string src = U"9ce\u0300\\uffff\\u0008[\\u0001-\\ud7fe]\u0320\\uffff\\u0008\\u0001"; + const std::u32string expect = U"9ce\\uffff\\u0008[\\u0001-\\ud7fe]\u0320\u0300\\uffff\\u0008\\u0001"; + std::u32string dst = src; assert(normalize_nfd_markers_segment(dst, map, regex_sentinel)); if (dst != expect) { std::cout << "dst: " << Debug_UnicodeString(dst) << std::endl; std::cout << "exp: " << Debug_UnicodeString(expect) << std::endl; } zassert_string_equal(dst, expect); + marker_map expm({{0x320, LDML_MARKER_ANY_INDEX}, {MARKER_BEFORE_EOT, 0x1L}}); + assert_marker_map_equal(map, expm); assert_equal(map.size(), 2); - assert_marker_list_equal(map[0x0320], marker_list({LDML_MARKER_ANY_INDEX})); - assert_marker_list_equal(map[MARKER_BEFORE_EOT], marker_list({0x1L})); } { @@ -873,52 +894,72 @@ int test_normalize() { std::cout << __FILE__ << ":" << __LINE__ << " - complex test 10 stack o' 2x2" << std::endl; const std::u32string src = U"9ce\u0300\uFFFF\u0008\u0002\uFFFF\u0008\u0002\u0320"; const std::u32string expect = U"9ce\uFFFF\u0008\u0002\uFFFF\u0008\u0002\u0320\u0300"; - std::u32string dst = src; + std::u32string dst = src; assert(normalize_nfd_markers_segment(dst, map)); if (dst != expect) { std::cout << "dst: " << Debug_UnicodeString(dst) << std::endl; std::cout << "exp: " << Debug_UnicodeString(expect) << std::endl; } - assert_equal(map.size(), 1); - assert_marker_list_equal(map[0x0320], (marker_list({0x2L, 0x2L}))); zassert_string_equal(dst, expect); + marker_map expm({{0x320, 0x2L}, {0x320, 0x2L}}); + assert_marker_map_equal(map, expm); + assert_equal(map.size(), 2); } - { // from tests marker_map map; std::cout << __FILE__ << ":" << __LINE__ << " - complex test 10 stack o' 2x1x2" << std::endl; const std::u32string src = U"9ce\u0300\uFFFF\u0008\u0002\uFFFF\u0008\u0001\uFFFF\u0008\u0003\u0320"; const std::u32string expect = U"9ce\uFFFF\u0008\u0002\uFFFF\u0008\u0001\uFFFF\u0008\u0003\u0320\u0300"; - std::u32string dst = src; + std::u32string dst = src; assert(normalize_nfd_markers_segment(dst, map)); if (dst != expect) { std::cout << "dst: " << Debug_UnicodeString(dst) << std::endl; std::cout << "exp: " << Debug_UnicodeString(expect) << std::endl; } zassert_string_equal(dst, expect); - assert_equal(map.size(), 1); - assert_marker_list_equal(map[0x0320], (marker_list({0x2L, 0x1L, 0x3L}))); + marker_map expm({{0x320, 0x2L}, {0x320, 0x1L}, {0x320, 0x3L}}); + assert_marker_map_equal(map, expm); } - { marker_map map; std::cout << __FILE__ << ":" << __LINE__ << " - dup-char test" << std::endl; - const std::u32string src = U"a\uFFFF\u0008\u0001\u0300e\uFFFF\u0008\u0002\u0300"; - const std::u32string dst = remove_markers(src, map); - const std::u32string expect = U"a\u0300e\u0300"; // U+0300 twice! This should be removed in 2 segments + const std::u32string src = U"a\uFFFF\u0008\u0001\u0300e\uFFFF\u0008\u0002\u0300"; + const std::u32string dst = remove_markers(src, map); + const std::u32string expect = U"a\u0300e\u0300"; // U+0300 twice! This should be removed in 2 segments zassert_string_equal(dst, expect); - assert_equal(map.size(), 1); - marker_list exp_ae = { 0x1L, 0x2L }; // Not what the user would see in practice. - assert_marker_list_equal(map[0x0300], exp_ae); // marker 1 @ e + marker_map expm({{0x300, 0x1L}, {0x300, 0x2L}}); + assert_marker_map_equal(map, expm); + } + { + marker_map map; + std::cout << __FILE__ << ":" << __LINE__ << " - support 2-segment markers " << std::endl; + // e\m{1}`\m{2}_E\m{3}`\m{4}_ + const std::u32string src = + U"e\uFFFF\u0008\u0001\u0300\uFFFF\u0008\u0002\u0320E\uFFFF\u0008\u0003\u0300\uFFFF\u0008\u0004\u0320"; + // e\m{2}_\m{1}`E\m{4}_\m{3}` + const std::u32string expect_rem = + U"e\u0300\u0320E\u0300\u0320"; + const std::u32string expect_nfd = + U"e\uFFFF\u0008\u0002\u0320\uFFFF\u0008\u0001\u0300E\uFFFF\u0008\u0004\u0320\uFFFF\u0008\u0003\u0300"; + auto dst_rem = remove_markers(src, &map); // note: this is bigger than a single segment. so it is a degenerate test case. + marker_map expm({{0x300, 0x1L}, {0x320, 0x2L}, {0x300, 0x3L}, {0x320, 0x4L}}); + assert_marker_map_equal(map, expm); + zassert_string_equal(dst_rem, expect_rem); + std::u32string dst_nfd = src; + assert(normalize_nfd_markers(dst_nfd)); + if (dst_nfd != expect_nfd) { + std::cout << "dst: " << Debug_UnicodeString(dst_nfd) << std::endl; + std::cout << "exp: " << Debug_UnicodeString(expect_nfd) << std::endl; + } + zassert_string_equal(dst_nfd, expect_nfd); } return EXIT_SUCCESS; } - int main(int argc, const char *argv[]) { int rc = EXIT_SUCCESS; @@ -929,7 +970,7 @@ main(int argc, const char *argv[]) { if (first_arg < argc) { arg_color = std::string(argv[first_arg]) == "--color"; - if(arg_color) { + if (arg_color) { first_arg++; } } diff --git a/developer/src/kmc-ldml/src/compiler/empty-compiler.ts b/developer/src/kmc-ldml/src/compiler/empty-compiler.ts index ac45243734..911e56d896 100644 --- a/developer/src/kmc-ldml/src/compiler/empty-compiler.ts +++ b/developer/src/kmc-ldml/src/compiler/empty-compiler.ts @@ -36,6 +36,12 @@ export class StrsCompiler extends EmptyCompiler { const badStringAnalyzer = new util.BadStringAnalyzer(); const CONTAINS_MARKER_REGEX = new RegExp(MarkerParser.ANY_MARKER_MATCH); for (let s of strs.allProcessedStrings.values()) { + // replace all \\uXXXX with the actual code point. + // this lets us analyze whether there are PUA, unassigned, etc. + // the results might not be valid regex of course. + if (util.CONTAINS_QUAD_ESCAPE.test(s)) { + s = util.unescapeQuadString(s); + } // skip marker strings if (CONTAINS_MARKER_REGEX.test(s)) { // it had a marker, take out all marker strings, as the sentinel is illegal diff --git a/developer/src/kmc-ldml/src/compiler/tran.ts b/developer/src/kmc-ldml/src/compiler/tran.ts index 2e1944a7cc..d0251588d4 100644 --- a/developer/src/kmc-ldml/src/compiler/tran.ts +++ b/developer/src/kmc-ldml/src/compiler/tran.ts @@ -1,5 +1,5 @@ import { constants, SectionIdent } from "@keymanapp/ldml-keyboard-constants"; -import { KMXPlus, LDMLKeyboard, CompilerCallbacks, VariableParser, MarkerParser } from '@keymanapp/common-types'; +import { KMXPlus, LDMLKeyboard, CompilerCallbacks, VariableParser, MarkerParser, util } from '@keymanapp/common-types'; import { SectionCompiler } from "./section-compiler.js"; import Bksp = KMXPlus.Bksp; @@ -138,9 +138,12 @@ export abstract class TransformCompiler block(strIndicator) 3d d8 40 de block(x) 00 00 # '🙀' block(strSentinel0001) FF FF 08 00 01 00 block(x) 00 00 # UC_SENTINEL CODE_DEADKEY U+0001 - block(strSentinel0001r) FF FF 08 00 5C 00 75 00 30 00 30 00 30 00 31 00 block(x) 00 00 # UC_SENTINEL CODE_DEADKEY \u0001 (regex form) block(endstrs) # end of strs block diff --git a/developer/src/kmc-ldml/test/fixtures/basic.xml b/developer/src/kmc-ldml/test/fixtures/basic.xml index 0044812652..275aa0f437 100644 --- a/developer/src/kmc-ldml/test/fixtures/basic.xml +++ b/developer/src/kmc-ldml/test/fixtures/basic.xml @@ -48,7 +48,7 @@ - + diff --git a/developer/src/kmc-ldml/test/fixtures/sections/ordr/nod-Lana.xml b/developer/src/kmc-ldml/test/fixtures/sections/ordr/nod-Lana.xml index 8395ea581a..ec1fe5c6e8 100644 --- a/developer/src/kmc-ldml/test/fixtures/sections/ordr/nod-Lana.xml +++ b/developer/src/kmc-ldml/test/fixtures/sections/ordr/nod-Lana.xml @@ -8,12 +8,12 @@ - - - - - - + + + + + + diff --git a/developer/src/kmc-ldml/test/fixtures/sections/strs/hint-pua.xml b/developer/src/kmc-ldml/test/fixtures/sections/strs/hint-pua.xml index 23c85d4023..355c5ef2b7 100644 --- a/developer/src/kmc-ldml/test/fixtures/sections/strs/hint-pua.xml +++ b/developer/src/kmc-ldml/test/fixtures/sections/strs/hint-pua.xml @@ -4,7 +4,7 @@ @@keys: [K_Q][K_W][K_Q] @@expected: \u0127\u1790\u17B6\u0127 --> - + @@ -46,7 +46,7 @@ - + diff --git a/developer/src/kmc-ldml/test/fixtures/sections/strs/invalid-illegal.xml b/developer/src/kmc-ldml/test/fixtures/sections/strs/invalid-illegal.xml index a21429d833..6362ac7fec 100644 --- a/developer/src/kmc-ldml/test/fixtures/sections/strs/invalid-illegal.xml +++ b/developer/src/kmc-ldml/test/fixtures/sections/strs/invalid-illegal.xml @@ -4,7 +4,7 @@ @@keys: [K_Q][K_W][K_Q] @@expected: \u0127\u1790\u17B6\u0127 --> - + diff --git a/developer/src/kmc-ldml/test/fixtures/sections/strs/warn-unassigned.xml b/developer/src/kmc-ldml/test/fixtures/sections/strs/warn-unassigned.xml index 79f3291dca..553795a0df 100644 --- a/developer/src/kmc-ldml/test/fixtures/sections/strs/warn-unassigned.xml +++ b/developer/src/kmc-ldml/test/fixtures/sections/strs/warn-unassigned.xml @@ -4,7 +4,7 @@ @@keys: [K_Q][K_W][K_Q] @@expected: \u0127\u1790\u17B6\u0127 --> - + @@ -46,7 +46,7 @@ - + diff --git a/ios/engine/KMEI/KeymanEngine/Classes/TextField.swift b/ios/engine/KMEI/KeymanEngine/Classes/TextField.swift index 42f8ea8297..3aa871f267 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/TextField.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/TextField.swift @@ -146,10 +146,6 @@ public class TextField: UITextField, KeymanResponder { font = UIFont.systemFont(ofSize: fontSize) } - if isFirstResponder { - resignFirstResponder() - becomeFirstResponder() - } log.debug("TextField \(self.hashValue) setFont: \(font?.familyName ?? "nil")") } @@ -179,7 +175,7 @@ extension KeymanResponder where Self: TextField { resignFirstResponder() Manager.shared.inputViewController.endEditing(true) } - + public func summonKeyboard() { becomeFirstResponder() } diff --git a/ios/engine/KMEI/KeymanEngine/Classes/TextView.swift b/ios/engine/KMEI/KeymanEngine/Classes/TextView.swift index 8f0ab0c639..43b735bc61 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/TextView.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/TextView.swift @@ -133,11 +133,6 @@ public class TextView: UITextView, KeymanResponder { font = UIFont.systemFont(ofSize: fontSize) } - if isFirstResponder { - resignFirstResponder() - becomeFirstResponder() - } - log.debug("TextView: \(self.hashValue) setFont: \(font?.familyName ?? "nil")") } diff --git a/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js b/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js index 3717b5e648..1b3effa9ee 100644 --- a/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js +++ b/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js @@ -81,6 +81,14 @@ function showBanner(flag) { function setBannerImage(path) { bannerImgPath = path; + + var bc = keyman && keyman.osk && keyman.osk.bannerController; + if(!bc) { + return; + } + + // If an inactive banner is set, update its image. + bc.inactiveBanner = bc.inactiveBanner ? new bc.ImageBanner(bannerImgPath) : null; } function setBannerHeight(h) { diff --git a/oem/firstvoices/android/app/build.gradle b/oem/firstvoices/android/app/build.gradle index 646152a81a..43adc17265 100644 --- a/oem/firstvoices/android/app/build.gradle +++ b/oem/firstvoices/android/app/build.gradle @@ -8,13 +8,13 @@ ext.rootPath = '../../../../android' apply from: "$rootPath/version.gradle" android { - compileSdkVersion 33 + compileSdk 34 namespace="com.firstvoices.keyboards" defaultConfig { applicationId "com.firstvoices.keyboards" minSdkVersion 21 - targetSdkVersion 33 + targetSdkVersion 34 // VERSION_CODE and VERSION_NAME from version.gradle versionCode VERSION_CODE as Integer diff --git a/resources/standards-data/ldml-keyboards/techpreview/3.0/bn.xml b/resources/standards-data/ldml-keyboards/techpreview/3.0/bn.xml index 167416e944..a4b4133591 100644 --- a/resources/standards-data/ldml-keyboards/techpreview/3.0/bn.xml +++ b/resources/standards-data/ldml-keyboards/techpreview/3.0/bn.xml @@ -129,7 +129,7 @@ - + diff --git a/resources/standards-data/ldml-keyboards/techpreview/3.0/fr-t-k0-optimise.xml b/resources/standards-data/ldml-keyboards/techpreview/3.0/fr-t-k0-optimise.xml index 8db073cc76..b8c6f76b47 100644 --- a/resources/standards-data/ldml-keyboards/techpreview/3.0/fr-t-k0-optimise.xml +++ b/resources/standards-data/ldml-keyboards/techpreview/3.0/fr-t-k0-optimise.xml @@ -8,7 +8,7 @@ - + @@ -191,9 +191,9 @@ - - - + + + diff --git a/web/src/engine/osk/src/banner/banner.ts b/web/src/engine/osk/src/banner/banner.ts index bc4f48632a..60690e3087 100644 --- a/web/src/engine/osk/src/banner/banner.ts +++ b/web/src/engine/osk/src/banner/banner.ts @@ -17,6 +17,7 @@ import { createUnselectableElement } from 'keyman/engine/dom-utils'; export abstract class Banner { private _height: number; // pixels + private _width: number; // pixels private div: HTMLDivElement; public static DEFAULT_HEIGHT: number = 37; // pixels; embedded apps can modify @@ -47,6 +48,15 @@ export abstract class Banner { this.update(); } + public get width(): number { + return this._width; + } + + public set width(width: number) { + this._width = width; + this.update(); + } + /** * Function update * @return {boolean} true if the banner styling changed @@ -88,7 +98,7 @@ export abstract class Banner { * Function getDiv * Scope Public * @returns {HTMLElement} Base element of the banner - * Description Returns the HTMLElelemnt of the banner + * Description Returns the HTMLElement of the banner */ public getDiv(): HTMLElement { return this.div; diff --git a/web/src/engine/osk/src/banner/bannerController.ts b/web/src/engine/osk/src/banner/bannerController.ts index 45ee746bbf..70925268f5 100644 --- a/web/src/engine/osk/src/banner/bannerController.ts +++ b/web/src/engine/osk/src/banner/bannerController.ts @@ -6,6 +6,7 @@ import { BannerView } from './bannerView.js'; import { Banner } from './banner.js'; import { BlankBanner } from './blankBanner.js'; import { HTMLBanner } from './htmlBanner.js'; +import { Keyboard, KeyboardProperties } from '@keymanapp/keyboard-processor'; export class BannerController { private container: BannerView; @@ -16,6 +17,9 @@ export class BannerController { private _inactiveBanner: Banner; + private keyboard: Keyboard; + private keyboardStub: KeyboardProperties; + /** * Builds a banner for use when predictions are not active, supporting a single image. */ @@ -92,6 +96,23 @@ export class BannerController { selectBanner(state: StateChangeEnum) { // Only display a SuggestionBanner when LanguageProcessor states it is active. this.activateBanner(state == 'active' || state == 'configured'); + + if(this.keyboard) { + this.container.banner.configureForKeyboard(this.keyboard, this.keyboardStub); + } + } + + /** + * Allows banners to adapt based on the active keyboard and related properties, such as + * associated fonts. + * @param keyboard + * @param keyboardProperties + */ + public configureForKeyboard(keyboard: Keyboard, keyboardProperties: KeyboardProperties) { + this.keyboard = keyboard; + this.keyboardStub = keyboardProperties; + + this.container.banner.configureForKeyboard(keyboard, keyboardProperties); } public shutdown() { diff --git a/web/src/engine/osk/src/banner/bannerScrollState.ts b/web/src/engine/osk/src/banner/bannerScrollState.ts new file mode 100644 index 0000000000..a0741ac6f5 --- /dev/null +++ b/web/src/engine/osk/src/banner/bannerScrollState.ts @@ -0,0 +1,46 @@ +import { InputSample } from "@keymanapp/gesture-recognizer"; + +/** + * The amount of coordinate 'noise' allowed during a scroll-enabled touch + * before interpreting the currently-ongoing touch command as having scrolled. + */ +const HAS_SCROLLED_FUDGE_FACTOR = 10; + +/** + * This class was added to facilitate scroll handling for overflow-x elements, though it could + * be extended in the future to accept overflow-y if needed. + * + * This is necessary because of the OSK's need to use `.preventDefault()` for stability; that + * same method blocks native handling of overflow scrolling for touch browsers. + */ +export class BannerScrollState { + totalLength = 0; + + baseCoord: InputSample; + curCoord: InputSample; + baseScrollLeft: number; + + constructor(coord: InputSample, baseScrollLeft: number) { + this.baseCoord = coord; + this.curCoord = coord; + this.baseScrollLeft = baseScrollLeft; + + this.totalLength = 0; + } + + updateTo(coord: InputSample): number { + let prevCoord = this.curCoord; + this.curCoord = coord; + + let delta = this.baseCoord.targetX - this.curCoord.targetX + this.baseScrollLeft; + // Track the total amount of scrolling used, even if just a pixel-wide back and forth wiggle. + this.totalLength += Math.abs(this.curCoord.targetX - prevCoord.targetX); + + return delta; + } + + public get hasScrolled(): boolean { + // Allow an accidental fudge-factor for overflow element noise during a touch, but not much. + return this.totalLength > HAS_SCROLLED_FUDGE_FACTOR; + } +} \ No newline at end of file diff --git a/web/src/engine/osk/src/banner/bannerView.ts b/web/src/engine/osk/src/banner/bannerView.ts index b291f7454e..de7d25a7e8 100644 --- a/web/src/engine/osk/src/banner/bannerView.ts +++ b/web/src/engine/osk/src/banner/bannerView.ts @@ -24,38 +24,8 @@ interface BannerViewEventMap { } /** - * The `BannerManager` module is designed to serve as a manager for the - * different `Banner` types. - * To facilitate this, it will provide a root element property that serves - * as a container for any active `Banner`, helping KMW to avoid needless - * DOM element shuffling. - * - * Goals for the `BannerManager`: - * - * * It will be exposed as `keyman.osk.banner` and will provide the following API: - * * `getOptions`, `setOptions` - refer to the `BannerOptions` class for details. - * * This provides a persistent point that the web page designers and our - * model apps can utilize and can communicate with. - * * These API functions are designed for live use and will allow - * _hot-swapping_ the `Banner` instance; they're not initialization-only. - * * Disabling the `Banner` (even for suggestions) outright with - * `enablePredictions == false` will auto-unload any loaded predictive model - * from `ModelManager` and setting it to `true` will revert this. - * * This should help to avoid wasting computational resources. - * * It will listen to ModelManager events and automatically swap Banner - * instances as appropriate: - * * The option `persistentBanner == true` is designed to replicate current - * iOS system keyboard behavior. - * * When true, an `ImageBanner` will be displayed. - * * If false, it will be replaced with a `BlankBanner` of zero height, - * corresponding to our current default lack of banner. - * * It will not automatically set `persistentBanner == true`; - * this must be set by the iOS app, and only under the following conditions: - * * `keyman.isEmbedded == true` - * * `device.OS == 'ios'` - * * Keyman is being used as the system keyboard within an app that - * needs to reserve this space (i.e: Keyman for iOS), - * rather than as its standalone app. + * The `BannerView` module is designed to serve as the hot-swap container for the + * different `Banner` types, helping KMW to avoid needless DOM element shuffling. */ export class BannerView implements OSKViewComponent { private bannerContainer: HTMLDivElement; @@ -161,5 +131,16 @@ export class BannerView implements OSKViewComponent { return ParsedLengthStyle.inPixels(this.height); } - public refreshLayout() {}; + public get width(): number | undefined { + return this.currentBanner?.width; + } + + public set width(w: number) { + if(this.currentBanner) { + this.currentBanner.width = w; + } + } + + public refreshLayout() { + } } \ No newline at end of file diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index d09829fd0c..052ca63b2e 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -7,7 +7,8 @@ import { GestureRecognizerConfiguration, GestureSource, InputSample, - PaddedZoneSource + PaddedZoneSource, + RecognitionZoneSource } from '@keymanapp/gesture-recognizer'; import { BANNER_GESTURE_SET } from './bannerGestureSet.js'; @@ -15,12 +16,73 @@ import { BANNER_GESTURE_SET } from './bannerGestureSet.js'; import { DeviceSpec, Keyboard, KeyboardProperties } from '@keymanapp/keyboard-processor'; import { Banner } from './banner.js'; import EventEmitter from 'eventemitter3'; +import { ParsedLengthStyle } from '../lengthStyle.js'; +import { getFontSizeStyle } from '../fontSizeUtils.js'; +import { getTextMetrics } from '../keyboard-layout/getTextMetrics.js'; +import { BannerScrollState } from './bannerScrollState.js'; + +const TOUCHED_CLASS: string = 'kmw-suggest-touched'; +const BANNER_CLASS: string = 'kmw-suggest-banner'; +const BANNER_SCROLLER_CLASS = 'kmw-suggest-banner-scroller'; + +const BANNER_VERT_ROAMING_HEIGHT_RATIO = 0.666; + +/** + * The style to temporarily apply when updating suggestion text in order to prevent + * fade transitions at that time. + */ +const FADE_SWALLOW_STYLE = 'swallow-fade-transition'; + +/** + * Defines various parameters used by `BannerSuggestion` instances for layout and formatting. + * This object is designed first and foremost for use with `BannerSuggestion.update()`. + */ +interface BannerSuggestionFormatSpec { + /** + * Sets a minimum width to use for the `BannerSuggestion`'s element; this overrides any + * and all settings that would otherwise result in a narrower final width. + */ + minWidth?: number; + + /** + * Sets the width of padding around the text of each suggestion. This should generally match + * the 'width' of class = `.kmw-suggest-option::before` and class = `.kmw-suggest-option::after` + * elements as defined in kmwosk.css. + */ + paddingWidth: number, + + /** + * The default font size to use for calculations based on relative font-size specs + */ + emSize: number, + + /** + * The font style (font-size, font-family) to use for suggestion-banner display text. + */ + styleForFont: { + fontSize: typeof CSSStyleDeclaration.prototype.fontSize, + fontFamily: typeof CSSStyleDeclaration.prototype.fontFamily + }, + + /** + * Sets a target width to use when 'collapsing' suggestions. Only affects those long + * enough to need said 'collapsing'. + */ + collapsedWidth?: number +} export class BannerSuggestion { div: HTMLDivElement; + container: HTMLDivElement; private display: HTMLSpanElement; + + private _collapsedWidth: number; + private _textWidth: number; + private _minWidth: number; + private _paddingWidth: number; + private fontFamily?: string; - private rtl: boolean = false; + public readonly rtl: boolean; private _suggestion: Suggestion; @@ -30,14 +92,19 @@ export class BannerSuggestion { constructor(index: number, isRTL: boolean) { this.index = index; - this.rtl = isRTL; + this.rtl = isRTL ?? false; this.constructRoot(); // Provides an empty, base SPAN for text display. We'll swap these out regularly; // `Suggestion`s will have varying length and may need different styling. let display = this.display = createUnselectableElement('span'); - this.div.appendChild(display); + display.className = 'kmw-suggestion-text'; + this.container.appendChild(display); + } + + get computedStyle() { + return getComputedStyle(this.display); } private constructRoot() { @@ -46,13 +113,18 @@ export class BannerSuggestion { div.className = "kmw-suggest-option"; div.id = BannerSuggestion.BASE_ID + this.index; - // Ensures that a reasonable width % is set. - let usableWidth = 100 - SuggestionBanner.MARGIN * (SuggestionBanner.SUGGESTION_LIMIT - 1); - let widthpc = usableWidth / SuggestionBanner.SUGGESTION_LIMIT; - - ds.width = widthpc + '%'; - this.div['suggestion'] = this; + + let container = this.container = document.createElement('div'); + container.className = "kmw-suggestion-container"; + + // Ensures that a reasonable default width, based on % is set. (Since it's not yet in the DOM, we may not yet have actual width info.) + let usableWidth = 100 - SuggestionBanner.MARGIN * (SuggestionBanner.LONG_SUGGESTION_DISPLAY_LIMIT - 1); + + let widthpc = usableWidth / (SuggestionBanner.LONG_SUGGESTION_DISPLAY_LIMIT); + container.style.minWidth = widthpc + '%'; + + div.appendChild(container); } public matchKeyboardProperties(keyboardProperties: KeyboardProperties) { @@ -77,30 +149,188 @@ export class BannerSuggestion { /** * Function update - * @param {string} id Element ID for the suggestion span * @param {Suggestion} suggestion Suggestion from the lexical model - * Description Update the ID and text of the BannerSuggestionSpec + * @param {BannerSuggestionFormatSpec} format Formatting metadata to use for the Suggestion + * + * Update the ID and text of the BannerSuggestionSpec */ - public update(suggestion: Suggestion) { + public update(suggestion: Suggestion, format: BannerSuggestionFormatSpec) { this._suggestion = suggestion; - this.updateText(); + + let display = this.generateSuggestionText(this.rtl); + this.container.replaceChild(display, this.display); + this.display = display; + + // Set internal properties for use in format calculations. + if(format.minWidth !== undefined) { + this._minWidth = format.minWidth; + } + + this._paddingWidth = format.paddingWidth; + this._collapsedWidth = format.collapsedWidth; + + if(suggestion && suggestion.displayAs) { + const rawMetrics = getTextMetrics(suggestion.displayAs, format.emSize, format.styleForFont); + this._textWidth = rawMetrics.width; + } else { + this._textWidth = 0; + } + + this.currentWidth = this.collapsedWidth; + this.updateLayout(); } - private updateText() { - let display = this.generateSuggestionText(this.rtl); - this.div.replaceChild(display, this.display); - this.display = display; + public updateLayout() { + if(!this.suggestion && this.index != 0) { + this.div.style.width='0px'; + return; + } else { + this.div.style.width=''; + } + + const collapserStyle = this.container.style; + collapserStyle.minWidth = this.collapsedWidth + 'px'; + + if(this.rtl) { + collapserStyle.marginRight = (this.collapsedWidth - this.expandedWidth) + 'px'; + } else { + collapserStyle.marginLeft = (this.collapsedWidth - this.expandedWidth) + 'px'; + } + + this.updateFade(); + } + + public updateFade() { + // Note: selected suggestion fade transitions are handled purely by CSS. + // We want to prevent them when updating a suggestion, though. + this.div.classList.add(FADE_SWALLOW_STYLE); + // Be sure that our fade-swallow mechanism is able to trigger once; + // we'll remove it after the current animation frame. + window.requestAnimationFrame(() => { + this.div.classList.remove(FADE_SWALLOW_STYLE); + }) + + // Never apply fading to the side that doesn't overflow. + this.div.classList.add(`kmw-hide-fade-${this.rtl ? 'left' : 'right'}`); + + // Matches the side that overflows, depending on if LTR or RTL. + const fadeClass = `kmw-hide-fade-${this.rtl ? 'right' : 'left'}`; + + // Is the suggestion already its ideal width?. + if(!(this.expandedWidth - this.collapsedWidth)) { + // Yes? Don't do any fading. + this.div.classList.add(fadeClass); + } else { + this.div.classList.remove(fadeClass); + } + } + + /** + * Denotes the threshold at which the banner suggestion will no longer gain width + * in its default form, resulting in two separate states: "collapsed" and "expanded". + */ + public get targetCollapsedWidth(): number { + return this._collapsedWidth; + } + + /** + * The raw width needed to display the suggestion's display text without triggering overflow. + */ + public get textWidth(): number { + return this._textWidth; + } + + /** + * Width of the padding to apply equally on both sides of the suggestion's display text. + * Is the sum of both, rather than the value applied to each side. + */ + public get paddingWidth(): number { + return this._paddingWidth; + } + + /** + * The absolute minimum width to allow for the represented suggestion's banner element. + */ + public get minWidth(): number { + return this._minWidth; + } + + /** + * The absolute minimum width to allow for the represented suggestion's banner element. + */ + public set minWidth(val: number) { + this._minWidth = val; + } + + /** + * The total width taken by the suggestion's banner element when fully expanded. + * This may equal the `collapsed` width for sufficiently short suggestions. + */ + public get expandedWidth(): number { + // minWidth must be defined AND greater for the conditional to return this.minWidth. + return this.minWidth > this.spanWidth ? this.minWidth : this.spanWidth; + } + + /** + * The total width used by the internal contents of the suggestion's banner element when not obscured. + */ + public get spanWidth(): number { + let spanWidth = this.textWidth ?? 0; + if(spanWidth) { + spanWidth += this.paddingWidth ?? 0; + } + + return spanWidth; + } + + /** + * The actual width to be used for the `BannerSuggestion`'s display element when in the 'collapsed' + * state and not transitioning. + */ + public get collapsedWidth(): number { + // Allow shrinking a suggestion's width if it has excess whitespace. + let utilizedWidth = this.spanWidth < this.targetCollapsedWidth ? this.spanWidth : this.targetCollapsedWidth; + // If a minimum width has been specified, enforce that minimum. + let maxWidth = utilizedWidth < this.expandedWidth ? utilizedWidth : this.expandedWidth; + + // Will return maxWidth if this.minWidth is undefined. + return (this.minWidth > maxWidth ? this.minWidth : maxWidth); + } + + /** + * The actual width currently utilized by the `BannerSuggestion`'s display element, regardless of + * current state. + */ + public get currentWidth(): number { + return this.div.offsetWidth; + } + + /** + * The actual width currently utilized by the `BannerSuggestion`'s display element, regardless of + * current state. + */ + public set currentWidth(val: number) { + // TODO: probably should set up errors or something here... + if(val < this.collapsedWidth) { + val = this.collapsedWidth; + } else if(val > this.expandedWidth) { + val = this.expandedWidth; + } + + if(this.rtl) { + this.container.style.marginRight = `${val - this.expandedWidth}px`; + } else { + this.container.style.marginLeft = `${val - this.expandedWidth}px`; + } } public highlight(on: boolean) { const elem = this.div; - let classes = elem.className; - let cs = ' ' + SuggestionBanner.TOUCHED_CLASS; - if(on && classes.indexOf(cs) < 0) { - elem.className=classes+cs; + if(on) { + elem.classList.add(TOUCHED_CLASS); } else { - elem.className=classes.replace(cs,''); + elem.classList.remove(TOUCHED_CLASS); } } @@ -148,7 +378,8 @@ export class BannerSuggestion { * Description Display lexical model suggestions in the banner */ export class SuggestionBanner extends Banner { - public static readonly SUGGESTION_LIMIT: number = 3; + public static readonly SUGGESTION_LIMIT: number = 8; + public static readonly LONG_SUGGESTION_DISPLAY_LIMIT: number = 3; public static readonly MARGIN = 1; public readonly type = "suggestion"; @@ -158,20 +389,33 @@ export class SuggestionBanner extends Banner { private currentSuggestions: Suggestion[] = []; private options : BannerSuggestion[] = []; + private separators: HTMLElement[] = []; + + private isRTL: boolean = false; + private hostDevice: DeviceSpec; + /** + * The banner 'container', which is also the root element for banner scrolling. + */ + private readonly container: HTMLElement; + private highlightAnimation: SuggestionExpandContractAnimation; + private gestureEngine: GestureRecognizer; + private scrollState: BannerScrollState; + private selectionBounds: RecognitionZoneSource; private _predictionContext: PredictionContext; - static readonly TOUCHED_CLASS: string = 'kmw-suggest-touched'; - static readonly BANNER_CLASS: string = 'kmw-suggest-banner'; - constructor(hostDevice: DeviceSpec, height?: number) { super(height || Banner.DEFAULT_HEIGHT); this.hostDevice = hostDevice; this.getDiv().className = this.getDiv().className + ' ' + SuggestionBanner.BANNER_CLASS; + + this.container = document.createElement('div'); + this.container.className = BANNER_SCROLLER_CLASS; + this.getDiv().appendChild(this.container); this.buildInternals(false); this.events = new EventEmitter(); //this.manager.events; @@ -180,9 +424,12 @@ export class SuggestionBanner extends Banner { } buildInternals(rtl: boolean) { + this.isRTL = rtl; if(this.options.length > 0) { - this.options.splice(0, this.options.length); // Clear the array. + this.options = []; + this.separators = []; } + for (var i=0; i { - - const findTargetFrom = (e: HTMLElement): HTMLDivElement => { - try { - if(e) { - if(e.classList.contains('kmw-suggest-option')) { - return e as HTMLDivElement; - } - if(e.parentElement && e.parentElement.classList.contains('kmw-suggest-option')) { - return e.parentElement as HTMLDivElement; - } - } - } catch(ex) {} - return null; - } + // Auto-cancels suggestion-selection if the finger moves too far; having very generous + // safe-zone settings also helps keep scrolls active on demo pages, etc. + const safeBounds = new PaddedZoneSource(this.getDiv(), [-Number.MAX_SAFE_INTEGER]); + this.selectionBounds = new PaddedZoneSource( + this.getDiv(), + [-BANNER_VERT_ROAMING_HEIGHT_RATIO * this.height, -Number.MAX_SAFE_INTEGER] + ); const config: GestureRecognizerConfiguration = { targetRoot: this.getDiv(), - maxRoamingBounds: new PaddedZoneSource(this.getDiv(), [-0.333 * this.height]), + maxRoamingBounds: safeBounds, + safeBounds: safeBounds, // touchEventRoot: this.element, // is the default itemIdentifier: (sample, target: HTMLElement) => { + const selBounds = this.selectionBounds.getBoundingClientRect(); + + // Step 1: is the coordinate within the range we permit for selecting _anything_? + if(sample.clientX < selBounds.left || sample.clientX > selBounds.right) { + return null; + } + if(sample.clientY < selBounds.top || sample.clientY > selBounds.bottom) { + return null; + } + + // Step 2: find the best-matching selection. + let bestMatch: BannerSuggestion = null; let bestDist = Number.MAX_VALUE; @@ -260,14 +521,33 @@ export class SuggestionBanner extends Banner { const sourceTracker: { source: GestureSource, - roamingHighlightHandler: (sample: InputSample) => void, + scrollingHandler: (sample: InputSample) => void, suggestion: BannerSuggestion } = { source: null, - roamingHighlightHandler: null, + scrollingHandler: null, suggestion: null }; + const markSelection = (suggestion: BannerSuggestion) => { + suggestion.highlight(true); + if(this.highlightAnimation) { + this.highlightAnimation.cancel(); + this.highlightAnimation.decouple(); + } + + this.highlightAnimation = new SuggestionExpandContractAnimation(this.container, suggestion, false); + this.highlightAnimation.expand(); + } + + const clearSelection = (suggestion: BannerSuggestion) => { + suggestion.highlight(false); + if(!this.highlightAnimation) { + this.highlightAnimation = new SuggestionExpandContractAnimation(this.container, suggestion, false); + } + this.highlightAnimation.collapse(); + } + engine.on('inputstart', (source) => { // The banner does not support multi-touch - if one is still current, block all others. if(sourceTracker.source) { @@ -275,41 +555,64 @@ export class SuggestionBanner extends Banner { return; } + this.scrollState = new BannerScrollState(source.currentSample, this.container.scrollLeft); + const suggestion = source.baseItem; + sourceTracker.source = source; - sourceTracker.roamingHighlightHandler = (sample) => { - // Maintain highlighting - const suggestion = sample.item; + sourceTracker.scrollingHandler = (sample) => { + const newScrollLeft = this.scrollState.updateTo(sample); + this.highlightAnimation.setBaseScroll(newScrollLeft); - if(suggestion != sourceTracker.suggestion) { - sourceTracker.suggestion.highlight(false); - suggestion.highlight(true); - sourceTracker.suggestion = suggestion; + // Only re-enable the original suggestion, even if the touchpoint finds + // itself over a different suggestion. Might happen if a scroll boundary + // is reached. + const incoming = sample.item ? suggestion : null; + + // It's possible to cancel selection while still scrolling. + if(incoming != sourceTracker.suggestion) { + if(sourceTracker.suggestion) { + clearSelection(sourceTracker.suggestion); } - }; - sourceTracker.suggestion = source.currentSample.item; + sourceTracker.suggestion = incoming; + if(incoming) { + markSelection(incoming); + } + } + }; + + sourceTracker.suggestion = source.currentSample.item; + markSelection(sourceTracker.suggestion); source.currentSample.item.highlight(true); const terminationHandler = () => { - sourceTracker.suggestion.highlight(false); + if(sourceTracker.suggestion) { + clearSelection(sourceTracker.suggestion); + sourceTracker.suggestion = null; + } + sourceTracker.source = null; - sourceTracker.roamingHighlightHandler = null; - sourceTracker.suggestion = null; + sourceTracker.scrollingHandler = null; } source.path.on('complete', terminationHandler); source.path.on('invalidated', terminationHandler); - source.path.on('step', sourceTracker.roamingHighlightHandler); + source.path.on('step', sourceTracker.scrollingHandler); }); engine.on('recognizedgesture', (sequence) => { // The actual result comes in via the sequence's `stage` event. sequence.once('stage', (result) => { const suggestion = result.item; // Should also == sourceTracker.suggestion. - if(suggestion) { - this.predictionContext.accept(suggestion.suggestion); + if(suggestion && !this.scrollState.hasScrolled) { + this.predictionContext.accept(suggestion.suggestion).then(() => { + // Reset the scroll state + this.container.scrollLeft = this.isRTL ? this.container.scrollWidth : 0; + }); } + + this.scrollState = null; }); }); @@ -322,7 +625,9 @@ export class SuggestionBanner extends Banner { // Ensure the banner's extended recognition zone is based on proper, up-to-date layout info. // Note: during banner init, `this.gestureEngine` may only be defined after // the first call to this setter! - (this.gestureEngine?.config.maxRoamingBounds as PaddedZoneSource)?.updatePadding([-0.333 * this.height]); + (this.selectionBounds as PaddedZoneSource)?.updatePadding( + [-BANNER_VERT_ROAMING_HEIGHT_RATIO * this.height, -Number.MAX_SAFE_INTEGER] + ); return result; } @@ -335,7 +640,7 @@ export class SuggestionBanner extends Banner { // parse incoming HTML. // // Just in case, alternative approaches: https://stackoverflow.com/a/3955238 - this.getDiv().textContent = ''; + this.container.textContent = ''; // Builds new children to match needed RTL properties. this.buildInternals(rtl); @@ -362,16 +667,106 @@ export class SuggestionBanner extends Banner { } } + /** + * Produces a closure useful for updating the SuggestionBanner's UI to match newly-received + * suggestions, including optimization of the banner's layout. + * @param suggestions + */ public onSuggestionUpdate = (suggestions: Suggestion[]): void => { this.currentSuggestions = suggestions; + // Immediately stop all animations and reset options accordingly. + this.highlightAnimation?.cancel(); - this.options.forEach((option: BannerSuggestion, i: number) => { - if(i < suggestions.length) { - option.update(suggestions[i]); + const fontStyleBase = this.options[0].computedStyle; + // Do NOT just re-use the returned object from the line above; it may spontaneously change + // (in a bad way) when the underlying span is replaced! + const fontStyle = { + fontSize: fontStyleBase.fontSize, + fontFamily: fontStyleBase.fontFamily + } + const emSizeStr = getComputedStyle(document.body).fontSize; + const emSize = getFontSizeStyle(emSizeStr).val; + + const textStyle = getComputedStyle(this.options[0].container.firstChild as HTMLSpanElement); + + const targetWidth = this.width / SuggestionBanner.LONG_SUGGESTION_DISPLAY_LIMIT; + + // computedStyle will fail if the element's not in the DOM yet. + // Seeks to get the values specified within kmwosk.css. + const textLeftPad = new ParsedLengthStyle(textStyle.paddingLeft || '4px'); + const textRightPad = new ParsedLengthStyle(textStyle.paddingRight || '4px'); + + let optionFormat: BannerSuggestionFormatSpec = { + paddingWidth: textLeftPad.val + textRightPad.val, // Assumes fixed px padding. + emSize: emSize, + styleForFont: fontStyle, + collapsedWidth: targetWidth, + minWidth: 0, + } + + let totalWidth = 0; + let displayCount = 0; + + let collapsedOptions: BannerSuggestion[] = []; + + for (let i=0; i i) { + const suggestion = suggestions[i]; + d.update(suggestion, optionFormat); + if(d.collapsedWidth < d.expandedWidth) { + collapsedOptions.push(d); + } + + totalWidth += d.collapsedWidth; + displayCount++; } else { - option.update(null); + d.update(null, optionFormat); } - }); + } + + // Ensure one suggestion is always displayed, even if empty. (Keep the separators out) + displayCount = displayCount || 1; + + if(totalWidth < this.width) { + let separatorWidth = (this.width * 0.01 * (displayCount-1)); + // Prioritize adding padding to suggestions that actually need it. + // Use equal measure for each so long as it still could use extra display space. + while(totalWidth < this.width && collapsedOptions.length > 0) { + let maxFillPadding = (this.width - totalWidth - separatorWidth) / collapsedOptions.length; + collapsedOptions.sort((a, b) => a.expandedWidth - b.expandedWidth); + + let shortestCollapsed = collapsedOptions[0]; + let neededWidth = shortestCollapsed.expandedWidth - shortestCollapsed.collapsedWidth; + + let padding = Math.min(neededWidth, maxFillPadding); + + // Check: it is possible that two elements were matched for equal length, thus the second loop's takes no additional padding. + // No need to trigger re-layout ops for that case. + if(padding > 0) { + collapsedOptions.forEach((a) => a.minWidth = a.collapsedWidth + padding); + totalWidth += padding * collapsedOptions.length; // don't forget to record that we added the padding! + } + + collapsedOptions.splice(0, 1); // discard the element we based our judgment upon; we need not consider it any longer. + } + + // If there's STILL leftover padding to distribute, let's do that now. + let fillPadding = (this.width - totalWidth - separatorWidth) / displayCount; + + for(let i=0; i < displayCount; i++) { + const d = this.options[i]; + + d.minWidth = d.collapsedWidth + fillPadding; + d.updateLayout(); + } + } + + // Hide any separators beyond the final displayed suggestion + for(let i=0; i < SuggestionBanner.SUGGESTION_LIMIT - 1; i++) { + this.separators[i].style.display = i < displayCount - 1 ? '' : 'none'; + } } } @@ -379,4 +774,260 @@ interface SuggestionInputEventMap { highlight: (bannerSuggestion: BannerSuggestion, state: boolean) => void, apply: (bannerSuggestion: BannerSuggestion) => void; hold: (bannerSuggestion: BannerSuggestion) => void; -} \ No newline at end of file + scrollLeft: (val: number) => void; +} + + +class SuggestionExpandContractAnimation { + private scrollContainer: HTMLElement | null; + private option: BannerSuggestion; + + private collapsedScrollOffset: number; + private rootScrollOffset: number; + + private startTimestamp: number; + private pendingAnimation: number; + + private static TRANSITION_TIME = 250; // in ms. + + constructor(scrollContainer: HTMLElement, option: BannerSuggestion, forRTL: boolean) { + this.scrollContainer = scrollContainer; + this.option = option; + this.collapsedScrollOffset = scrollContainer.scrollLeft; + this.rootScrollOffset = scrollContainer.scrollLeft; + } + + public setBaseScroll(val: number) { + this.collapsedScrollOffset = val; + + // If the user has shifted the scroll position to make more of the element visible, we can remove part + // of the corresponding scrolling offset permanently; the user's taken action to view that area. + if(this.option.rtl) { + // A higher scrollLeft (scrolling right) will reveal more of an initially-clipped suggestion. + if(val > this.rootScrollOffset) { + this.rootScrollOffset = val; + } + } else { + // Here, a lower scrollLeft (scrolling left). + if(val < this.rootScrollOffset) { + this.rootScrollOffset = val; + } + } + + // Synchronize the banner-scroller's offset update with that of the + // animation for expansion and collapsing. + window.requestAnimationFrame(this.setScrollOffset); + } + + /** + * Performs mapping of the user's touchpoint to properly-offset scroll coordinates based on + * the state of the ongoing scroll operation. + * + * First priority: this function aims to keep all currently-visible parts of a selected + * suggestion visible when first selected. Any currently-clipped parts will remain clipped. + * + * Second priority: all animations should be smooth and continuous; aesthetics do matter to + * users. + * + * Third priority: when possible without violating the first two priorities, this (in tandem with + * adjustments within `setBaseScroll`) will aim to sync the touchpoint with its original + * location on an expanded suggestion. + * - For LTR languages, this means that suggestions will "expand left" if possible. + * - While for RTL languages, they will "expand right" if possible. + * - However, if they would expand outside of the banner's effective viewport, a scroll offset + * will kick in to enforce the "first priority" mentioned above. + * - This "scroll offset" will be progressively removed (because second priority) if and as + * the user manually scrolls to reveal relevant space that was originally outside of the viewport. + * + * @returns + */ + private setScrollOffset = () => { + // If we've been 'decoupled', a different instance (likely for a different suggestion) + // is responsible for counter-scrolling. + if(!this.scrollContainer) { + return; + } + + // -- Clamping / "scroll offset" logic -- + + // As currently written / defined below, and used internally within this function, "clamping" + // refers to alterations to scroll-positioned mapping designed to keep as much of the expanded + // option visible as possible via the offsets below (that is, "clamped" to the relevant border) + // while not adding extra discontinuity by pushing already-obscured parts of the expanded option + // into visible range. + // + // In essence, it's an extra "scroll offset" we apply that is dynamically adjusted depending on + // scroll position as it changes. This offset may be decreased when it is no longer needed to + // make parts of the element visible. + + // The amount of extra space being taken by a partially or completely expanded suggestion. + const maxWidthToCounterscroll = this.option.currentWidth - this.option.collapsedWidth; + const rtl = this.option.rtl; + + // If non-zero, indicates the pixel-width of the collapsed form of the suggestion clipped by the relevant screen border. + const ltrOverflow = Math.max(this.rootScrollOffset - this.option.div.offsetLeft, 0); + const rtlOverflow = Math.max(this.option.div.offsetLeft + this.option.collapsedWidth - (this.rootScrollOffset + this.scrollContainer.offsetWidth)); + + const srcCounterscrollOverflow = Math.max(rtl ? rtlOverflow : ltrOverflow, 0); // positive offset into overflow-land. + + // Base position for scrollLeft clamped within std element scroll bounds, including: + // - an adjustment to cover the extra width from expansion + // - preserving the base expected overflow levels + // Does NOT make adjustments to force extra visibility on the element being highlighted/focused. + const unclampedExpandingScrollOffset = Math.max(this.collapsedScrollOffset + (rtl ? 0 : 1) * maxWidthToCounterscroll, 0) + (rtl ? 0 : -1) * srcCounterscrollOverflow; + // The same, but for our 'root scroll coordinate'. + const rootUnclampedExpandingScrollOffset = Math.max(this.rootScrollOffset + (rtl ? 0 : 1) * maxWidthToCounterscroll, 0) + (rtl ? 0 : -1) * srcCounterscrollOverflow; + + // Do not shift an element clipped by the screen border further than its original scroll starting point. + const elementOffsetForClamping = rtl + ? Math.max(unclampedExpandingScrollOffset, rootUnclampedExpandingScrollOffset) + : Math.min(unclampedExpandingScrollOffset, rootUnclampedExpandingScrollOffset); + + // Based on the scroll point selected, determine how far to offset scrolls to keep the option in visible range. + // Higher .scrollLeft values make this non-zero and reflect when scroll has begun clipping the element. + const elementOffsetFromBorder = rtl + // RTL offset: "offsetRight" based on "scrollRight" + ? Math.max(this.option.div.offsetLeft + this.option.currentWidth - (elementOffsetForClamping + this.scrollContainer.offsetWidth), 0) // double-check this one. + // LTR: based on scrollLeft offsetLeft + : Math.max(elementOffsetForClamping - this.option.div.offsetLeft, 0); + + // If the element is close enough to the border, don't offset beyond the element! + // If it is further, do not add excess padding - it'd effectively break scrolling. + // Do maintain any remaining scroll offset that exists, though. + const clampedExpandingScrollOffset = Math.min(maxWidthToCounterscroll, elementOffsetFromBorder); + + const finalScrollOffset = unclampedExpandingScrollOffset // base scroll-coordinate transform mapping based on extra width from element expansion + + (rtl ? 1 : -1) * clampedExpandingScrollOffset // offset to scroll to put word-start border against the corresponding screen border, fully visible + + (rtl ? 0 : 1) * srcCounterscrollOverflow; // offset to maintain original overflow past that border if it existed + + // -- Final step: Apply & fine-tune the final scroll positioning -- + this.scrollContainer.scrollLeft = finalScrollOffset; + + // Prevent "jitters" during counterscroll that occur on expansion / collapse animation. + // A one-frame "error correction" effect at the end of animation is far less jarring. + if(this.pendingAnimation) { + // scrollLeft doesn't work well with fractional values, unlike marginLeft / marginRight + const fractionalOffset = this.scrollContainer.scrollLeft - finalScrollOffset; + // So we put the fractional difference into marginLeft to force it to sync. + this.option.currentWidth += fractionalOffset; + } + } + + public decouple() { + this.cancel(); + this.scrollContainer = null; + } + + private clear() { + this.startTimestamp = null; + window.cancelAnimationFrame(this.pendingAnimation); + this.pendingAnimation = null; + } + + cancel() { + this.clear(); + this.option.currentWidth = this.option.collapsedWidth; + } + + public expand() { + // Cancel any prior iterating animation-frame commands. + this.clear(); + + // set timestamp, adjusting the current time based on intermediate progress + this.startTimestamp = performance.now(); + + let progress = this.option.currentWidth - this.option.collapsedWidth; + let expansionDiff = this.option.expandedWidth - this.option.collapsedWidth; + + if(progress != 0) { + // Offset the timestamp by noting what start time would have given rise to + // the current position, keeping related animations smooth. + this.startTimestamp -= (progress / expansionDiff) * SuggestionExpandContractAnimation.TRANSITION_TIME; + } + + this.pendingAnimation = window.requestAnimationFrame(this._expand); + } + + private _expand = (timestamp: number) => { + if(this.startTimestamp === undefined) { + return; // No active expand op exists. May have been cancelled via `clear`. + } + + let progressTime = timestamp - this.startTimestamp; + let fin = progressTime > SuggestionExpandContractAnimation.TRANSITION_TIME; + + if(fin) { + progressTime = SuggestionExpandContractAnimation.TRANSITION_TIME; + } + + // -- Part 1: handle option expand / collapse state -- + let expansionDiff = this.option.expandedWidth - this.option.collapsedWidth; + let expansionRatio = progressTime / SuggestionExpandContractAnimation.TRANSITION_TIME; + + // expansionDiff * expansionRatio: the total adjustment from 'collapsed' width, in px. + const expansionPx = expansionDiff * expansionRatio; + this.option.currentWidth = expansionPx + this.option.collapsedWidth; + + // Part 2: trigger the next animation frame. + if(!fin) { + this.pendingAnimation = window.requestAnimationFrame(this._expand); + } else { + this.clear(); + } + + // Part 3: perform any needed counter-scrolling, scroll clamping, etc + // Existence of a followup animation frame is part of the logic, so keep this 'after'! + this.setScrollOffset(); + }; + + public collapse() { + // Cancel any prior iterating animation-frame commands. + this.clear(); + + // set timestamp, adjusting the current time based on intermediate progress + this.startTimestamp = performance.now(); + + let progress = this.option.expandedWidth - this.option.currentWidth; + let expansionDiff = this.option.expandedWidth - this.option.collapsedWidth; + + if(progress != 0) { + // Offset the timestamp by noting what start time would have given rise to + // the current position, keeping related animations smooth. + this.startTimestamp -= (progress / expansionDiff) * SuggestionExpandContractAnimation.TRANSITION_TIME; + } + + this.pendingAnimation = window.requestAnimationFrame(this._collapse); + } + + private _collapse = (timestamp: number) => { + if(this.startTimestamp === undefined) { + return; // No active collapse op exists. May have been cancelled via `clear`. + } + + let progressTime = timestamp - this.startTimestamp; + let fin = progressTime > SuggestionExpandContractAnimation.TRANSITION_TIME; + if(fin) { + progressTime = SuggestionExpandContractAnimation.TRANSITION_TIME; + } + + // -- Part 1: handle option expand / collapse state -- + let expansionDiff = this.option.expandedWidth - this.option.collapsedWidth; + let expansionRatio = 1 - progressTime / SuggestionExpandContractAnimation.TRANSITION_TIME; + + // expansionDiff * expansionRatio: the total adjustment from 'collapsed' width, in px. + const expansionPx = expansionDiff * expansionRatio; + this.option.currentWidth = expansionPx + this.option.collapsedWidth; + + // Part 2: trigger the next animation frame. + if(!fin) { + this.pendingAnimation = window.requestAnimationFrame(this._collapse); + } else { + this.clear(); + } + + // Part 3: perform any needed counter-scrolling, scroll clamping, etc + // Existence of a followup animation frame is part of the logic, so keep this 'after'! + this.setScrollOffset(); + }; +} + diff --git a/web/src/engine/osk/src/keyboard-layout/getTextMetrics.ts b/web/src/engine/osk/src/keyboard-layout/getTextMetrics.ts new file mode 100644 index 0000000000..bb5db1cbb9 --- /dev/null +++ b/web/src/engine/osk/src/keyboard-layout/getTextMetrics.ts @@ -0,0 +1,51 @@ +import { getFontSizeStyle } from "../fontSizeUtils.js"; + +let metricsCanvas: HTMLCanvasElement; + +/** + * Uses canvas.measureText to compute and return the width of the given text of given font in pixels. + * + * @param {String} text The text to be rendered. + * @param emScale The absolute `px` size expected to match `1em`. + * @param {String} style The CSSStyleDeclaration for an element to measure against, without modification. + * + * @see https://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393 + * This version has been substantially modified to work for this particular application. + */ +export function getTextMetrics(text: string, emScale: number, style: {fontFamily?: string, fontSize: string}): TextMetrics { + // Since we may mutate the incoming style, let's make sure to copy it first. + // Only the relevant properties, though. + style = { + fontFamily: style.fontFamily, + fontSize: style.fontSize + }; + + // A final fallback - having the right font selected makes a world of difference. + if(!style.fontFamily) { + style.fontFamily = getComputedStyle(document.body).fontFamily; + } + + if(!style.fontSize || style.fontSize == "") { + style.fontSize = '1em'; + } + + let fontFamily = style.fontFamily; + let fontSpec = getFontSizeStyle(style.fontSize); + + var fontSize: string; + if(fontSpec.absolute) { + // We've already got an exact size - use it! + fontSize = fontSpec.val + 'px'; + } else { + fontSize = fontSpec.val * emScale + 'px'; + } + + // re-use canvas object for better performance + metricsCanvas = metricsCanvas ?? document.createElement("canvas"); + + var context = metricsCanvas.getContext("2d"); + context.font = fontSize + " " + fontFamily; + var metrics = context.measureText(text); + + return metrics; +} \ No newline at end of file diff --git a/web/src/engine/osk/src/keyboard-layout/oskKey.ts b/web/src/engine/osk/src/keyboard-layout/oskKey.ts index 29d04dd037..3d364a9549 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskKey.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskKey.ts @@ -11,6 +11,7 @@ import buttonClassNames from '../buttonClassNames.js'; import { KeyElement } from '../keyElement.js'; import VisualKeyboard from '../visualKeyboard.js'; +import { getTextMetrics } from './getTextMetrics.js'; /** * Replace default key names by special font codes for modifier keys @@ -166,53 +167,6 @@ export default abstract class OSKKey { } } - /** - * Uses canvas.measureText to compute and return the width of the given text of given font in pixels. - * - * @param {String} text The text to be rendered. - * @param {String} style The CSSStyleDeclaration for an element to measure against, without modification. - * - * @see https://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393 - * This version has been substantially modified to work for this particular application. - */ - static getTextMetrics(text: string, emScale: number, style: {fontFamily?: string, fontSize: string}): TextMetrics { - // Since we may mutate the incoming style, let's make sure to copy it first. - // Only the relevant properties, though. - style = { - fontFamily: style.fontFamily, - fontSize: style.fontSize - }; - - // A final fallback - having the right font selected makes a world of difference. - if(!style.fontFamily) { - style.fontFamily = getComputedStyle(document.body).fontFamily; - } - - if(!style.fontSize || style.fontSize == "") { - style.fontSize = '1em'; - } - - let fontFamily = style.fontFamily; - let fontSpec = getFontSizeStyle(style.fontSize); - - var fontSize: string; - if(fontSpec.absolute) { - // We've already got an exact size - use it! - fontSize = fontSpec.val + 'px'; - } else { - fontSize = fontSpec.val * emScale + 'px'; - } - - // re-use canvas object for better performance - var canvas: HTMLCanvasElement = OSKKey.getTextMetrics['canvas'] || - (OSKKey.getTextMetrics['canvas'] = document.createElement("canvas")); - var context = canvas.getContext("2d"); - context.font = fontSize + " " + fontFamily; - var metrics = context.measureText(text); - - return metrics; - } - /** * Calculate the font size required for a key cap, scaling to fit longer text * @param vkbd @@ -243,7 +197,7 @@ export default abstract class OSKKey { } let fontSpec = getFontSizeStyle(style.fontSize || '1em'); - let metrics = OSKKey.getTextMetrics(text, emScale, style); + let metrics = getTextMetrics(text, emScale, style); const MAX_X_PROPORTION = 0.90; const MAX_Y_PROPORTION = 0.90; @@ -300,12 +254,8 @@ export default abstract class OSKKey { // Add OSK key labels let keyText = null; if(spec['text'] == null || spec['text'] == '') { - if(typeof spec['id'] == 'string') { - // If the ID's Unicode-based, just use that code. - keyText = ActiveKey.unicodeIDToText(spec['id']); - } - - keyText = keyText || DEFAULT_BLANK; + // U_ codes are handled during keyboard pre-processing. + keyText = DEFAULT_BLANK; } else { keyText=spec['text']; @@ -355,7 +305,7 @@ export default abstract class OSKKey { // Check the key's display width - does the key visualize well? let emScale = vkbd.getKeyEmFontSize(); - var width: number = OSKKey.getTextMetrics(keyText, emScale, styleSpec).width; + var width: number = getTextMetrics(keyText, emScale, styleSpec).width; if(width == 0 && keyText != '' && keyText != '\xa0') { // Add the Unicode 'empty circle' as a base support for needy diacritics. diff --git a/web/src/engine/osk/src/views/oskView.ts b/web/src/engine/osk/src/views/oskView.ts index 36cb2dc839..b1d16d292f 100644 --- a/web/src/engine/osk/src/views/oskView.ts +++ b/web/src/engine/osk/src/views/oskView.ts @@ -627,6 +627,7 @@ export default abstract class OSKView if(!pending) { this.headerView?.refreshLayout(); this.bannerView.refreshLayout(); + this.bannerView.width = this.computedWidth; this.footerView?.refreshLayout(); } @@ -728,9 +729,7 @@ export default abstract class OSKView // Add suggestion banner bar to OSK this._Box.appendChild(this.banner.element); - if(this.bannerView.banner) { - this.banner.banner.configureForKeyboard(this.keyboardData?.keyboard, this.keyboardData?.metadata); - } + this.bannerController?.configureForKeyboard(this.keyboardData?.keyboard, this.keyboardData?.metadata); let kbdView: KeyboardView = this.keyboardView = this._GenerateKeyboardView(this.keyboardData?.keyboard, this.keyboardData?.metadata); this._Box.appendChild(kbdView.element); diff --git a/web/src/engine/osk/src/visualKeyboard.ts b/web/src/engine/osk/src/visualKeyboard.ts index 79563beb75..3bed7075e2 100644 --- a/web/src/engine/osk/src/visualKeyboard.ts +++ b/web/src/engine/osk/src/visualKeyboard.ts @@ -407,6 +407,20 @@ export default class VisualKeyboard extends EventEmitter implements Ke previewHost: GesturePreviewHost }> = {}; + const clearActiveGestures = (excludedTouchpointId?: string) => { + for(const identifier of Object.keys(sourceTrackingMap)) { + // Filter out the exclusion if one exists. + if(identifier == excludedTouchpointId) { + continue; + } + + // Any _other_ gesture, though - yeah, that should cancel out. + // Note: this can cancel ongoing modipress gestures, which may trigger an unexpected layer shift. + const entry = sourceTrackingMap[identifier]; + entry.source.terminate(true); + } + } + const gestureHandlerMap = new Map, GestureHandler[]>(); // Now to set up event-handling links. @@ -612,8 +626,11 @@ export default class VisualKeyboard extends EventEmitter implements Ke // Merely constructing the instance is enough; it'll link into the sequence's events and // handle everything that remains for the backspace from here. handlers = [new HeldRepeater(gestureSequence, () => this.modelKeyClick(gestureKey, coord))]; - } else if(gestureKey.key.spec.baseKeyID == "K_LOPT") { + } else if(gestureKey.key.spec.baseKeyID == "K_LOPT") { // globe key gestureSequence.on('complete', () => this.emit('globekey', gestureKey, false)); + // Cancel all other gesture sources; a language-menu interaction voids all previously-active + // gestures that haven't completed. + clearActiveGestures(coordSource.identifier); } } else if(gestureStage.matchedId.indexOf('longpress') > -1) { existingPreviewHost?.cancel(); @@ -903,7 +920,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke // Prevent NaN breakages. if (!width || !height) { - return null; + return new Map(); } let kbdAspectRatio = width / height; @@ -1038,7 +1055,9 @@ export default class VisualKeyboard extends EventEmitter implements Ke return; } - this.gestureEngine.stateToken = layerId; + if(this.gestureEngine) { + this.gestureEngine.stateToken = layerId; + } // So... through KMW 14, we actually never tracked the capsKey, numKey, and scrollKey // properly for keyboard-defined layouts - only _default_, desktop-style layouts. @@ -1250,15 +1269,19 @@ export default class VisualKeyboard extends EventEmitter implements Ke return; } - // Step 3: perform layout operations. - const paddingZone = this.gestureEngine.config.maxRoamingBounds as PaddedZoneSource; - paddingZone.updatePadding([-0.333 * this.currentLayer.rowHeight]); + // Step 3: recalculate gesture parameter values + // Skip for doc-keyboards, since they don't do gestures. + if(!this.isStatic) { + const paddingZone = this.gestureEngine.config.maxRoamingBounds as PaddedZoneSource; + paddingZone.updatePadding([-0.333 * this.currentLayer.rowHeight]); - this.gestureParams.longpress.flickDist = 0.25 * this.currentLayer.rowHeight; - this.gestureParams.flick.startDist = 0.15 * this.currentLayer.rowHeight; - this.gestureParams.flick.dirLockDist = 0.35 * this.currentLayer.rowHeight; - this.gestureParams.flick.triggerDist = 0.75 * this.currentLayer.rowHeight; + this.gestureParams.longpress.flickDist = 0.25 * this.currentLayer.rowHeight; + this.gestureParams.flick.startDist = 0.15 * this.currentLayer.rowHeight; + this.gestureParams.flick.dirLockDist = 0.35 * this.currentLayer.rowHeight; + this.gestureParams.flick.triggerDist = 0.75 * this.currentLayer.rowHeight; + } + // Step 4: perform layout operations. // Needs the refreshed layout info to work correctly. if(this.currentLayer) { this.currentLayer.refreshLayout(this, this._computedHeight - this.getVerticalLayerGroupPadding()); diff --git a/web/src/resources/osk/kmwosk.css b/web/src/resources/osk/kmwosk.css index d07d5726e9..d0b9980d21 100644 --- a/web/src/resources/osk/kmwosk.css +++ b/web/src/resources/osk/kmwosk.css @@ -66,6 +66,25 @@ font-size: 0.75em; } +.kmw-suggest-banner-scroller { + overflow-x: hidden; + width: 100%; + height: 100%; + scrollbar-width: none; /* Firefox scrollbar prevention */ +} + +.kmw-suggest-banner-scroller::-webkit-scrollbar { + display: none; /* Safari + Chrome scrollbar prevention */ +} + +.kmw-suggest-option { + overflow: hidden; +} + +.kmw-suggestion-container { + height: 100%; +} + .phone.windows .kmw-key-row{max-width:80%;} .phone .kmw-5rows {padding-top: 0;} @@ -81,7 +100,6 @@ .phone.ios .kmw-key.kmw-key-shift-on, .phone.ios .kmw-key.kmw-key-special-on {color:#fff;background-color:#88f;} .phone.ios .kmw-key.kmw-key-touched {background-color:#447;} -.phone.ios .kmw-suggest-option.kmw-suggest-touched {background-color:#88f;} .phone.ios .kmw-key-deadkey{color:#048204;background-color:#fdfdfe;} /* Probably best to make this its own CSS that can be optionally included? */ @@ -104,6 +122,14 @@ width: 100%; } +.ios .kmw-suggest-option::before { + background: linear-gradient(90deg, #cfd3d9 0%, transparent 100%); +} + +.ios .kmw-suggest-option::after { + background: linear-gradient(90deg, transparent 0%, #cfd3d9 100%); +} + .ios .kmw-banner-bar .kmw-suggest-option { display:inline-block; text-align: center; @@ -118,7 +144,18 @@ color: #000; } -.phone.ios .kmw-suggest-option.kmw-suggest-touched {background-color:#88f;} +.phone.ios .kmw-suggest-option.kmw-suggest-touched, +.tablet.ios .kmw-suggest-option.kmw-suggest-touched {background-color:#88f;} + +.phone.ios .kmw-suggest-option.kmw-suggest-touched::before, +.tablet.ios .kmw-suggest-option.kmw-suggest-touched::before { + background: linear-gradient(90deg, #88f 0%, transparent 100%); +} + +.phone.ios .kmw-suggest-option.kmw-suggest-touched::after, +.tablet.ios .kmw-suggest-option.kmw-suggest-touched::after { + background: linear-gradient(90deg, transparent 0%, #88f 100%); +} .phone.ios.kmw-osk-frame, .tablet.ios.kmw-osk-frame { @@ -136,6 +173,14 @@ background-color: #0f1319; } + .ios .kmw-suggest-option::before { + background: linear-gradient(90deg, #0f1319 0%, transparent 100%); + } + + .ios .kmw-suggest-option::after { + background: linear-gradient(90deg, transparent 0%, #0f1319 100%); + } + .ios .kmw-banner-bar .kmw-banner-separator { border-left: solid 1px #8a8d90 } @@ -174,6 +219,14 @@ width: 100%; } +.phone.android .kmw-suggest-option::before { + background: linear-gradient(90deg, #222 0%, transparent 100%); +} + +.phone.android .kmw-suggest-option::after { + background: linear-gradient(90deg, transparent 0%, #222 100%); +} + .phone.android .kmw-banner-bar .kmw-suggest-option { display:inline-block; text-align: center; @@ -186,6 +239,14 @@ .phone.android .kmw-suggest-option.kmw-suggest-touched {background-color:#bbb;} +.phone.android .kmw-suggest-option.kmw-suggest-touched::before { + background: linear-gradient(90deg, #bbb 0%, transparent 100%); +} + +.phone.android .kmw-suggest-option.kmw-suggest-touched::after { + background: linear-gradient(90deg, transparent 0%, #bbb 100%); +} + .tablet.kmw-osk-frame{left:0;bottom:0;width:100%;height:144px;overflow-y:visible; background-color:rgba(0,0,0,0.8);-webkit-user-select:none;} .tablet .kmw-osk-inner-frame{margin:0;background:transparent;} @@ -209,7 +270,6 @@ .tablet.ios .kmw-key.kmw-key-shift-on, .tablet.ios .kmw-key.kmw-key-special-on {color:#fff;background-color:#88f;} .tablet.ios .kmw-key.kmw-key-touched {background-color:#447;} -.tablet.ios .kmw-suggest-option.kmw-suggest-touched {background-color:#88f;} .tablet.ios .kmw-key-deadkey{color:#048204;background-color:#fdfdfe;} /* Probably best to make this its own CSS that can be optionally included? */ @@ -249,6 +309,14 @@ width: 100%; } +.tablet.android .kmw-suggest-option::before { + background: linear-gradient(90deg, #b4b4b8 0px, transparent 100%); +} + +.tablet.android .kmw-suggest-option::after { + background: linear-gradient(90deg, transparent 0%, #b4b4b8 100%); +} + .tablet.android .kmw-banner-bar .kmw-suggest-option { display:inline-block; text-align: center; @@ -258,11 +326,20 @@ .tablet.android .kmw-suggestion-text { color:#77f; } + .tablet.android .kmw-suggest-option.kmw-suggest-touched {background-color:#447;} +.tablet.android .kmw-suggest-option.kmw-suggest-touched::before { + background: linear-gradient(90deg, #447 0px, transparent 100%); +} + +.tablet.android .kmw-suggest-option.kmw-suggest-touched::after { + background: linear-gradient(90deg, transparent 0%, #447 100%); +} + /* Vertical centering of text labels on keys */ .kmw-key {text-align:center; white-space:nowrap;} -.kmw-key:before {content:'.'; display:inline-block; height:100%; vertical-align:middle; max-width:0px; visibility:hidden;} +.kmw-key::before {content:'.'; display:inline-block; height:100%; vertical-align:middle; max-width:0px; visibility:hidden;} .kmw-key span {display:inline-block} @@ -309,22 +386,98 @@ .kmw-footer-caption{color:#fff;font:0.7em Arial;margin:0 0 0 4px;} .kmw-banner-bar{height:100%; width:100%; margin:0; background-color:darkorange; display: inline-block; white-space: nowrap;} + +/* Creates a gradient to fade text at the borders, providing visual indication of overflow */ +/* Make sure the non-transparent color of the gradient matches .kmw-banner-bar's background-color. */ +.kmw-suggest-option::before, +.kmw-suggest-option::after { + position:absolute; + + /* Set scrollable-suggestion fade width here. Make sure to also set .kmw-suggestion-text + * padding-left and padding-right accordingly! + */ + width: 32px; + height: 100%; + content: ''; + top: 0; + z-index:10000; /* z-indexes this _behind_ the 'option' element that hosts the scrollable zone. */ + user-select: none; + pointer-events: none; /* Ensures click-through! But apparently not touch-through. */ + touch-action: none; /* Doesn't seem to allow touch-through, though - even with touch-action: none */ + /* https://stackoverflow.com/q/21474722 - poster never could find a solution, and settled*/ + /* on the same workaround: a 'before' and 'after' piece instead of a single overlay.*/ + transition: opacity 0.25s linear; +} + +.kmw-suggest-option.swallow-fade-transition::before, +.kmw-suggest-option.swallow-fade-transition::after { + transition-duration: 0s; +} + +.kmw-suggest-option::before { + background: linear-gradient(90deg, darkorange 0%, transparent 100%); + left: 0; +} + +.kmw-suggest-option.kmw-hide-fade-left::before, +.kmw-suggest-option.kmw-hide-fade-right::after { + opacity: 0; + /* visibility: hidden; */ +} + +.kmw-suggest-option::after { + background: linear-gradient(90deg, transparent 0%, darkorange 100%); + right: 0; +} + +/* Fallback suggestion-selection highlighting */ +.kmw-suggest-option.kmw-suggest-touched { + background: #bbb; +} + +.kmw-suggest-option.kmw-suggest-touched::before, +.kmw-suggest-option.kmw-suggest-touched::after { + /* Immediately start hiding the fade styling for touched suggestions. */ + opacity: 0; +} + +/* Creates a gradient to fade text at the borders, providing visual indication of overflow */ +/* Make sure the non-transparent color of the gradient matches .kmw-banner-bar's background-color. */ +.kmw-suggest-option.kmw-suggest-touched::before { + background: linear-gradient(90deg, #bbb 0%, transparent 100%); +} + +.kmw-suggest-option.kmw-suggest-touched::after { + background: linear-gradient(90deg, transparent 0%, #bbb 100%); +} + .kmw-banner-bar .kmw-banner-separator {border-left: solid 1px #8a8d90; width: 0px; vertical-align: middle; height: 45%; display: inline-block;} -.kmw-banner-bar .kmw-suggest-option {display:inline-block; text-align: center; height: 85%; overflow-x:hidden} -.kmw-suggestion-text{color:#fff; line-height: normal; position: relative; vertical-align: middle;} +.kmw-banner-bar .kmw-suggest-option {display:inline-block; text-align: center; height: 85%; position: relative; z-index: 10001} +.kmw-suggestion-text { + color:#fff; + line-height: normal; + position: relative; + vertical-align: middle; + /* Contrast with .kmw-suggest-option::before .width styling. */ + padding-left: 8px; /* Keeps a bit of whitespace on the suggestion's side. */ + padding-right: 8px; /* Keeps a bit of whitespace on the suggestion's side. */ + width: max-content; /* Ensure the text span acts like it contains its text */ + min-width: calc(100% - 16px); /* To ensure the span stays centered. */ + white-space: nowrap; +} .kmw-footer-resize{cursor:se-resize;position:absolute;right:2px;bottom:2px;width:16px;height:16px;overflow:hidden; font-family:SpecialOSK;color:white;} .kmw-footer-resize:hover{font-weight:bold;} -.kmw-footer-resize:before {content:'\e023';} +.kmw-footer-resize::before {content:'\e023';} .kmw-title-bar-image {cursor: default; float:right; padding: 2px 2px 0 0; width:16px; height:16px; font-family:SpecialOSK; color:white;} .kmw-title-bar-image:hover{font-weight:bold;} -#kmw-pin-image:before{content:'\e024';} -#kmw-config-image:before{content:'\e030';} -#kmw-help-image:before{content:'\e042';} -#kmw-close-button:before {content:'\e025';} +#kmw-pin-image::before{content:'\e024';} +#kmw-config-image::before{content:'\e030';} +#kmw-help-image::before{content:'\e042';} +#kmw-close-button::before {content:'\e025';} /* Common key appearance styles (can override with form-factor styles if necessary) */ .kmw-key-default{color:#000;background-color:#eee;} @@ -505,7 +658,7 @@ div.android div.kmw-keytip-cap { /* Box styles for keyboard-specific OSK (e.g. EuroLatin) and if no keyboard active (desktop only) */ .kmw-osk-static, .kmw-osk-none{text-align:left;font:12px sans-serif;border:solid 1px #ad4a28;color:blue;background-color:white;} .kmw-osk-none{padding:4px 6px 6px} -.kmw-osk-none:before{content:'Installing keyboard...';} +.kmw-osk-none::before{content:'Installing keyboard...';} /* OSK language menu styles */ #kmw-language-menu{position:fixed;left:0;width:232px;max-width:232px;z-index:10004;background-color:rgba(128,128,128,1); @@ -619,7 +772,7 @@ div.android div.kmw-keytip-cap { border:3px solid #ad4a28;border-radius:8px;text-align:center;padding:0px;background:white;} .kmw-alert-close{float:right; height:24px; width:24px; font:1em bold Arial,sans-serif;color:#ad4a28;} /*.kmw-alert-close{float:right; height:24px; width:24px; font:2em bold Arial,sans-serif;color:#ad4a28;} */ -.kmw-alert-close:before{content:'\00d7'} +.kmw-alert-close::before{content:'\00d7'} /*.kmw-alert-close{float:right;background:url('icons.gif') no-repeat -30px 0; height:13px; width:15px;}*/ .kmw-wait-text{clear:both; margin:4px;white-space:nowrap;} .kmw-wait-graphic{width:100%;min-height:19px;background:url('ajax-loader.gif') no-repeat;background-position:center top;} diff --git a/web/src/test/manual/web/prediction-mtnt/index.html b/web/src/test/manual/web/prediction-mtnt/index.html index 10e277cfd6..34f43f3c5b 100644 --- a/web/src/test/manual/web/prediction-mtnt/index.html +++ b/web/src/test/manual/web/prediction-mtnt/index.html @@ -48,9 +48,12 @@ kmw.addKeyboards({id:'gesture_prototyping',name:'Gesture prototyping',languages:{id:'en',name:'English'}, filename:('../keyboards/gesture_prototyping/build/gesture_prototyping.js')}); - var pageRef = (window.location.protocol == 'file:') - ? window.location.href.substr(0, window.location.href.lastIndexOf('/')+1) - : window.location.href; + // Ensure the URL we prefix to the page's path is the page's directory, not including + // the actual HTML page itself. + var urlIncludesIndex = window.location.href.lastIndexOf('.html') > 1; + var pageRef = (urlIncludesIndex + ? window.location.href.substr(0, window.location.href.lastIndexOf('/')) + : window.location.href) + '/'; var modelStub = {'id': 'nrc.en.mtnt', languages: ['en'], diff --git a/web/src/test/manual/web/prediction-ui/index.html b/web/src/test/manual/web/prediction-ui/index.html index e7cfc76101..84d08cbb87 100644 --- a/web/src/test/manual/web/prediction-ui/index.html +++ b/web/src/test/manual/web/prediction-ui/index.html @@ -46,9 +46,12 @@ kmw.addKeyboards({id:'obolo_chwerty_6351',name:'obolo_chwerty_6351',languages:{id:'en',name:'English'}, filename:('../obolo_chwerty_6351.js')}); - var pageRef = (window.location.protocol == 'file:') - ? window.location.href.substr(0, window.location.href.lastIndexOf('/')+1) - : window.location.href; + // Ensure the URL we prefix to the page's path is the page's directory, not including + // the actual HTML page itself. + var urlIncludesIndex = window.location.href.lastIndexOf('.html') > 1; + var pageRef = (urlIncludesIndex + ? window.location.href.substr(0, window.location.href.lastIndexOf('/')) + : window.location.href) + '/'; var modelStub = {'id': 'example.en.trie', languages: ['en'], diff --git a/web/src/tools/testing/bulk_rendering/README.md b/web/src/tools/testing/bulk_rendering/README.md index 2f9703d1c7..3c062a2658 100644 --- a/web/src/tools/testing/bulk_rendering/README.md +++ b/web/src/tools/testing/bulk_rendering/README.md @@ -12,8 +12,9 @@ This renderer loads all the cloud keyboards from api.keyman.com and renders each - Note that it is preferable to run this on an actual device if possible. - If no prompt is given re: screensharing when you click the 'run' button, use Chrome's Developer Tools on a desktop or laptop to run this via emulation instead. - - If emulating a mobile device, when prompted to screenshare, be sure to share _the Chrome tab_. The screen capture - system will fail to capture the OSK properly otherwise. + - If emulating a mobile device, when prompted to screenshare... + - Be sure to share _the Chrome tab_. The screen capture system will fail to capture the OSK properly otherwise. + - Also verify that emulation zoom is set to 100%; the system will fail to capture the OSK properly otherwise. 6. Save the result to a .html file, either before.html or after.html. 7. When swapping versions, don't forget to rebuild. diff --git a/web/src/tools/testing/bulk_rendering/index.html b/web/src/tools/testing/bulk_rendering/index.html index 1b94f02c65..28350cbddd 100644 --- a/web/src/tools/testing/bulk_rendering/index.html +++ b/web/src/tools/testing/bulk_rendering/index.html @@ -27,7 +27,7 @@ - +