chore(web): Merge branch 'master' into fix/web/legacy-kbd-modifier-tracking

This commit is contained in:
Joshua A. Horton 2024-01-22 08:55:01 +07:00
commit 010f0b9eb5
51 changed files with 1860 additions and 670 deletions

View file

@ -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)

View file

@ -1 +1 @@
17.0.246
17.0.249

View file

@ -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

View file

@ -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+"\""

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -33,6 +33,11 @@ export class GestureRecognizer<HoveredItemType, StateToken = any> 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();

View file

@ -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;

View file

@ -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 */

View file

@ -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 <https://www.unicode.org/reports/tr18/#Hex_notation>
* @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 <https://www.unicode.org/reports/tr18/#Hex_notation>
* @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 {

View file

@ -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

View file

@ -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) {

View file

@ -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
}
}
}

View file

@ -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_num> marker_list;
/** map from following-char to marker numbers. */
typedef std::map<char32_t, marker_list> marker_map;
/** map from one char to one entry */
typedef std::pair<char32_t, marker_num> marker_entry;
/** map from following-char to marker numbers, in front to back order */
typedef std::deque<marker_entry> 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

View file

@ -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

View file

@ -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

View file

@ -16,7 +16,7 @@
<!-- Note: displays is only used for keycap presentation -->
<!-- this example is not required for this keyboard as we use the spacing
modifiers -->
<display output="\u0300" display="\u02CB" /> <!-- display combining grave as modifier letter
<display output="\u{0300}" display="\u{02CB}" /> <!-- display combining grave as modifier letter
grave ˋ -->
<!-- Note: We discussed why the existing displayMap was used for display.
rather than adding something closer to the key layout. 1. This way we can

View file

@ -35,12 +35,12 @@
<transforms type="simple">
<transformGroup>
<!-- from the spec: nod-Lana for ᩅᩫ᩶ -->
<reorder from="\u1A60" order="127" />
<reorder from="\u1A6B" order="42" />
<reorder from="[\u1A75-\u1A79]" order="55" />
<reorder before="\u1A6B" from="\u1A60\u1A45" order="10" />
<reorder before="\u1A6B[\u1A75-\u1A79]" from="\u1A60\u1A45" order="10" />
<reorder before="\u1A6B" from="\u1A60[\u1A75-\u1A79]\u1A45" order="10 55 10" />
<reorder from="\u{1A60}" order="127" />
<reorder from="\u{1A6B}" order="42" />
<reorder from="[\u{1A75}-\u{1A79}]" order="55" />
<reorder before="\u{1A6B}" from="\u{1A60}\u{1A45}" order="10" />
<reorder before="\u{1A6B}[\u{1A75}-\u{1A79}]" from="\u{1A60}\u{1A45}" order="10" />
<reorder before="\u{1A6B}" from="\u{1A60}[\u{1A75}-\u{1A79}]\u{1A45}" order="10 55 10" />
</transformGroup>
</transforms>
</keyboard3>

View file

@ -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 <iostream>
#include <string>
#include <test_assert.h>
#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++;
}
}

View file

@ -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

View file

@ -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<T extends TransformCompilerType, TranBas
// add in markers. idempotent if no markers.
cookedFrom = sections.vars.substituteMarkerString(cookedFrom, true);
// don't unescape literals here, because we are going to pass them through into the regex
cookedFrom = util.unescapeStringToRegex(cookedFrom);
// cookedFrom is cooked above, since there's some special treatment
result.from = sections.strs.allocString(cookedFrom, {
unescape: true
unescape: false,
}, sections);
// 'to' is handled via allocString
result.to = sections.strs.allocString(transform.to, {

View file

@ -154,7 +154,7 @@ block(bksp)
00 00 00 00 # KMX_DWORD index = 0
# transforms 0
index(strNull,strElemTranFrom1b,2) # KMX_DWORD str from ^e
index(strNull,strElemTranFrom1b,2) # KMX_DWORD str from ^e ## << ??
index(strNull,strNull,2) # KMX_DWORD str to 0
index(strNull,strNull,2) # KMX_DWORD str mapFrom 0
index(strNull,strNull,2) # KMX_DWORD str mapTo 0
@ -493,6 +493,7 @@ block(strs) # struct COMP_KMXPLUS_STRS {
diff(strs,strFromSet) sizeof(strFromSet,2)
diff(strs,strUSet) sizeof(strUSet,2)
diff(strs,strAmarker) sizeof(strAmarker,2)
diff(strs,strSentinel0001r) sizeof(strSentinel0001r,2)
diff(strs,strElemTranFrom1) sizeof(strElemTranFrom1,2)
diff(strs,strElemTranFrom1a) sizeof(strElemTranFrom1a,2)
diff(strs,strElemTranFrom1b) sizeof(strElemTranFrom1b,2)
@ -516,7 +517,6 @@ block(strs) # struct COMP_KMXPLUS_STRS {
diff(strs,strKeys) sizeof(strKeys,2)
diff(strs,strIndicator) sizeof(strIndicator,2)
diff(strs,strSentinel0001) sizeof(strSentinel0001,2)
diff(strs,strSentinel0001r) sizeof(strSentinel0001r,2)
# String table -- block(x) is used to store the null u16char at end of each string
# without interfering with sizeof() calculation above
@ -528,6 +528,7 @@ block(strs) # struct COMP_KMXPLUS_STRS {
block(strFromSet) 5B 00 5C 00 75 00 31 00 41 00 37 00 35 00 2D 00 5C 00 75 00 31 00 41 00 37 00 39 00 5D 00 block(x) 00 00 # [\u1a75-\u1a79]
block(strUSet) 5b 00 61 00 62 00 63 00 5d 00 block(x) 00 00 # '[abc]'
block(strAmarker) 5C 00 6D 00 7B 00 61 00 7D 00 block(x) 00 00 # '\m{a}'
block(strSentinel0001r) 5c 00 75 00 66 00 66 00 66 00 66 00 5c 00 75 00 30 00 30 00 30 00 38 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(strElemTranFrom1) 5E 00 block(x) 00 00 # '^'
block(strElemTranFrom1a) 5E 00 61 00 block(x) 00 00 # '^a'
block(strElemTranFrom1b) 5E 00 65 00 block(x) 00 00 # '^e'
@ -556,7 +557,6 @@ block(strs) # struct COMP_KMXPLUS_STRS {
# <reorder before="ᩫ" from="᩠᩵ᩅ" order="10 55 10" />
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

View file

@ -48,7 +48,7 @@
<transformGroup>
<!-- Northern Thai example from spec -->
<reorder before="\u{1A6B}" from="\u{1A60}[\u1A75-\u1A79]\u{1A45}" order="10 55 10" />
<reorder before="\u{1A6B}" from="\u{1A60}[\u{1A75}-\u{1A79}]\u{1A45}" order="10 55 10" />
</transformGroup>
</transforms>

View file

@ -8,12 +8,12 @@
<transforms type="simple">
<transformGroup>
<!-- from the spec: nod-Lana for ᩅᩫ᩶ -->
<reorder from="\u1A60" order="127" />
<reorder from="\u1A6B" order="42" />
<reorder from="[\u1A75-\u1A79]" order="55" />
<reorder before="\u1A6B" from="\u1A60\u1A45" order="10" />
<reorder before="\u1A6B[\u1A75-\u1A79]" from="\u1A60\u1A45" order="10" />
<reorder before="\u1A6B" from="\u1A60[\u1A75-\u1A79]\u1A45" order="10 55 10" />
<reorder from="\u{1A60}" order="127" />
<reorder from="\u{1A6B}" order="42" />
<reorder from="[\u{1A75}-\u{1A79}]" order="55" />
<reorder before="\u{1A6B}" from="\u{1A60}\u{1A45}" order="10" />
<reorder before="\u{1A6B}[\u{1A75}-\u{1A79}]" from="\u{1A60}\u{1A45}" order="10" />
<reorder before="\u{1A6B}" from="\u{1A60}[\u{1A75}-\u{1A79}]\u{1A45}" order="10 55 10" />
</transformGroup>
</transforms>
</keyboard3>

View file

@ -4,7 +4,7 @@
@@keys: [K_Q][K_W][K_Q]
@@expected: \u0127\u1790\u17B6\u0127
-->
<!DOCTYPE keyboard3 SYSTEM "../../../../../resources/standards-data/ldml-keyboards/techpreview/dtd/ldmlKeyboard3.dtd">
<!DOCTYPE keyboard3 SYSTEM "../../../../../../../resources/standards-data/ldml-keyboards/techpreview/dtd/ldmlKeyboard3.dtd">
<keyboard3 locale="mt" conformsTo="techpreview">
<version number="1.0.0" />
@ -46,7 +46,7 @@
<transformGroup>
<!-- Northern Thai example from spec -->
<reorder before="\u{1A6B}" from="\u{1A60}[\u1A75-\u1A79]\u{1A45}" order="10 55 10" />
<reorder before="\u{1A6B}" from="\u{1A60}[\u{1A75}-\u{1A79}]\u{1A45}" order="10 55 10" />
</transformGroup>
</transforms>

View file

@ -4,7 +4,7 @@
@@keys: [K_Q][K_W][K_Q]
@@expected: \u0127\u1790\u17B6\u0127
-->
<!DOCTYPE keyboard3 SYSTEM "../../../../../resources/standards-data/ldml-keyboards/techpreview/dtd/ldmlKeyboard3.dtd">
<!DOCTYPE keyboard3 SYSTEM "../../../../../../../resources/standards-data/ldml-keyboards/techpreview/dtd/ldmlKeyboard3.dtd">
<keyboard3 locale="mt" conformsTo="techpreview">
<version number="1.0.0" />

View file

@ -4,7 +4,7 @@
@@keys: [K_Q][K_W][K_Q]
@@expected: \u0127\u1790\u17B6\u0127
-->
<!DOCTYPE keyboard3 SYSTEM "../../../../../resources/standards-data/ldml-keyboards/techpreview/dtd/ldmlKeyboard3.dtd">
<!DOCTYPE keyboard3 SYSTEM "../../../../../../../resources/standards-data/ldml-keyboards/techpreview/dtd/ldmlKeyboard3.dtd">
<keyboard3 locale="mt" conformsTo="techpreview">
<version number="1.0.0" />
@ -46,7 +46,7 @@
<transformGroup>
<!-- Northern Thai example from spec -->
<reorder before="\u{1A6B}" from="\u{1A60}[\u1A75-\u1A79]\u{1A45}" order="10 55 10" />
<reorder before="\u{1A6B}" from="\u{1A60}[\u{1A75}-\u{1A79}]\u{1A45}" order="10 55 10" />
</transformGroup>
</transforms>

View file

@ -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()
}

View file

@ -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")")
}

