mirror of
https://github.com/rio-labs/rio.git
synced 2026-01-28 16:29:46 -06:00
KeyEventListener can now listen to only specific keys
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { ComponentBase, ComponentState } from "./componentBase";
|
||||
import { ComponentId } from "../dataModels";
|
||||
import { markEventAsHandled } from "../eventHandling";
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
|
||||
const HARDWARE_KEY_MAP = {
|
||||
@@ -638,7 +639,7 @@ function encodeKey(event: KeyboardEvent): Key {
|
||||
if (event.key in SOFTWARE_KEY_MAP) {
|
||||
softwareKey = SOFTWARE_KEY_MAP[event.key];
|
||||
} else if (event.key.length === 1) {
|
||||
softwareKey = event.key as SoftwareKey;
|
||||
softwareKey = event.key.toLowerCase() as SoftwareKey;
|
||||
} else {
|
||||
console.warn(`Unknown software key: ${event.key}`);
|
||||
softwareKey = "unknown";
|
||||
@@ -683,17 +684,23 @@ function encodeEvent(event: KeyboardEvent): EncodedEvent {
|
||||
};
|
||||
}
|
||||
|
||||
type KeyCombination = SoftwareKey | SoftwareKey[];
|
||||
|
||||
export type KeyEventListenerState = ComponentState & {
|
||||
_type_: "KeyEventListener-builtin";
|
||||
content?: ComponentId;
|
||||
reportKeyDown?: boolean;
|
||||
reportKeyUp?: boolean;
|
||||
reportKeyPress?: boolean;
|
||||
reportKeyDown?: KeyCombination[] | true;
|
||||
reportKeyUp?: KeyCombination[] | true;
|
||||
reportKeyPress?: KeyCombination[] | true;
|
||||
};
|
||||
|
||||
export class KeyEventListenerComponent extends ComponentBase {
|
||||
declare state: Required<KeyEventListenerState>;
|
||||
|
||||
private keyDownCombinations: Map<string, KeyCombination> | true;
|
||||
private keyUpCombinations: Map<string, KeyCombination> | true;
|
||||
private keyPressCombinations: Map<string, KeyCombination> | true;
|
||||
|
||||
createElement(): HTMLElement {
|
||||
let element = document.createElement("div");
|
||||
element.classList.add("rio-key-event-listener");
|
||||
@@ -707,29 +714,40 @@ export class KeyEventListenerComponent extends ComponentBase {
|
||||
): void {
|
||||
super.updateElement(deltaState, latentComponents);
|
||||
|
||||
// To efficiently find out if a key combination needs to be reported to
|
||||
// the backend, we need to store the key combinations in a hashable
|
||||
// format.
|
||||
if (deltaState.reportKeyDown !== undefined) {
|
||||
this.keyDownCombinations = keyCombinationsMapFromDeltaState(
|
||||
deltaState.reportKeyDown
|
||||
);
|
||||
}
|
||||
|
||||
if (deltaState.reportKeyUp !== undefined) {
|
||||
this.keyUpCombinations = keyCombinationsMapFromDeltaState(
|
||||
deltaState.reportKeyUp
|
||||
);
|
||||
}
|
||||
|
||||
if (deltaState.reportKeyPress !== undefined) {
|
||||
this.keyPressCombinations = keyCombinationsMapFromDeltaState(
|
||||
deltaState.reportKeyPress
|
||||
);
|
||||
}
|
||||
|
||||
let reportKeyDown =
|
||||
deltaState.reportKeyDown ?? this.state.reportKeyDown;
|
||||
let reportKeyUp = deltaState.reportKeyUp ?? this.state.reportKeyUp;
|
||||
this.keyDownCombinations === true ||
|
||||
this.keyDownCombinations.size > 0;
|
||||
let reportKeyUp =
|
||||
this.keyUpCombinations === true || this.keyUpCombinations.size > 0;
|
||||
let reportKeyPress =
|
||||
deltaState.reportKeyPress ?? this.state.reportKeyPress;
|
||||
this.keyPressCombinations === true ||
|
||||
this.keyPressCombinations.size > 0;
|
||||
|
||||
if (reportKeyDown || reportKeyPress) {
|
||||
this.element.onkeydown = (e: KeyboardEvent) => {
|
||||
let encodedEvent = encodeEvent(e);
|
||||
|
||||
if (reportKeyPress) {
|
||||
this.sendMessageToBackend({
|
||||
type: "KeyPress",
|
||||
...encodedEvent,
|
||||
});
|
||||
}
|
||||
|
||||
if (reportKeyDown && !e.repeat) {
|
||||
this.sendMessageToBackend({
|
||||
type: "KeyDown",
|
||||
...encodedEvent,
|
||||
});
|
||||
}
|
||||
this.handleKeyEvent(e, "KeyPress", this.keyPressCombinations);
|
||||
this.handleKeyEvent(e, "KeyDown", this.keyDownCombinations);
|
||||
};
|
||||
} else {
|
||||
this.element.onkeydown = null;
|
||||
@@ -737,10 +755,7 @@ export class KeyEventListenerComponent extends ComponentBase {
|
||||
|
||||
if (reportKeyUp) {
|
||||
this.element.onkeyup = (e: KeyboardEvent) => {
|
||||
this.sendMessageToBackend({
|
||||
type: "KeyUp",
|
||||
...encodeEvent(e),
|
||||
});
|
||||
this.handleKeyEvent(e, "KeyUp", this.keyUpCombinations);
|
||||
};
|
||||
} else {
|
||||
this.element.onkeyup = null;
|
||||
@@ -749,7 +764,74 @@ export class KeyEventListenerComponent extends ComponentBase {
|
||||
this.replaceOnlyChild(latentComponents, deltaState.content);
|
||||
}
|
||||
|
||||
private handleKeyEvent(
|
||||
event: KeyboardEvent,
|
||||
eventType: "KeyDown" | "KeyUp" | "KeyPress",
|
||||
keyCombinations: Map<string, KeyCombination> | true
|
||||
): void {
|
||||
let encodedEvent = encodeEvent(event);
|
||||
|
||||
// If we're listening for all keys, just send it
|
||||
if (keyCombinations === true) {
|
||||
markEventAsHandled(event);
|
||||
this.sendMessageToBackend({
|
||||
type: eventType,
|
||||
keyCombination: null,
|
||||
...encodedEvent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're only listening for specific key combinations, check if this
|
||||
// is one of them
|
||||
let keys: SoftwareKey[] = encodedEvent.modifiers;
|
||||
if (!keys.includes(encodedEvent.softwareKey)) {
|
||||
keys = [...keys, encodedEvent.softwareKey];
|
||||
}
|
||||
let keyCombinationString = makeKeyCombinationString(keys);
|
||||
let keyCombination = keyCombinations.get(keyCombinationString);
|
||||
|
||||
// No? Abort
|
||||
if (keyCombination === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Yes? Send it
|
||||
markEventAsHandled(event);
|
||||
this.sendMessageToBackend({
|
||||
type: eventType,
|
||||
keyCombination: keyCombination,
|
||||
...encodedEvent,
|
||||
});
|
||||
}
|
||||
|
||||
grabKeyboardFocus(): void {
|
||||
this.element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function keyCombinationsMapFromDeltaState(
|
||||
reportKeyCombinations: KeyCombination[] | true
|
||||
): Map<string, KeyCombination> | true {
|
||||
if (reportKeyCombinations === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let map = new Map<string, KeyCombination>();
|
||||
for (const keyCombination of reportKeyCombinations) {
|
||||
let keyCombinationString: string;
|
||||
|
||||
if (typeof keyCombination === "string") {
|
||||
keyCombinationString = keyCombination;
|
||||
} else {
|
||||
keyCombinationString = makeKeyCombinationString(keyCombination);
|
||||
}
|
||||
|
||||
map.set(keyCombinationString, keyCombination);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function makeKeyCombinationString(keys: SoftwareKey[]): string {
|
||||
return Array.from(keys).sort().join("+");
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
T = t.TypeVar("T")
|
||||
|
||||
|
||||
HardwareKey = t.Literal[
|
||||
"unknown",
|
||||
# Function keys
|
||||
@@ -564,6 +567,7 @@ SoftwareKey = t.Literal[
|
||||
]
|
||||
|
||||
ModifierKey = t.Literal["alt", "control", "meta", "shift"]
|
||||
KeyCombination = SoftwareKey | tuple[ModifierKey | SoftwareKey, ...]
|
||||
|
||||
_MODIFIERS = ("control", "shift", "alt", "meta")
|
||||
|
||||
@@ -663,15 +667,24 @@ class KeyEventListener(KeyboardFocusableFundamentalComponent):
|
||||
|
||||
content: rio.Component
|
||||
_: dataclasses.KW_ONLY
|
||||
on_key_down: rio.EventHandler[KeyDownEvent] = None
|
||||
on_key_up: rio.EventHandler[KeyUpEvent] = None
|
||||
on_key_press: rio.EventHandler[KeyPressEvent] = None
|
||||
on_key_down: (
|
||||
rio.EventHandler[KeyDownEvent]
|
||||
| t.Mapping[KeyCombination, rio.EventHandler[KeyDownEvent]]
|
||||
) = None
|
||||
on_key_up: (
|
||||
rio.EventHandler[KeyUpEvent]
|
||||
| t.Mapping[KeyCombination, rio.EventHandler[KeyUpEvent]]
|
||||
) = None
|
||||
on_key_press: (
|
||||
rio.EventHandler[KeyPressEvent]
|
||||
| t.Mapping[KeyCombination, rio.EventHandler[KeyPressEvent]]
|
||||
) = None
|
||||
|
||||
def _custom_serialize_(self) -> dict[str, Jsonable]:
|
||||
return {
|
||||
"reportKeyDown": self.on_key_down is not None,
|
||||
"reportKeyUp": self.on_key_up is not None,
|
||||
"reportKeyPress": self.on_key_press is not None,
|
||||
"reportKeyDown": _serialize_one_handler(self.on_key_down),
|
||||
"reportKeyUp": _serialize_one_handler(self.on_key_up),
|
||||
"reportKeyPress": _serialize_one_handler(self.on_key_press),
|
||||
}
|
||||
|
||||
async def _on_message_(self, msg: t.Any) -> None:
|
||||
@@ -683,19 +696,22 @@ class KeyEventListener(KeyboardFocusableFundamentalComponent):
|
||||
|
||||
# Dispatch the correct event
|
||||
if msg_type == "KeyDown":
|
||||
await self.call_event_handler(
|
||||
await self._call_key_event_handler(
|
||||
msg["keyCombination"],
|
||||
self.on_key_down,
|
||||
KeyDownEvent._from_json(msg),
|
||||
)
|
||||
|
||||
elif msg_type == "KeyUp":
|
||||
await self.call_event_handler(
|
||||
await self._call_key_event_handler(
|
||||
msg["keyCombination"],
|
||||
self.on_key_up,
|
||||
KeyUpEvent._from_json(msg),
|
||||
)
|
||||
|
||||
elif msg_type == "KeyPress":
|
||||
await self.call_event_handler(
|
||||
await self._call_key_event_handler(
|
||||
msg["keyCombination"],
|
||||
self.on_key_press,
|
||||
KeyPressEvent._from_json(msg),
|
||||
)
|
||||
@@ -708,5 +724,37 @@ class KeyEventListener(KeyboardFocusableFundamentalComponent):
|
||||
# Refresh the session
|
||||
await self.session._refresh()
|
||||
|
||||
async def _call_key_event_handler(
|
||||
self,
|
||||
key_combination: KeyCombination | None,
|
||||
handler: rio.EventHandler[T]
|
||||
| t.Mapping[KeyCombination, rio.EventHandler[T]],
|
||||
event: T,
|
||||
) -> None:
|
||||
if handler is not None and not callable(handler):
|
||||
assert key_combination is not None
|
||||
|
||||
# JSON turns our tuple into a list
|
||||
if isinstance(key_combination, list):
|
||||
key_combination = tuple(key_combination) # type: ignore (?????)
|
||||
|
||||
try:
|
||||
handler = handler[key_combination] # type: ignore (?????)
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
await self.call_event_handler(handler, event)
|
||||
|
||||
|
||||
KeyEventListener._unique_id_ = "KeyEventListener-builtin"
|
||||
|
||||
|
||||
def _serialize_one_handler(
|
||||
handler: rio.EventHandler | t.Mapping[KeyCombination, rio.EventHandler],
|
||||
) -> Jsonable:
|
||||
if not handler:
|
||||
return []
|
||||
elif callable(handler):
|
||||
return True
|
||||
else:
|
||||
return list(handler)
|
||||
|
||||
@@ -137,10 +137,10 @@ class StateProperty:
|
||||
) -> AttributeBinding:
|
||||
# In order to create a `StateBinding`, the owner's attribute
|
||||
# must also be a binding
|
||||
binding_owner = request.component
|
||||
binding_owner = request._component_
|
||||
binding_owner_vars = vars(binding_owner)
|
||||
|
||||
owner_binding = binding_owner_vars[request.state_property.name]
|
||||
owner_binding = binding_owner_vars[request._state_property_.name]
|
||||
|
||||
if not isinstance(owner_binding, AttributeBinding):
|
||||
owner_binding = AttributeBinding(
|
||||
@@ -151,7 +151,7 @@ class StateProperty:
|
||||
value=owner_binding,
|
||||
children=weakref.WeakSet(),
|
||||
)
|
||||
binding_owner_vars[request.state_property.name] = owner_binding
|
||||
binding_owner_vars[request._state_property_.name] = owner_binding
|
||||
|
||||
# Create the child binding
|
||||
child_binding = AttributeBinding(
|
||||
@@ -247,13 +247,18 @@ class AttributeBindingMaker:
|
||||
class PendingAttributeBinding:
|
||||
# This is not a dataclasses because it makes pyright do nonsense
|
||||
def __init__(self, component: Component, state_property: StateProperty):
|
||||
self.component = component
|
||||
self.state_property = state_property
|
||||
self._component_ = component
|
||||
self._state_property_ = state_property
|
||||
|
||||
def _get_error_message(self, operation: str) -> str:
|
||||
return f"You attempted to use `{operation}` on a pending attribute binding. This is not supported. Attribute bindings are an instruction for rio to synchronize the state of two components. They do not have a value. For more information, see https://rio.dev/docs/howto/attribute-bindings"
|
||||
|
||||
def _warn_about_incorrect_usage(self, operation: str) -> None:
|
||||
revel.warning(
|
||||
f"You attempted to use `{operation}` on a pending attribute binding. This is not supported. Attribute bindings are an instruction for rio to synchronize the state of two components. They do not have a value. For more information, see https://rio.dev/docs/howto/attribute-bindings"
|
||||
)
|
||||
revel.warning(self._get_error_message(operation))
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
operation = f".{name}"
|
||||
raise AttributeError(self._get_error_message(operation))
|
||||
|
||||
def __add__(self, other):
|
||||
self._warn_about_incorrect_usage("+")
|
||||
@@ -281,4 +286,4 @@ class PendingAttributeBinding:
|
||||
|
||||
def __repr__(self) -> str:
|
||||
self._warn_about_incorrect_usage("__repr__")
|
||||
return f"<PendingAttributeBinding for {self.component!r}.{self.state_property.name}>"
|
||||
return f"<PendingAttributeBinding for {self._component_!r}.{self._state_property_.name}>"
|
||||
|
||||
Reference in New Issue
Block a user