Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web): VisualKeyboard layout-reflow optimization 🪠 #11177

Merged
merged 8 commits into from
Apr 12, 2024
20 changes: 10 additions & 10 deletions web/src/engine/osk/src/input/gestures/browser/keytip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { KeyElement } from '../../../keyElement.js';
import KeyTipInterface from '../../../keytip.interface.js';
import VisualKeyboard from '../../../visualKeyboard.js';
import { GesturePreviewHost } from '../../../keyboard-layout/gesturePreviewHost.js';
import { ParsedLengthStyle } from '../../../lengthStyle.js';

const CSS_PREFIX = 'kmw-';
const DEFAULT_TIP_ORIENTATION: PhoneKeyTipOrientation = 'top';
Expand Down Expand Up @@ -111,16 +112,15 @@ export default class KeyTip implements KeyTipInterface {
let canvasWidth = xWidth + Math.ceil(xWidth * 0.3) * 2;
let canvasHeight = Math.ceil(2.3 * xHeight) + (ySubPixelPadding); //

kts.top = 'auto';
if(orientation == 'bottom') {
y += canvasHeight - xHeight;
}

kts.top = 'auto';
const unselectedOrientation = orientation == 'top' ? 'bottom' : 'top';
this.tip.classList.remove(`${CSS_PREFIX}${unselectedOrientation}`);
this.tip.classList.add(`${CSS_PREFIX}${orientation}`);

if(orientation == 'bottom') {
y += canvasHeight - xHeight;
}

kts.bottom = Math.floor(_BoxRect.height - y) + 'px';
kts.textAlign = 'center';
kts.overflow = 'visible';
Expand All @@ -141,14 +141,14 @@ export default class KeyTip implements KeyTipInterface {
}

if(px != 0) {
let popupFS = previewFontScale * px;
let scaleStyle = {
fontFamily: kts.fontFamily,
fontSize: popupFS + 'px',
height: 1.6 * xHeight + 'px' // as opposed to the canvas height of 2.3 * xHeight.
keyWidth: 1.6 * xWidth,
keyHeight: 1.6 * xHeight, // as opposed to the canvas height of 2.3 * xHeight.
baseEmFontSize: vkbd.getKeyEmFontSize(),
layoutFontSize: new ParsedLengthStyle(vkbd.kbdDiv.style.fontSize)
};

kts.fontSize = key.key.getIdealFontSize(vkbd, key.key.keyText, scaleStyle, true);
kts.fontSize = key.key.getIdealFontSize(key.key.keyText, scaleStyle, previewFontScale).styleString;
}

// Adjust shape if at edges
Expand Down
37 changes: 15 additions & 22 deletions web/src/engine/osk/src/keyboard-layout/oskBaseKey.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { ActiveKey, Codes, DeviceSpec } from '@keymanapp/keyboard-processor';
import { landscapeView } from 'keyman/engine/dom-utils';

import OSKKey, { renameSpecialKey } from './oskKey.js';
import OSKKey, { KeyLayoutParams, renameSpecialKey } from './oskKey.js';
import { KeyData, KeyElement, link } from '../keyElement.js';
import OSKRow from './oskRow.js';
import VisualKeyboard from '../visualKeyboard.js';
import { ParsedLengthStyle } from '../lengthStyle.js';
import { GesturePreviewHost } from './gesturePreviewHost.js';


export default class OSKBaseKey extends OSKKey {
private capLabel: HTMLDivElement;
private previewHost: GesturePreviewHost;
Expand Down Expand Up @@ -197,29 +196,23 @@ export default class OSKBaseKey extends OSKKey {
this.btn.replaceChild(this.preview, oldPreview);
}

public refreshLayout(vkbd: VisualKeyboard) {
let key = this.spec as ActiveKey;
this.square.style.width = vkbd.layoutWidth.scaledBy(key.proportionalWidth).styleString;
this.square.style.marginLeft = vkbd.layoutWidth.scaledBy(key.proportionalPad).styleString;
this.btn.style.width = vkbd.usesFixedWidthScaling ? this.square.style.width : '100%';
public refreshLayout(layoutParams: KeyLayoutParams) {
const keyTextClosure = super.refreshLayout(layoutParams); // key labels in particular.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why 'keyTextClosure'? It's really unclear what that does! Perhaps we should call it superFinalization to clarify that it's more work that super.refreshlayout has to do once this function is finished?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why 'keyTextClosure'?

Because it's a closure to finalize/apply layout changes directly tied to key text as seen in OSKKey.

Perhaps we should call it superFinalization [...]

If I swapped to superFinalization or something else unrelated to the code-contents of the closure... well, I suppose I can always just add a comment about what the closure's actual effects are.


if(vkbd.usesFixedHeightScaling) {
// Matches its row's height.
this.square.style.height = vkbd.internalHeight.scaledBy(this.row.heightFraction).styleString;
} else {
this.square.style.height = '100%'; // use the full row height
}
return () => {
// part 2: key internals - these do depend on recalculating internal layout.

super.refreshLayout(vkbd);
// Ideally, the rest would be in yet another calculation layer... need to figure out a good design for this.
keyTextClosure(); // we're already in that phase, so go ahead and run it.

const device = vkbd.device;
const resizeLabels = (device.OS == DeviceSpec.OperatingSystem.iOS &&
device.formFactor == DeviceSpec.FormFactor.Phone
&& landscapeView());

// Rescale keycap labels on iPhone (iOS 7)
if(resizeLabels && this.capLabel) {
this.capLabel.style.fontSize = '6px';
const emFont = layoutParams.baseEmFontSize;
// Rescale keycap labels on small phones
if(emFont.val < 12) {
this.capLabel.style.fontSize = '6px';
} else {
// The default value set within kmwosk.css.
this.capLabel.style.fontSize = ParsedLengthStyle.forScalar(0.5).styleString;
}
}
}

Expand Down
170 changes: 90 additions & 80 deletions web/src/engine/osk/src/keyboard-layout/oskKey.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { ActiveKey, ActiveSubKey, ButtonClass, DeviceSpec, LayoutKey } from '@keymanapp/keyboard-processor';
import { TouchLayout } from '@keymanapp/common-types';
import TouchLayoutFlick = TouchLayout.TouchLayoutFlick;
import { ActiveKey, ActiveSubKey, ButtonClass, ButtonClasses, DeviceSpec } from '@keymanapp/keyboard-processor';

// At present, we don't use @keymanapp/keyman. Just `keyman`. (Refer to <root>/web/package.json.)
import { getAbsoluteX, getAbsoluteY } from 'keyman/engine/dom-utils';

import { getFontSizeStyle } from '../fontSizeUtils.js';
import specialChars from '../specialCharacters.js';
import buttonClassNames from '../buttonClassNames.js';

import { KeyElement } from '../keyElement.js';
import VisualKeyboard from '../visualKeyboard.js';
import { getTextMetrics } from './getTextMetrics.js';
import { ParsedLengthStyle } from '../lengthStyle.js';

export interface KeyLayoutParams {
keyWidth: number;
keyHeight: number;
baseEmFontSize: ParsedLengthStyle;
layoutFontSize: ParsedLengthStyle;
}

/**
* Replace default key names by special font codes for modifier keys
Expand Down Expand Up @@ -59,6 +62,9 @@ export default abstract class OSKKey {
label: HTMLSpanElement;
square: HTMLDivElement;

private _fontSize: ParsedLengthStyle;
private _fontFamily: string;

/**
* The layer of the OSK on which the key is displayed.
*/
Expand Down Expand Up @@ -169,58 +175,54 @@ export default abstract class OSKKey {

/**
* Calculate the font size required for a key cap, scaling to fit longer text
* @param vkbd
* @param text
* @param style specification for the desired base font size
* @param override if true, don't use the font spec from the button, just use the passed in spec
* @param layoutParams specification for the key
* @param scale additional scaling to apply for the font-size calculation (used by keytips)
* @returns font size as a style string
*/
getIdealFontSize(vkbd: VisualKeyboard, text: string, style: {height?: string, fontFamily?: string, fontSize: string}, override?: boolean): string {
let buttonStyle: typeof style & {width?: string} = getComputedStyle(this.btn);
let keyWidth = parseFloat(buttonStyle.width);
let emScale = vkbd.getKeyEmFontSize();
getIdealFontSize(text: string, layoutParams: KeyLayoutParams, scale?: number): ParsedLengthStyle {
// Fallback in case not all style info is currently ready.
if(!this._fontFamily) {
return new ParsedLengthStyle('1em');
}

scale ??= 1;

const keyWidth = layoutParams.keyWidth;
const keyHeight = layoutParams.keyHeight;
const emScale = layoutParams.baseEmFontSize.scaledBy(layoutParams.layoutFontSize.val);

// Among other things, ensures we use SpecialOSK styling for special key text.
// It's set on the key-span, not on the button.
//
// Also helps ensure that the stub's font-family name is used for keys, should
// that mismatch the font-family name specified within the keyboard's touch layout.
const capFont = !this.label ? undefined: getComputedStyle(this.label).fontFamily;
if(capFont) {
buttonStyle = {
fontFamily: capFont,
fontSize: buttonStyle.fontSize,
height: buttonStyle.height
}
}

const originalSize = getFontSizeStyle(style.fontSize || '1em');
let originalSize = this._fontSize;
if(!originalSize.absolute) {
originalSize = emScale.scaledBy(originalSize.val);
}

// Not yet available; it'll be handled in a later layout pass.
if(!override) {
// When available, just use computedStyle instead.
style = buttonStyle;
const style = {
fontFamily: this._fontFamily,
fontSize: originalSize.styleString,
height: layoutParams.keyHeight
}

let fontSpec = getFontSizeStyle(style.fontSize || '1em');
let metrics = getTextMetrics(text, emScale, style);
let metrics = getTextMetrics(text, emScale.scaledBy(scale).val, style);

const MAX_X_PROPORTION = 0.90;
const MAX_Y_PROPORTION = 0.90;
const X_PADDING = 2;

var fontHeight: number, keyHeight: number;
var fontHeight: number;
if(metrics.fontBoundingBoxAscent) {
fontHeight = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent;
}

// Don't add extra padding to height - multiplying with MAX_Y_PROPORTION already gives
// padding
let textHeight = fontHeight ?? 0;
if(style.height && style.height.indexOf('px') != -1) {
keyHeight = Number.parseFloat(style.height.substring(0, style.height.indexOf('px')));
}

let xProportion = (keyWidth * MAX_X_PROPORTION) / (metrics.width + X_PADDING); // How much of the key does the text want to take?
let yProportion = textHeight && keyHeight ? (keyHeight * MAX_Y_PROPORTION) / textHeight : undefined;

Expand All @@ -229,28 +231,10 @@ export default abstract class OSKKey {
proportion = yProportion;
}

// Never upscale keys past the default - only downscale them.
// Never upscale keys past the default * the specified scale - only downscale them.
// Proportion < 1: ratio of key width to (padded [loosely speaking]) text width
// maxProportion determines the 'padding' involved.
//
if(proportion < 1) {
if(originalSize.absolute) {
return proportion * fontSpec.val + 'px';
} else {
return proportion * originalSize.val + 'em';
}
} else {
if(originalSize.absolute) {
return fontSpec.val + 'px';
} else {
return originalSize.val + 'em';
}
}
}

getKeyWidth(vkbd: VisualKeyboard): number {
let key = this.spec as ActiveKey;
return key.proportionalWidth * vkbd.width;
return ParsedLengthStyle.forScalar(scale * Math.min(proportion, 1));
}

public get keyText(): string {
Expand Down Expand Up @@ -310,47 +294,73 @@ export default abstract class OSKKey {
}

// Check the key's display width - does the key visualize well?
let emScale = vkbd.getKeyEmFontSize();
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.

// Disabled by mcdurdin 2020-10-19; dotted circle display is inconsistent on iOS/Safari
// at least and doesn't combine with diacritic marks. For consistent display, it may be
// necessary to build a custom font that does not depend on renderer choices for base
// mark display -- e.g. create marks with custom base included, potentially even on PUA
// code points and use those in rendering the OSK. See #3039 for more details.
// keyText = '\u25cc' + keyText;

if(vkbd.isRTL) {
// Add the RTL marker to ensure it displays properly.
keyText = '\u200f' + keyText;
}
if(vkbd.isRTL) {
// Add the RTL marker to ensure it displays properly.
keyText = '\u200f' + keyText;
}

ts.fontSize = this.getIdealFontSize(vkbd, keyText, styleSpec);

// Finalize the key's text.
t.innerText = keyText;

return t;
}

public refreshLayout(vkbd: VisualKeyboard) {
public resetFontPrecalc() {
this._fontFamily = undefined;
this._fontSize = undefined;
this.label.style.fontSize = '';
}

public refreshLayout(layoutParams: KeyLayoutParams) {
// Avoid doing any font-size related calculations if there's no text to display.
if(this.spec.sp == ButtonClasses.spacer || this.spec.sp == ButtonClasses.blank) {
return () => {};
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be better to return null and then in the caller, use closure?.() to make the call: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining#optional_chaining_with_function_calls


// Attempt to detect static but key-specific style properties if they haven't yet
// been detected.
if(this._fontFamily === undefined) {
const lblStyle = getComputedStyle(this.label);

// Abort if the element is not currently in the DOM; we can't get any info this way.
if(!lblStyle.fontFamily) {
return () => {};
}
this._fontFamily = lblStyle.fontFamily;

// Detect any difference in base (em) font size and that which is computed for the key itself.
const computedFontSize = new ParsedLengthStyle(lblStyle.fontSize);
const layoutFontSize = layoutParams.layoutFontSize;
if(layoutFontSize.absolute) {
// rather than just straight-up taking .layoutFontSize
this._fontSize = computedFontSize;
} else {
const baseEmFontSize = layoutParams.baseEmFontSize;
const baseFontSize = layoutFontSize.scaledBy(baseEmFontSize.val);
const localFontScaling = computedFontSize.val / baseFontSize.val;
this._fontSize = ParsedLengthStyle.forScalar(localFontScaling);
}
}

// space bar may not define the text span!
if(this.label) {
if(!this.label.classList.contains('kmw-spacebar-caption')) {
// Do not use `this.keyText` - it holds *___* codes for special keys, not the actual glyph!
const keyCapText = this.label.textContent;
this.label.style.fontSize = this.getIdealFontSize(vkbd, keyCapText, this.btn.style);
const fontSize = this.getIdealFontSize(keyCapText, layoutParams);
return () => {
this.label.style.fontSize = fontSize.styleString;
};
} else {
Comment on lines +351 to +353
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is kinda ugly and it's unclear why this is postponed and how it gets called

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the other side of that keyTextClosure you were asking about - its actual definition.

Admittedly, yeah, the keyTextClosure aspect is probably the least-clear thing in this PR. It got tricky to forward well due to the inheritance structures involved.

// Remove any custom setting placed on it before recomputing its inherited style info.
this.label.style.fontSize = '';
const fontSize = this.getIdealFontSize(vkbd, this.label.textContent, this.btn.style);

// Since the kmw-spacebar-caption version uses !important, we must specify
// it directly on the element too; otherwise, scaling gets ignored.
this.label.style.setProperty("font-size", fontSize, "important");
// Spacebar text, on the other hand, is available via this.keyText.
// Using this field helps prevent layout reflow during updates.
const fontSize = this.getIdealFontSize(this.keyText, layoutParams);

return () => {
// Since the kmw-spacebar-caption version uses !important, we must specify
// it directly on the element too; otherwise, scaling gets ignored.
this.label.style.setProperty("font-size", fontSize.styleString, "important");
};
}
}
}
Expand Down
Loading
Loading