lazy-widgets
Version:
Typescript retained mode GUI for the HTML canvas API
391 lines • 15.7 kB
JavaScript
import { KeyReleaseEvent } from '../events/KeyReleaseEvent.js';
import { KeyPressEvent } from '../events/KeyPressEvent.js';
import { FocusType } from '../core/FocusType.js';
import { TabSelectEvent } from '../events/TabSelectEvent.js';
import { insertValueIntoOrderedSubsetList } from '../index.js';
/**
* A generic keyboard {@link Driver | driver}.
*
* Does nothing on its own, but provides an API for sending keyboard events to
* registered roots.
*
* @category Driver
*/
export class KeyboardDriver {
constructor() {
/**
* Groups belonging to this driver. Roots in the same group transfer tab
* selections between themselves. Do not modify from a child class.
*/
this.groups = new Array();
/**
* A map from a Root to a group. Used only for optimisation purposes. Do not
* modify from a child class.
*/
this.groupMap = new Map();
/**
* The list of {@link Root | Roots} that are using this driver, in the order
* of access; the last focused (with any focus type) Root is moved to the
* beginning of the list.
*
* Used as a fallback for {@link KeyboardDriver#focus}.
*/
this.accessList = new Array();
/** A set containing the keys currently down. */
this.keysDown = new Set();
/** The currently focused root. New keyboard events will go to this root */
this.focus = null;
}
/**
* Changes the current {@link KeyboardDriver#focus | root focus}.
*
* If there was a previous root focus, that root's {@link Root#clearFocus}
* is called with {@link FocusType#Keyboard}.
*
* {@link KeyboardDriver#keysDown} is cleared.
*/
changeFocusedRoot(root) {
if (this.focus === root) {
return;
}
if (this.focus !== null) {
this.focus.clearFocus(FocusType.Keyboard);
}
this.focus = root;
this.keysDown.clear();
}
/**
* Get the current {@link KeyboardDriver#focus | root focus}.
*
* @returns Returns {@link KeyboardDriver#focus}
*/
getFocusedRoot() {
return this.focus;
}
/**
* Similar to {@link KeyboardDriver#getFocusedRoot}, but can fall back to
* the first root of {@link KeyboardDriver#accessList} if
* {@link KeyboardDriver#focus} is null.
*/
getEffectiveFocusedRoot() {
if (this.focus) {
return this.focus;
}
else if (this.accessList.length > 0) {
return this.accessList[0];
}
else {
return null;
}
}
/**
* Clear the current {@link KeyboardDriver#focus | root focus}. Calls
* {@link KeyboardDriver#changeFocusedRoot} with null.
*/
clearFocus() {
this.changeFocusedRoot(null);
}
/**
* Dispatch a new {@link KeyPressEvent} event to the
* {@link KeyboardDriver#getEffectiveFocusedRoot | effective focused Root}.
*
* @param key - Must follow the {@link https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values | KeyboardEvent.key} Web API.
* @param shift - Is shift being pressed?
* @param ctrl - Is control being pressed?
* @param alt - Is alt being pressed?
* @param virtual - Is the key down originating from a virtual keyboard? False by default
* @returns Returns a list of dispatched events and whether they were captured.
*/
keyDown(key, shift, ctrl, alt, virtual = false) {
this.keysDown.add(key);
return this.dispatchEvent(new KeyPressEvent(key, shift, ctrl, alt, virtual, null));
}
/**
* Dispatch a new {@link KeyReleaseEvent} event to the
* {@link KeyboardDriver#getEffectiveFocusedRoot | effective focused Root}.
*
* @param key - Must follow the {@link https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values | KeyboardEvent.key} Web API.
* @param shift - Is shift being pressed?
* @param ctrl - Is control being pressed?
* @param alt - Is alt being pressed?
* @param virtual - Is the key up originating from a virtual keyboard? False by default
* @returns Returns a list of dispatched events and whether they were captured.
*/
keyUp(key, shift, ctrl, alt, virtual = false) {
if (this.keysDown.delete(key)) {
return this.dispatchEvent(new KeyReleaseEvent(key, shift, ctrl, alt, virtual, null));
}
return [];
}
/**
* Calls {@link KeyboardDriver#keyDown} followed by
* {@link KeyboardDriver#keyUp}. If the key was already down before calling
* ({@link KeyboardDriver#isKeyDown}), keyUp is not called.
*
* @param key - Must follow the {@link https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values | KeyboardEvent.key} Web API.
* @param shift - Is shift being pressed?
* @param ctrl - Is control being pressed?
* @param alt - Is alt being pressed?
* @param virtual - Is the key press originating from a virtual keyboard? False by default
* @returns Returns a list of dispatched events and whether they were captured.
*/
keyPress(key, shift, ctrl, alt, virtual = false) {
const wasDown = this.isKeyDown(key);
const captured = this.keyDown(key, shift, ctrl, alt, virtual);
if (!wasDown) {
captured.push(...this.keyUp(key, shift, ctrl, alt, virtual));
}
return captured;
}
/**
* Check if a key is pressed.
*
* @param key - Must follow the {@link https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values | KeyboardEvent.key} Web API.
*
* @returns Returns true if key was in {@link KeyboardDriver#keysDown}
*/
isKeyDown(key) {
return this.keysDown.has(key);
}
/**
* Adds enabled root to {@link KeyboardDriver#accessList} and its respective
* group.
*/
onEnable(root) {
if (this.accessList.indexOf(root) >= 0) {
console.warn('KeyboardDriver was already registered to the Root, but "onEnable" was called');
}
else {
// add to enabled roots list in group
const group = this.getGroup(root);
// XXX enabledRoots is an ordered subset of roots
insertValueIntoOrderedSubsetList(root, group.enabledRoots, group.roots);
if (root.tabFocusable) {
// XXX tabbableRoots is an ordered subset of enabledRoots
insertValueIntoOrderedSubsetList(root, group.tabbableRoots, group.enabledRoots);
}
// add to access list
this.accessList.push(root);
}
}
/**
* Removes disabled root from {@link KeyboardDriver#accessList} and its
* respective group. If the root was the {@link KeyboardDriver#focus}, then
* {@link KeyboardDriver#clearFocus | the focus is cleared }.
*/
onDisable(root) {
const index = this.accessList.indexOf(root);
if (index < 0) {
console.warn('KeyboardDriver was not registered to the Root, but "onDisable" was called');
}
else {
// remove from enabled roots list in group
const group = this.getGroup(root);
const groupIndex = group.enabledRoots.indexOf(root);
if (groupIndex < 0) {
throw new Error("Root not found in group's enabled root list; this is a bug, please report it");
}
group.enabledRoots.splice(groupIndex, 1);
// remove from tabbable roots list in group
if (root.tabFocusable) {
const tabGroupIndex = group.tabbableRoots.indexOf(root);
if (tabGroupIndex < 0) {
throw new Error("Root not found in group's tabbable root list; this is a bug, please report it");
}
group.tabbableRoots.splice(tabGroupIndex, 1);
}
// remove from access list
this.accessList.splice(index, 1);
// clear focus if needed
if (root === this.focus) {
this.clearFocus();
}
}
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
update(_root) { }
/**
* Does nothing if the new focus type is not a {@link FocusType.Keyboard}.
* If the focus comes from a root which is not the
* {@link KeyboardDriver#focus | root focus}, then the root focus is
* {@link KeyboardDriver#changeFocusedRoot | changed to the new root}. If
* there is no new focused widget (the root's keyboard focus was cleared),
* then nothing happens.
*
* If a {@link Root} becomes focused (with any focus type, not just keyboard
* focus), it is moved to the beginning of the
* {@link KeyboardDriver#accessList} list.
*
* This behaviour is confusing, however, it's required so that the keyboard
* focus "lingers" for future tab key presses; this way, pressing tab can do
* tab selection even when there is no widget that wants keyboard input.
* When a focus is lingering, then it means that key events are still being
* dispatched to the last focused root, but they don't have a target. This
* way, most events get dropped, but tab key events are used for tab
* selection.
*/
onFocusChanged(root, focusType, newFocus) {
if (newFocus !== null) {
const oldIndex = this.accessList.indexOf(root);
if (oldIndex < 0) {
console.warn("Focus changed to Root which doesn't have this KeyboardDriver attached");
}
else if (oldIndex > 0) {
this.accessList.splice(oldIndex, 1);
this.accessList.unshift(root);
}
}
if (focusType === FocusType.Keyboard && root !== this.focus && newFocus !== null) {
this.changeFocusedRoot(root);
}
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onFocusCapturerChanged(_root, _focusType, _oldCapturer, _newCapturer) { }
/**
* Check if the currently focused root needs keyboard input. Virtual
* keyboard should query this property to know when to show themselves.
*/
get needsInput() {
return this.focus !== null && this.focus.getFocus(FocusType.Keyboard) !== null;
}
/**
* Dispatches an event to the currently focused root (or a fallback).
* Handles wrap-around for tab selection. Internal use only.
*
* @param event - The event to dispatch
*/
dispatchEvent(event) {
const root = this.getEffectiveFocusedRoot();
if (root) {
const captureList = root.dispatchEvent(event);
const group = this.getGroup(root);
const rootCount = group.tabbableRoots.length;
// check if there was any uncaptured TabSelectEvent event and carry
// it over to another root in the same group
if (rootCount > 1 || (group.wrapsAround && rootCount > 0)) {
const capListLen = captureList.length;
for (let i = 0; i < capListLen; i++) {
const [cEvent, captured] = captureList[i];
if (!captured && cEvent.isa(TabSelectEvent)) {
let iNext = i + (cEvent.reversed ? -1 : 1);
if (iNext < 0 || iNext >= rootCount) {
if (group.wrapsAround) {
iNext = (iNext + rootCount) % rootCount;
}
else {
break;
}
}
const nextRoot = group.tabbableRoots[iNext];
captureList.splice(i, 1);
captureList.push(...nextRoot.dispatchEvent(new TabSelectEvent(null, cEvent.reversed)));
break;
}
}
}
return captureList;
}
return [];
}
/** Get the index of a group in the groups list. For internal use only */
getGroupIndex(group) {
const index = this.groups.indexOf(group);
if (index < 0) {
throw new Error('Group does not exist; maybe it belongs to another KeyboardDriver?');
}
return index;
}
/**
* Get the group that a {@link Root} is assigned to. Throws an error if the
* Root is not assigned to any group in this driver.
*
* @returns Returns the group assigned to this Root. The group is live, do not modify it directly.
*/
getGroup(root) {
const group = this.groupMap.get(root);
if (!group) {
throw new Error('Root is not assigned to any group in KeyboardDriver');
}
return group;
}
/**
* Get a new group, with no {@link Root | Roots}.
*
* @returns Returns the created group. The group is live, do not modify it directly.
*/
createGroup(options) {
const group = {
roots: new Array(),
enabledRoots: new Array(),
tabbableRoots: new Array(),
wrapsAround: !!options.wrapsAround,
};
this.groups.push(group);
return group;
}
/**
* Delete a group that is assigned to this keyboard. Throws an error if the
* group is still in use (has assigned {@link Root | Roots}).
*/
deleteGroup(group) {
// find group index
const index = this.getGroupIndex(group);
// assert that the group is not in use
for (const oGroup of this.groupMap.values()) {
if (group === oGroup) {
throw new Error("Can't delete group; group is still in use");
}
}
// remove group
this.groups.splice(index, 1);
}
/**
* Bind a {@link Root} to a group that is assigned to this keyboard. Throws
* an error if the Root is already assigned to a group in this driver.
*/
bindRoot(root, group) {
// assert the root is not already bound in this driver
if (this.groupMap.has(root)) {
throw new Error('Root is already bound');
}
// assert group belongs to this driver
this.getGroupIndex(group);
// add root to group
group.roots.push(root);
this.groupMap.set(root, group);
}
/**
* Unbind a {@link Root} from its assigned group. Throws an error if the
* Root is not assigned to any group in this driver.
*/
unbindGroup(root) {
// remove root from group
const group = this.getGroup(root);
this.groupMap.delete(root);
const index = group.roots.indexOf(root);
if (index < 0) {
throw new Error('Root not found in group; this is a bug, please report it');
}
group.roots.splice(index, 1);
}
/**
* Bind a {@link Root} to this keyboard, in a new group dedicated to the
* Root. Equivalent to creating a new group and binding a Root to it. Useful
* if you are using lazy-widgets directly in the DOM, where each Root has a
* dedicated DOM element.
*
* @returns Returns the created group. The group is live, do not modify it directly.
*/
bindSingletRoot(root, options) {
const group = this.createGroup(options);
try {
this.bindRoot(root, group);
}
catch (err) {
this.deleteGroup(group);
throw err;
}
return group;
}
}
//# sourceMappingURL=KeyboardDriver.js.map