View file

@ -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) {

View file

@ -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

View file

@ -129,7 +129,7 @@
<transforms type="simple">
<transformGroup>
<transform from="\u{09C7}\m{A}" to="\u{09CC}" /> <!-- E + lengthener = AU -->
<!-- <transform from="\u{09C7}\u{09BE}" to="\u09CB" /> --> <!-- E + O = O This is handled by normalization. -->
<!-- <transform from="\u{09C7}\u{09BE}" to="\u{09CB}" /> --> <!-- E + O = O This is handled by normalization. -->
<!-- these suport the 'q' key -->
<transform from="\m{q}:" to="\u{0983}" />

View file

@ -8,7 +8,7 @@
<info author="Team Keyboard" name="French AZERTY optimisé" layout="AZERTY" indicator="FR" />
<displays>
<display output="\u0300" display="${grave}" />
<display output="\u{0300}" display="${grave}" />
<display output="\m{acute}" display="´" />
<display output="\m{grave}" display="`" />
<display output="\m{umlaut}" display="¨" />
@ -191,9 +191,9 @@
<transformGroup>
<!-- this is a reorder group -->
<!-- nod-Lana partial example -->
<reorder from="\u1A60" order="127" />
<reorder from="\u1A6B" order="42" />
<reorder from="[\u1A75-\u1A79]" order="55" />
<reorder from="\u{1A60}" order="127" />
<reorder from="\u{1A6B}" order="42" />
<reorder from="[\u{1A75}-\u{1A79}]" order="55" />
<!-- ... partial example ... -->
</transformGroup>
</transforms>

View file

@ -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;

View file

@ -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() {

View file

@ -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<any>;
curCoord: InputSample<any>;
baseScrollLeft: number;
constructor(coord: InputSample<any>, baseScrollLeft: number) {
this.baseCoord = coord;
this.curCoord = coord;
this.baseScrollLeft = baseScrollLeft;
this.totalLength = 0;
}
updateTo(coord: InputSample<any>): 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;
}
}

View file

@ -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() {
}
}

View file

