opentui
opentui copied to clipboard
Keypress event leaks causing unintended automatic ITEM_SELECTED
Description
When a SelectRenderable (s0) handles ITEM_SELECTED, and the handler makes another selector (s1) visible and focuses it, the same keypress event leaks into s1, causing s1 to immediately fire its own ITEM_SELECTED event and automatically select its first option.
This happens without any user input.
Currently, the workaround is using setTimeout or queueMicrotask. I wonder if this can be fixed or there is better practice?
Expected behaviour
- Only one widget should receive a keypress.
- After s0 selection, s1 should become visible and focused.
- s1 should not automatically select an item unless the user presses a new key.
Actual behaviour
- The same keypress that selected an item in s0 is immediately routed into s1 as well.
- s1 fires its own ITEM_SELECTED.
- s1 selects the first item without user intention.
- This creates broken UI flows (menus, dialogs, cascading selectors, etc.).
Reproduce
#!/usr/bin/env bun
import {
CliRenderer,
createCliRenderer,
SelectRenderable,
type SelectOption,
type KeyEvent,
SelectRenderableEvents,
} from "@opentui/core";
function run(renderer: CliRenderer) {
const s0 = new SelectRenderable(renderer, {
id: "select_0",
position: "absolute",
top: 0,
left: 0,
width: 30,
height: 10,
options: [
{ name: "a", value: "a", description: "a" },
{ name: "b", value: "b", description: "b" },
{ name: "c", value: "c", description: "c" },
],
showDescription: false,
});
const s1 = new SelectRenderable(renderer, {
id: "s1",
position: "absolute",
top: 2,
left: 35,
width: 30,
height: 10,
options: [
{
name: "I",
value: "I",
description: "I",
},
{
name: "II",
value: "II",
description: "II",
},
],
showDescription: false,
});
s0.on(
SelectRenderableEvents.ITEM_SELECTED,
(idnex: number, option: SelectOption) => {
console.log(`s0 selected index ${idnex} with option:`, option.value);
switch (option.value) {
case "a": {
break;
}
case "b": {
// TODO: ITEM_SELECTED leaks to s1?
s1.visible = true;
s1.focus();
// FIX:
// setTimeout(() => {
// s1.visible = true;
// s1.focus();
// });
// FIX:
// queueMicrotask(() => {
// s1.visible = true;
// s1.focus();
// });
break;
}
case "c": {
break;
}
default:
break;
}
},
);
s0.visible = true;
s0.focus();
renderer.root.add(s0);
s1.on(
SelectRenderableEvents.ITEM_SELECTED,
(index: number, option: SelectOption) => {
console.log(`s1 selected index ${index} with option:`, option.value);
switch (option.value) {
case "a": {
break;
}
case "b": {
s1.visible = true;
s1.focus();
break;
}
default:
break;
}
},
);
s1.visible = false;
s1.blur();
renderer.root.add(s1);
}
function setupKeybindings(renderer: CliRenderer) {
renderer.keyInput.on("keypress", (key: KeyEvent) => {
if (key.name === "`") {
renderer.console.toggle();
}
});
}
if (import.meta.main) {
const renderer = await createCliRenderer({
exitOnCtrlC: true,
});
run(renderer);
setupKeybindings(renderer);
renderer.start();
}