Skip to content

Commit

Permalink
Merge pull request #11177 from keymanapp/feat/web/vkbd-layout-optimiz…
Browse files Browse the repository at this point in the history
…ation

feat(web): VisualKeyboard layout-reflow optimization 🪠
  • Loading branch information
jahorton authored Apr 12, 2024
2 parents 50b261f + 573ffd8 commit 68f6a24
Show file tree
Hide file tree
Showing 8 changed files with 309 additions and 201 deletions.
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.

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

// 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 {
// 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

0 comments on commit 68f6a24

Please sign in to comment.