@ -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<BannerSuggestion>;
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<SuggestionInputEventMap>(); //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<SuggestionBanner.SUGGESTION_LIMIT; i++) {
let d = new BannerSuggestion(i, rtl);
this.options[i] = d;
@ -196,44 +443,58 @@ export class SuggestionBanner extends Banner {
* for visuals/UI while still being internally LTR.
*/
for (var i=0; i<SuggestionBanner.SUGGESTION_LIMIT; i++) {
let indexToInsert = rtl ? SuggestionBanner.SUGGESTION_LIMIT - i -1 : i;
this.getDiv().appendChild(this.options[indexToInsert].div);
let indexToInsert = rtl ? SuggestionBanner.SUGGESTION_LIMIT - i - 1 : i;
this.container.appendChild(this.options[indexToInsert].div);
if(i != SuggestionBanner.SUGGESTION_LIMIT) {
// RTL should start right-aligned, thus @ max scroll.
if(rtl) {
this.container.scrollLeft = this.container.scrollWidth;
}
if(i != SuggestionBanner.SUGGESTION_LIMIT - 1) {
// Adds a 'separator' div element for UI purposes.
let separatorDiv = createUnselectableElement('div');
separatorDiv.className = 'kmw-banner-separator';
let ds = separatorDiv.style;
ds.marginLeft = (SuggestionBanner.MARGIN / 2) + '%';
ds.marginRight = (SuggestionBanner.MARGIN / 2) + '%';
ds.marginLeft = `calc(${(SuggestionBanner.MARGIN / 2)}% - 0.5px)`;
ds.marginRight = `calc(${(SuggestionBanner.MARGIN / 2)}% - 0.5px)`;
this.getDiv().appendChild(separatorDiv);
this.container.appendChild(separatorDiv);
// Ensure the separators are maintained in the same order as the
// suggestion elements!
this.separators[indexToInsert - (rtl ? 1 : 0)] = separatorDiv;
}
}
}
private setupInputHandling(): GestureRecognizer<BannerSuggestion> {
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<BannerSuggestion> = {
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<BannerSuggestion>,
roamingHighlightHandler: (sample: InputSample<BannerSuggestion>) => void,
scrollingHandler: (sample: InputSample<BannerSuggestion>) => 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<SuggestionBanner.SUGGESTION_LIMIT; i++) {
const d = this.options[i];
if(suggestions.length > 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;
}
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();
};
}

View file

@ -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;
}

View file

@ -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.

View file

@ -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);

View file

@ -407,6 +407,20 @@ export default class VisualKeyboard extends EventEmitter<EventMap> 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<GestureSequence<KeyElement>, GestureHandler[]>();
// Now to set up event-handling links.
@ -612,8 +626,11 @@ export default class VisualKeyboard extends EventEmitter<EventMap> 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<EventMap> 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<EventMap> 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<EventMap> 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());

View file

@ -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;}

View file

@ -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'],

View file

@ -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'],

View file

@ -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.

View file

@ -27,7 +27,7 @@
<!-- Insert unminified KeymanWeb source scripts -->
<!--<script src="https://s.keyman.com/kmw/engine/14.0.227/keymanweb.js" type="application/javascript"></script>-->
<script src="../../../../build/publish/debug/keymanweb.js" type="application/javascript"></script>
<script src="../../../../build/tools/testing/bulk_rendering/bulk_render.js" type="application/javascript"></script>
<script src="../../../../build/tools/testing/bulk_rendering/lib/bulk_render.js" type="application/javascript"></script>
<!-- Initialization: set paths to keyboards, resources and fonts as required -->
<script>

View file

