opentui icon indicating copy to clipboard operation
opentui copied to clipboard

Keypress event leaks causing unintended automatic ITEM_SELECTED

Open hsulab opened this issue 2 weeks ago • 1 comments

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

Image

hsulab avatar Nov 25 '25 20:11 hsulab