KeyEventListener can now listen to only specific keys

This commit is contained in:
Aran-Fey
2025-03-08 16:25:07 +01:00
parent f3356b998b
commit 312abe62cc
3 changed files with 179 additions and 44 deletions

View File

@@ -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("+");
}

View File

@@ -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)

View File

@@ -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}>"