@ -1,5 +1,6 @@
// Includes KeymanWeb's Device class, as it's quite a useful resource for KMW-related projects.
import { Device } from 'keyman/engine/device-detect';
import { type DeviceSpec } from '@keymanapp/web-utils';
import type { KeymanEngine } from 'keyman/app/browser';
import type { FloatingOSKView } from 'keyman/engine/osk';
@ -28,7 +29,10 @@ export class BatchRenderer {
width: screen.width,
height: screen.height,
frameRate: 60,
}
},
// Chrome 96+.
// https://stackoverflow.com/a/70665376
preferCurrentTab: true
});
} catch(err) {
console.error("Error: " + err);
@ -95,7 +99,7 @@ export class BatchRenderer {
return capture();
}
createKeyboardHeader(kbd: KeyboardData, loaded: boolean): HTMLDivElement {
createKeyboardHeader(kbd: KeyboardData, formFactor: DeviceSpec.FormFactor, loaded: boolean): HTMLDivElement {
let divHeader = document.createElement('div');
let eleName = document.createElement('h2');
@ -108,7 +112,20 @@ export class BatchRenderer {
eleDescription.appendChild(document.createTextNode('Name: ' + kbd.Name));
eleDescription.appendChild(document.createElement('br'));
eleDescription.appendChild(document.createTextNode('Font: ' + keyman.core.activeKeyboard['_legacyLayoutSpec'].F));
const keyboard = keyman.core.activeKeyboard;
// Some keyboards, such as sil_euro_latin and sil_ipa, no longer specify this property.
if(keyboard['_legacyLayoutSpec']) {
eleDescription.appendChild(document.createTextNode('Font: ' + keyboard['_legacyLayoutSpec'].F));
} else {
// They instead specify only the post-KMW-10 touch-layout format.
const layout = keyboard.layout(formFactor);
if(!layout) {
eleDescription.appendChild(document.createTextNode('Keyboard is help-text based'));
} else {
eleDescription.appendChild(document.createTextNode('Font: ' + layout.font));
}
}
} else {
eleDescription.appendChild(document.createTextNode('Unable to load this keyboard!'));
@ -121,7 +138,8 @@ export class BatchRenderer {
private processKeyboard(kbd: KeyboardData) {
let p: Promise<any> = keyman.setActiveKeyboard(kbd.InternalName);
let isMobile = keyman.config.hostDevice.formFactor != 'desktop';
const formFactor = keyman.config.hostDevice.formFactor;
let isMobile = formFactor != 'desktop';
// Establish common keyboard header info.
let divSummary = document.createElement('div');
@ -143,30 +161,35 @@ export class BatchRenderer {
const screenOffsetY = window.outerHeight - window.innerHeight;
BatchRenderer.boundingRect.y += screenOffsetY;
divSummary.appendChild(renderer.createKeyboardHeader(kbd, true));
divSummary.appendChild(renderer.createKeyboardHeader(kbd, formFactor, true));
let divRenders = document.createElement('div');
divSummary.appendChild(divRenders);
// Uses 'private' APIs that may be subject to change in the future. Keep it updated!
var layers;
if(isMobile) {
layers = keyman.osk.vkbd.layerGroup.layers;
} else {
if(!isMobile) {
// The desktop OSK will be overpopulated, with a number of blank layers to display in most cases.
// We instead rely upon the KLS definition to ensure we keep the renders sparse.
//
// _legacyLayoutSpec is technically private, but it's what we've been using, so... yeah.
layers = keyman.core.activeKeyboard['_legacyLayoutSpec'].KLS;
layers = keyman.core.activeKeyboard['_legacyLayoutSpec']?.KLS;
}
// If mobile, or if the desktop definition lacks a `_legacyLayoutSpec` entry.
// Note: vkbd will be null for keyboards with desktop help-text, such as sil_euro_latin.
layers = layers || keyman.osk.vkbd?.layerGroup.layers;
let renderLayer = function(i: number) {
return new Promise(function(resolve) {
// (Private API) Directly sets the keyboard layer within KMW, then uses .show to force-display it.
if(keyman.osk.vkbd) {
keyman.core.keyboardProcessor.layerId = Object.keys(layers)[i];
} else {
console.error("Error - keyman.osk.vkbd is undefined!");
// Again, occurs for keyboards with desktop help-text.
console.error(`Error - keyman.osk.vkbd is undefined for ${kbd.InternalName}!`);
// Will otherwise crash the renderer later.
return;
}
// Make sure the active element's still set!
renderer.setActiveDummy();
@ -192,10 +215,11 @@ export class BatchRenderer {
};
// The resulting Promise will only call it's `.then()` once all of this keyboard's renders have been completed.
return renderer.arrayPromiseIteration(renderLayer, Object.keys(layers).length);
}).catch(function() {
console.log("Failed to load the \"" + kbd.InternalName + "\" keyboard for rendering!");
divSummary.appendChild(renderer.createKeyboardHeader(kbd, false));
return renderer.arrayPromiseIteration(renderLayer, layers ? Object.keys(layers).length : 0);
}).catch(function(err) {
console.error("Failed to load the \"" + kbd.InternalName + "\" keyboard for rendering:");
console.error(err);
divSummary.appendChild(renderer.createKeyboardHeader(kbd, formFactor, false));
return Promise.resolve();
});
}
@ -293,5 +317,7 @@ export class BatchRenderer {
}
(function(){
// BatchRenderer's internal state stuff is static on the class and is not exposed with
// the line below.
window['kmw_renderer'] = new BatchRenderer();
})();