BangleApps/apps/kbmatry/lib.js

505 lines
17 KiB
JavaScript
Raw Normal View History

const widgetUtils = require("widget_utils");
2023-06-10 15:14:04 +00:00
/**
* Attempt to lay out a set of characters in a logical way to optimize the number of buttons with the number
* of characters per button. Useful if you need to dynamically (or frequently) change your character set
* and don't want to create a layout for ever possible combination.
* @param text The text you want to parse into a character set.
* @param specials Any special buttons you want to add to the keyboard (must match hardcoded special string values)
* @returns {*[]}
*/
function createCharSet(text, specials) {
specials = specials || [];
const mandatoryExtraKeys = specials.length;
const preferredNumChars = [1, 2, 4, 6, 9, 12];
const preferredNumKeys = [4, 6, 9, 12].map(num => num - mandatoryExtraKeys);
let keyIndex = 0, charIndex = 0;
let keySpace = preferredNumChars[charIndex] * preferredNumKeys[keyIndex];
while (keySpace < text.length) {
const numKeys = preferredNumKeys[keyIndex];
const numChars = preferredNumChars[charIndex];
const nextNumKeys = preferredNumKeys[keyIndex];
const nextNumChars = preferredNumChars[charIndex];
if (numChars <= numKeys) {
charIndex++;
} else if ((text.length / nextNumChars) < nextNumKeys) {
charIndex++;
} else {
keyIndex++;
}
keySpace = preferredNumChars[charIndex] * preferredNumKeys[keyIndex];
}
const charsPerKey = preferredNumChars[charIndex];
let charSet = [];
for (let i = 0; i < text.length; i += charsPerKey) {
charSet.push(text.slice(i, i + charsPerKey)
.split(""));
}
charSet = charSet.concat(specials);
return charSet;
}
/**
* Given the width, height, padding (between chars) and number of characters that need to fit horizontally /
* vertically, this function attempts to select the largest font it can that will still fit within the bounds when
* drawing a grid of characters. Does not handle multi-letter entries well, assumes we are laying out a grid of
* single characters.
* @param width The total width available for letters (px)
* @param height The total height available for letters (px)
* @param padding The amount of space required between characters (px)
* @param gridWidth The number of characters wide the rendering is going to be
* @param gridHeight The number of characters high the rendering is going to be
* @returns {{w: number, h: number, font: string}}
*/
function getBestFont(width, height, padding, gridWidth, gridHeight) {
let font = "4x6";
let w = 4;
let h = 6;
const charMaxWidth = width / gridWidth - padding * gridWidth;
const charMaxHeight = height / gridHeight - padding * gridHeight;
if (charMaxWidth >= 6 && charMaxHeight >= 8) {
w = 6;
h = 8;
font = "6x8";
}
if (charMaxWidth >= 12 && charMaxHeight >= 16) {
w = 12;
h = 16;
font = "6x8:2";
}
if (charMaxWidth >= 12 && charMaxHeight >= 20) {
w = 12;
h = 20;
font = "12x20";
}
if (charMaxWidth >= 20 && charMaxHeight >= 20) {
font = "Vector" + Math.floor(Math.min(charMaxWidth, charMaxHeight));
const dims = g.setFont(font)
.stringMetrics("W");
w = dims.width
h = dims.height;
}
return {w, h, font};
}
/**
* Generate a set of key objects given an array of arrays of characters to make available for typing.
* @param characterArrays
* @returns {Promise<void>}
*/
function getKeys(characterArrays) {
if (Array.isArray(characterArrays)) {
return Promise.all(characterArrays.map((chars, i) => generateKeyFromChars(characterArrays, i)));
} else {
return generateKeyFromChars(characterArrays, 0);
}
}
/**
* Given a set of characters, determine whether or not this needs to be a matryoshka key, a basic key, or a special key.
* Then generate that key. If the key is a matryoshka key, we queue up the generation of its sub-keys for later to
* improve load times.
* @param chars
* @param i
* @returns {Promise<unknown>}
*/
2023-06-10 15:14:04 +00:00
function generateKeyFromChars(chars, i) {
return new Promise((resolve, reject) => {
let special;
if (!Array.isArray(chars[i]) && chars[i].length > 1) {
// If it's not an array we assume it's a string. Fingers crossed I guess, lol. Be nice to my functions!
special = chars[i];
}
const key = getKeyByIndex(chars, i, special);
if (!special) {
key.chars = chars[i];
}
if (key.chars.length > 1) {
key.pendingSubKeys = true;
key.getSubKeys = () => getKeys(key.chars);
resolve(key)
} else {
resolve(key);
}
})
}
/**
* Given a set of characters (or sets of characters) get the position and dimensions of the i'th key in that set.
* @param charSet An array where each element represents a key on the hypothetical keyboard.
* @param i The index of the key in the set you want to get dimensions for.
* @param special The special property of the key - for example "del" for a key used for deleting characters.
* @returns {{special, bord: number, pad: number, w: number, x: number, h: number, y: number, chars: *[]}}
*/
function getKeyByIndex(charSet, i, special) {
// Key dimensions
const keyboardOffsetY = 40;
const margin = 3;
const padding = 4;
const border = 2;
const gridWidth = Math.ceil(Math.sqrt(charSet.length));
const gridHeight = Math.ceil(charSet.length / gridWidth);
const keyWidth = Math.floor((g.getWidth()) / gridWidth) - margin;
const keyHeight = Math.floor((g.getHeight() - keyboardOffsetY) / gridHeight) - margin;
const gridx = i % gridWidth;
const gridy = Math.floor(i / gridWidth) % gridWidth;
const x = gridx * (keyWidth + margin);
const y = gridy * (keyHeight + margin) + keyboardOffsetY;
const w = keyWidth;
const h = keyHeight;
// internal Character spacing
const numChars = charSet[i].length;
const subGridWidth = Math.ceil(Math.sqrt(numChars));
const subGridHeight = Math.ceil(numChars / subGridWidth);
const bestFont = getBestFont(w - padding, h - padding, 0, subGridWidth, subGridHeight);
const letterWidth = bestFont.w;
const letterHeight = bestFont.h;
const totalWidth = (subGridWidth - 1) * (w / subGridWidth) + padding + letterWidth + 1;
const totalHeight = (subGridHeight - 1) * (h / subGridHeight) + padding + letterHeight + 1;
const extraPadH = (w - totalWidth) / 2;
const extraPadV = (h - totalHeight) / 2;
return {
x,
y,
w,
h,
pad : padding,
bord : border,
chars: [],
special,
subGridWidth,
subGridHeight,
extraPadH,
extraPadV,
font : bestFont.font
};
}
/**
* This is probably the most intense part of this keyboard library. If you don't do it ahead of time, it will happen
* when you call the keyboard, and it can take up to 0.5 seconds for a full alphanumeric keyboard. Depending on what
* is an acceptable user experience for you, and how many keys you are actually generating, you may choose to do this
* ahead of time and pass the result to the "input" function of this library. NOTE: This function would need to be
* called once per key set - so if you have a keyboard with a "shift" key you'd need to run it once for your base
* keyset and once for your shift keyset.
2023-06-10 15:14:04 +00:00
* @param charSets
* @returns {Promise<unknown>}
*/
function generateKeyboard(charSets) {
if (!Array.isArray(charSets)) {
// User passed a string. We will divvy it up into a real set of subdivided characters.
charSets = createCharSet(charSets, ["ok", "del", "shft"]);
}
return getKeys(charSets);
}
// Default layout
2023-06-10 15:14:04 +00:00
const defaultCharSet = [
["a", "b", "c", "d", "e", "f", "g", "h", "i"],
["j", "k", "l", "m", "n", "o", "p", "q", "r"],
["s", "t", "u", "v", "w", "x", "y", "z", "0"],
["1", "2", "3", "4", "5", "6", "7", "8", "9"],
[" ", "`", "-", "=", "[", "]", "\\", ";", "'"],
[",", ".", "/"],
"ok",
"shft",
"del"
];
// Default layout with shift pressed
2023-06-10 15:14:04 +00:00
const defaultCharSetShift = [
["A", "B", "C", "D", "E", "F", "G", "H", "I"],
["J", "K", "L", "M", "N", "O", "P", "Q", "R"],
["S", "T", "U", "V", "W", "X", "Y", "Z", ")"],
["!", "@", "#", "$", "%", "^", "&", "*", "("],
["~", "_", "+", "{", "}", "|", ":", "\"", "<"],
[">", "?"],
"ok",
"shft",
"del"
];
/**
* Given initial options, allow the user to type a set of characters and return their entry in a promise. If you do not
* submit your own character set, a default alphanumeric keyboard will display.
* @param options The object containing initial options for the keyboard.
* @param {string} options.text The initial text to display / edit in the keyboard
* @param {array[]|string[]} [options.keyboardMain] The primary keyboard generated with generateKeyboard()
* @param {array[]|string[]} [options.keyboardShift] Like keyboardMain, but displayed when shift / capslock is pressed.
* @returns {Promise<unknown>}
*/
function input(options) {
widgetUtils.hide();
2023-06-10 15:14:04 +00:00
options = options || {};
let typed = options.text || "";
let resolveFunction = () => {};
let shift = false;
let caps = false;
let activeKeySet;
const offsetX = 0;
const offsetY = 40;
E.showMessage("Loading...");
let keyboardPromise;
if (options.keyboardMain) {
keyboardPromise = Promise.all([options.keyboardMain, options.keyboardShift || Promise.resolve([])]);
} else {
keyboardPromise = Promise.all([generateKeyboard(defaultCharSet), generateKeyboard(defaultCharSetShift)])
}
let mainKeys;
let mainKeysShift;
/**
* Draw an individual keyboard key - handles special formatting and the rectangle pad, followed by the character
* rendering.
2023-06-10 15:14:04 +00:00
* @param key
*/
function drawKey(key) {
let bgColor = g.theme.bg;
if (key.special) {
if (key.special === "ok") bgColor = "#0F0";
if (key.special === "cncl") bgColor = "#F00";
if (key.special === "del") bgColor = g.theme.bg2;
if (key.special === "spc") bgColor = g.theme.bg2;
if (key.special === "shft") {
bgColor = shift ? g.theme.bgH : g.theme.bg2;
}
if (key.special === "caps") {
bgColor = caps ? g.theme.bgH : g.theme.bg2;
}
g.setColor(bgColor)
.fillRect({x: key.x, y: key.y, w: key.w, h: key.h});
}
g.setColor(g.theme.fg)
.drawRect({x: key.x, y: key.y, w: key.w, h: key.h});
drawChars(key);
}
/**
* Draw the characters for a given key - this handles the layout of all characters needed for the key, whether the
* key has 12 characters, 1, or if it represents a special key.
* @param key
*/
function drawChars(key) {
const numChars = key.chars.length;
if (key.special) {
g.setColor(g.theme.fg)
.setFont("12x20")
.setFontAlign(-1, -1)
.drawString(key.special, key.x + key.w / 2 - g.stringWidth(key.special) / 2, key.y + key.h / 2 - 10, false);
} else {
g.setColor(g.theme.fg)
.setFont(key.font)
.setFontAlign(-1, -1);
for (let i = 0; i < numChars; i++) {
const gridX = i % key.subGridWidth;
const gridY = Math.floor(i / key.subGridWidth) % key.subGridWidth;
const charOffsetX = gridX * (key.w / key.subGridWidth);
const charOffsetY = gridY * (key.h / key.subGridHeight);
const posX = key.x + key.pad + charOffsetX + key.extraPadH;
const posY = key.y + key.pad + charOffsetY + key.extraPadV;
g.drawString(key.chars[i], posX, posY, false);
}
}
}
/**
* Get the key set corresponding to the indicated shift state. Allows easy switching between capital letters and
* lower case by just switching the boolean passed here.
* @param shift
* @returns {*[]}
*/
function getMainKeySet(shift) {
return shift ? mainKeysShift : mainKeys;
}
/**
* Draw all the given keys on the screen.
* @param keys
*/
function drawKeys(keys) {
keys.forEach(key => {
drawKey(key);
});
}
/**
* Draw the text that the user has typed so far, includes a cursor and automatic truncation when the string is too
* long.
* @param text
* @param cursorChar
*/
function drawTyped(text, cursorChar) {
let visibleText = text;
let ellipsis = false;
const maxWidth = 176 - 40;
while (g.setFont("12x20")
.stringWidth(visibleText) > maxWidth) {
ellipsis = true;
visibleText = visibleText.slice(1);
}
if (ellipsis) {
visibleText = "..." + visibleText;
}
g.setColor(g.theme.bg2)
.fillRect(5, 5, 171, 30);
g.setColor(g.theme.fg2)
.setFont("12x20")
.drawString(visibleText + cursorChar, 15, 10, false);
}
/**
* Clear the space on the screen that the keyboard occupies (not the text the user has written).
*/
function clearKeySpace() {
g.setColor(g.theme.bg)
.fillRect(offsetX, offsetY, 176, 176);
}
/**
* Based on a touch event, determine which key was pressed by the user.
2023-06-10 15:14:04 +00:00
* @param touchEvent
* @param keys
* @returns {*}
*/
function getTouchedKey(touchEvent, keys) {
return keys.find((key) => {
let relX = touchEvent.x - key.x;
let relY = touchEvent.y - key.y;
return relX > 0 && relX < key.w && relY > 0 && relY < key.h;
})
}
/**
* On a touch event, determine whether a key is touched and take appropriate action if it is.
* @param button
* @param touchEvent
*/
function keyTouch(button, touchEvent) {
const pressedKey = getTouchedKey(touchEvent, activeKeySet);
if (pressedKey == null) {
// User tapped empty space.
swapKeySet(getMainKeySet(shift !== caps));
return;
}
if (pressedKey.pendingSubKeys) {
// We have to generate the subkeys for this key still, but we decided to wait until we needed it!
pressedKey.pendingSubKeys = false;
pressedKey.getSubKeys()
.then(subkeys => {
pressedKey.subKeys = subkeys;
keyTouch(undefined, touchEvent);
})
return;
}
// Haptic feedback
Bangle.buzz(25, 1);
if (pressedKey.subKeys) {
// Hold press for "shift!"
if (touchEvent.type > 1) {
shift = !shift;
swapKeySet(getMainKeySet(shift !== caps));
} else {
swapKeySet(pressedKey.subKeys);
}
} else {
if (pressedKey.special) {
evaluateSpecialFunctions(pressedKey);
} else {
typed = typed + pressedKey.chars;
shift = false;
drawTyped(typed, "");
swapKeySet(getMainKeySet(shift !== caps));
}
}
}
/**
* Manage setting, generating, and rendering new keys when a key set is changed.
* @param newKeys
*/
function swapKeySet(newKeys) {
activeKeySet = newKeys;
clearKeySpace();
drawKeys(activeKeySet);
}
/**
* Determine if the key contains any of the special strings that have their own special behaviour when pressed.
* @param key
*/
function evaluateSpecialFunctions(key) {
switch (key.special) {
case "ok":
setTimeout(() => resolveFunction(typed), 50);
break;
case "del":
typed = typed.slice(0, -1);
drawTyped(typed, "");
break;
case "shft":
shift = !shift;
swapKeySet(getMainKeySet(shift !== caps));
break;
case "caps":
caps = !caps;
swapKeySet(getMainKeySet(shift !== caps));
break;
case "cncl":
setTimeout(() => resolveFunction(), 50);
break;
case "spc":
typed = typed + " ";
break;
}
}
let isCursorVisible = true;
const blinkInterval = setInterval(() => {
if (!activeKeySet) return;
isCursorVisible = !isCursorVisible;
if (isCursorVisible) {
drawTyped(typed, "_");
} else {
drawTyped(typed, "");
}
}, 200);
/**
* We return a promise but the resolve function is assigned to a variable in the higher function scope. That allows
* us to return the promise and resolve it after we are done typing without having to return the entire scope of the
* application within the promise.
*/
return new Promise((resolve, reject) => {
g.clear(true);
resolveFunction = resolve;
keyboardPromise.then((result) => {
mainKeys = result[0];
mainKeysShift = result[1];
swapKeySet(getMainKeySet(shift !== caps));
Bangle.setUI({
mode: "custom", touch: keyTouch
});
Bangle.setLocked(false);
})
}).then((result) => {
g.clear(true);
widgetUtils.show();
2023-06-10 15:14:04 +00:00
clearInterval(blinkInterval);
Bangle.setUI();
return result;
});
}
exports.input = input;
exports.generateKeyboard = generateKeyboard;
exports.defaultCharSet = defaultCharSet;
exports.defaultCharSetShift = defaultCharSetShift;
exports.createCharSet = createCharSet;