Interaction, focus, and selection

OpenTUI routes mouse input through rendered cell bounds. It tracks one focused renderable and one global text selection per renderer.

Terminal-window focus is a separate state. A terminal focus report does not give keyboard input to a renderable.

Mouse events#

Mouse input is enabled by default. Set useMouse: false to disable it or enableMouseMovement: false to omit movement tracking.

Renderable options accept a catch-all onMouse handler and these specific handlers:

Handler event.type Meaning
onMouseDown "down" A button went down.
onMouseUp "up" A button went up.
onMouseMove "move" The pointer moved without a pressed button.
onMouseDrag "drag" The pointer moved with a pressed button.
onMouseDragEnd "drag-end" An automatically captured left-button drag ended.
onMouseDrop "drop" A captured drag ended over this target.
onMouseOver "over" The hit target changed to this renderable.
onMouseOut "out" The hit target changed away from this renderable.
onMouseScroll "scroll" The terminal reported wheel input.

OpenTUI does not synthesize a click event. A click produces down and up. A double click produces two such pairs.

MouseButton.LEFT, MIDDLE, and RIGHT are 0, 1, and 2. For wheel input, read event.scroll.direction and event.scroll.delta instead of event.button.

Each MouseEvent also contains these fields:

  • x and y are zero-based global cells in the renderer’s render region.
  • modifiers contains shift, alt, and ctrl.
  • target is the original hit renderable.
  • currentTarget changes as the event moves through ancestors.
  • source identifies the captured renderable on over and drop events.
  • isDragging marks mouse events that belong to a text-selection drag.

OpenTUI does not expose a coordinate-conversion helper. Compute local cells from the current target.

import { BoxRenderable, createCliRenderer } from "@opentui/core"

const renderer = await createCliRenderer()
const box = new BoxRenderable(renderer, {
  width: 20,
  height: 4,
  onMouseDown(event) {
    const localX = event.x - event.currentTarget!.x
    const localY = event.y - event.currentTarget!.y
    console.log(localX, localY)
  },
})

renderer.root.add(box)

Hit order and propagation#

Each rendered node writes its bounds to a native hit grid. Later writes replace earlier writes in overlapping cells.

Children render after their parent. Siblings render in ascending zIndex order, so a higher zIndex receives an overlapping hit.

The hit grid obeys clipping from overflow: "hidden" and "scroll". A layout change can emit out and over without physical pointer movement.

Mouse events start at the hit target and bubble through parent links. event.stopPropagation() prevents later ancestors from receiving that event.

event.preventDefault() has only defined renderer defaults. On left-button down, it prevents automatic focus and the post-dispatch selection clear.

It does not stop propagation. It also does not undo a new selection that already started on selectable text.

Hover, drag, and capture#

over and out report changes to the top hit target. They are not browser enter and leave events, and they bubble like other mouse events.

After a left-button drag starts on a renderable, OpenTUI captures later drag events to that source. No public pointer-capture API exists.

On release, the source receives drag-end and up. The renderable under the pointer receives drop with event.source, then its normal up event.

Right-button and middle-button drags follow hit testing without this capture. A selectable-text drag follows the selection path instead.

Renderable focus#

A renderable receives keyboard and paste input only while its focused property is true. Calling focus() has no effect unless focusable is true.

Input, Textarea, Select, TabSelect, ScrollBox, and ScrollBar are focusable by default. A Box becomes focusable with focusable: true.

Each renderer tracks at most one focused renderable. Focusing another renderable blurs the previous one.

Use focus() and blur() for explicit control. Listen for RenderableEvents.FOCUSED and RenderableEvents.BLURRED on the instance.

By default, a left-button down focuses the nearest focusable target or ancestor. Set renderer autoFocus: false to disable this behavior.

OpenTUI Core has no automatic Tab traversal or focus-order property. Your application must choose the next renderable and call focus().

Focused components apply their own key bindings before editing or changing local state. See Keyboard input.

Terminal focus reports#

The renderer emits focus and blur when the terminal sends window-focus reports. Capability detection controls whether those reports are available.

These events can pause application work when the terminal window loses focus. They do not change currentFocusedRenderable.

Use focused_renderable for renderable focus changes. See Terminal capabilities for report support and timing.

Text selection#

Text-buffer renderables are selectable by default. Set selectable: false on Text or related content to disable selection.

A left-button down on selectable content starts a global selection. Dragging extends it across selectable descendants in the active container.

The active container expands to an ancestor when the pointer leaves its current subtree.

Releasing the button ends the drag and emits renderer selection. Ctrl+left-click extends an existing selection from its original anchor.

A normal left-button down that does not start or extend selection clears it. A mouse handler can prevent that clear with preventDefault().

You can also call renderer.clearSelection().

Use these public values:

  • renderer.hasSelection reports whether a global selection object exists.
  • renderer.getSelection() returns that Selection or null.
  • Selection.anchor, focus, and bounds use global renderer cells. The rectangular bounds includes both endpoint cells.
  • Selection.selectedRenderables lists renderables with selected text.
  • Selection.getSelectedText() joins selected text in top-to-bottom, left-to-right order.

Each text buffer converts the global cell rectangle to local coordinates. TextBufferRenderable.getSelection() then returns { start, end }.

These offsets form a half-open range from the start of that text buffer. They count terminal display width, and each line break adds one unit.

The offsets are not UTF-16 indexes. Selection boundaries snap around complete grapheme clusters, including a cluster that spans multiple cells.

Read Text and terminal cells before you combine selection offsets with JavaScript string methods.

Test interaction#

createTestRenderer() returns mockMouse and mockInput. Both send terminal sequences through the real parser.

import { InputRenderable, TextRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"

const setup = await createTestRenderer({ width: 30, height: 6 })

try {
  const text = new TextRenderable(setup.renderer, { content: "select this", width: 11 })
  const input = new InputRenderable(setup.renderer, { position: "absolute", top: 2, width: 20 })
  setup.renderer.root.add(text)
  setup.renderer.root.add(input)
  await setup.renderOnce()

  await setup.mockMouse.drag(text.x, text.y, text.x + 5, text.y)
  input.focus()
  await setup.mockInput.typeText("hello")

  console.log(setup.renderer.getSelection()?.getSelectedText())
  console.log(input.value)
} finally {
  setup.renderer.destroy()
}

See Testing for MockMouse, MockInput, frame capture, and capability fixtures.

Terminal limitations#

Mouse protocols, focus reports, hyperlinks, and modifier detail vary by terminal. Keyboard-only operation must not depend on pointer hover or drag.

OpenTUI does not create a browser accessibility tree. Supply visible focus state, keyboard alternatives, and text labels in your application.

Next#

Read Keyboard input for event ownership. See Input, Textarea, Text, Box, and Embedded terminal for component behavior.