Implement Matryoshka Keyboard

pull/2808/head
Philip Andresen 2023-06-10 11:14:04 -04:00
parent d620ff25be
commit 2385426699
13 changed files with 630 additions and 0 deletions

7
apps/kbmatry/ChangeLog Normal file
View File

@ -0,0 +1,7 @@
1.00: New keyboard
1.01: Change swipe interface to taps, speed up responses (efficiency tweaks).
1.02: Generalize drawing and letter scaling. Allow custom and auto-generated character sets. Improve documentation.
1.03: Attempt to improve keyboard load time.
1.04: Make code asynchronous and improve load time.
1.05: Fix layout issue and rename library
1.06: Touch up readme, prep for IPO, add screenshots

119
apps/kbmatry/README.md Normal file
View File

@ -0,0 +1,119 @@
# Matryoshka Keyboard
![icon](icon.png)
![screenshot](screenshot.png) ![screenshot](screenshot6.png)
![screenshot](screenshot5.png) ![screenshot](screenshot2.png)
![screenshot](screenshot3.png) ![screenshot](screenshot4.png)
Nested key input utility.
## How to type
Press your finger down on the letter group that contains the character you would like to type, then tap the letter you
want to enter. Once you are touching the letter you want, release your
finger.
![help](help.png)
Press "shft" or "caps" to access alternative characters, including upper case letters, punctuation, and special
characters.
Pressing "shft" also reveals a cancel button if you would like to terminate input without saving.
Press "ok" to finish typing and send your text to whatever app called this keyboard.
Press "del" to delete the leftmost character.
## Themes and Colors
This keyboard will attempt to use whatever theme or colorscheme is being used by your Bangle device.
## How to use in a program
This was developed to match the interface implemented for kbtouch, kbswipe, etc.
In your app's metadata, add:
```json
"dependencies": {"textinput": "type"}
```
From inside your app, call:
```js
const textInput = require("textinput");
textInput.input({text: ""})
.then(result => {
console.log("The user entered: ", result);
});
```
Alternatively, if you want to improve the load time of the keyboard, you can pre-generate the data the keyboard needs
to function and render like so:
```js
const textInput = require("textinput");
const defaultKeyboard = textInput.generateKeyboard(textInput.defaultCharSet);
const defaultShiftKeyboard = textInput.generateKeyboard(textInput.defaultCharSetShift);
// ...
textInput.input({text: "", keyboardMain: defaultKeyboard, keyboardShift: defaultShiftKeyboard})
.then(result => {
console.log("The user entered: ", result);
// And it was faster!
});
```
This isn't required, but if you are using a large character set, and the user is interacting with the keyboard a lot,
it can really smooth the experience.
The default keyboard has a full set of alphanumeric characters as well as special characters and buttons in a
pre-defined layout. If your application needs something different, or you want to have a custom layout, you can do so:
```js
const textInput = require("textinput");
const customKeyboard = textInput.generateKeyboard([
["1", "2", "3", "4"], ["5", "6", "7", "8"], ["9", "0", ".", "-"], "ok", "del", "cncl"
]);
// ...
textInput.input({text: "", keyboardMain: customKeyboard})
.then(result => {
console.log("The user entered: ", result);
// And they could only enter numbers, periods, and dashes!
});
```
This will give you a keyboard with six buttons. The first three buttons will open up a 2x2 keyboard. The second three
buttons are special keys for submitting, deleting, and cancelling respectively.
Finally if you are like, super lazy, or have a dynamic set of keys you want to be using at any given time, you can
generate keysets from strings like so:
```js
const textInput = require("textinput");
const customKeyboard = textInput.generateKeyboard(createCharSet("ABCDEFGHIJKLMNOP", ["ok", "shft", "cncl"]));
const customShiftKeyboard = textInput.generateKeyboard(createCharSet("abcdefghijklmnop", ["ok", "shft", "cncl"]));
// ...
textInput.input({text: "", keyboardMain: customKeyboard, keyboardShift: customShiftKeyboard})
.then(result => {
console.log("The user entered: ", result);
// And the keyboard was automatically generated to include "ABCDEFGHIJKLMNOP" plus an OK button, a shift button, and a cancel button!
});
```
The promise resolves when the user hits "ok" on the input or if they cancel. If the user cancels, undefined is
returned, although the user can hit "OK" with an empty string as well. If you define a custom character set and
do not include the "ok" button your user will be soft-locked by the keyboard. Fair warning!
At some point I may add swipe-for-space and swipe-for-delete as well as swipe-for-submit and swipe-for-cancel
however I want to have a good strategy for the touch screen
[affordance](https://careerfoundry.com/en/blog/ux-design/affordances-ux-design/).
## Secret features
If you long press a key with characters on it, that will enable "Shift" mode.

1
apps/kbmatry/app-icon.js Normal file
View File

@ -0,0 +1 @@
require("heatshrink").decompress(atob("mEwwcBkmSpICVz//ABARGCBIRByA/Dk+AAgUH8AECgP4kmRCwX4n+PAoXH8YEC+IRC4HguE4/+P/EfCIXwgARHn4RG+P/j4RDJwgRBGQIRIEYNxCIRECGpV/CIXAgY1P4/8v41JOgeOn4RDGo4jER5Y1FCJWQg4RDYpeSNIQAMkmTCBwRBz4IG9YRIyA8COgJHBhMgI4+QyVJAYJrC9Mkw5rHwFAkEQCImSCJvAhIRBpazFGo3HEYVJkIjGCIIUCAQu/CKGSGo4jPLIhHMNayPLYo6zBYozpH9MvdI+TfaGSv4KHCI+Qg4GDI4IABg5HGyIYENYIAB45rGyPACKIIDx/4gF/CIPx/8fCIY1F4H8CJPA8BtCa4I1DCJFxCIYXBCILXBGpXHGplwn5HPuE4NaH4n6PLyC6CgEnYpeSpICDdJYRFz4RQARQ"))

BIN
apps/kbmatry/help.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
apps/kbmatry/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 B

489
apps/kbmatry/lib.js Normal file
View File

@ -0,0 +1,489 @@
/**
* 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);
}
}
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 1.5 seconds for a full keyboard. Not a super great user experience
* SO if you have a tiny keyset, don't worry about it so much, but if you want to maximize performance, generate
* a keyboard ahead of time and pass it into the "keyboard" argument of the object in the "input" function.
* @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);
}
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"
];
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) {
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. Returns a promise so that you can do many of these in a loop without blocking the thread.
* @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 even, determine which key was pressed by the user.
* @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.clearRect(Bangle.appRect);
clearInterval(blinkInterval);
Bangle.setUI();
return result;
});
}
exports.input = input;
exports.generateKeyboard = generateKeyboard;
exports.defaultCharSet = defaultCharSet;
exports.defaultCharSetShift = defaultCharSetShift;
exports.createCharSet = createCharSet;

View File

@ -0,0 +1,14 @@
{ "id": "kbmatry",
"name": "Matryoshka Keyboard",
"version":"1.06",
"description": "A library for text input via onscreen keyboard. Easily enter characters with nested keyboards.",
"icon": "icon.png",
"type":"textinput",
"tags": "keyboard",
"supports" : ["BANGLEJS2"],
"screenshots": [{"url":"screenshot.png"},{"url":"screenshot6.png"},{"url":"screenshot2.png"},{"url":"screenshot3.png"},{"url":"screenshot4.png"},{"url":"screenshot5.png"},{"url": "help.png"}],
"readme": "README.md",
"storage": [
{"name":"textinput","url":"lib.js"}
]
}

BIN
apps/kbmatry/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB