Files
oh-my-pi/packages/tui/src/mouse.ts
T
Wolfgang Schoenberger e5e29219fe feat(tui): add shared SGR mouse-routing helpers and SelectList.routeMouse
Add routeSgrMouseInput() and routeSelectListMouse() to centralize the
repeated SGR parse + SelectList hit-test/hover/wheel/click patterns, and
make SelectList implement MouseRoutable via routeMouse(). The structural
SelectListMouseTarget interface keeps mouse.ts free of component imports.

Extend the emergency-restore tests to assert mouse-off sequences
(1006l/1003l/1000l) are emitted while keeping the alt-screen 1049 gating.
2026-06-24 20:09:48 -07:00

106 lines
3.8 KiB
TypeScript

/**
* SGR mouse report parsing (`\x1b[<button;col;rowM` / `…m`).
*
* Mouse tracking is enabled only while a fullscreen overlay holds the
* alternate screen (see tui.ts MOUSE_TRACKING_ON), so consumers are
* fullscreen components hit-testing against their own rendered frame:
* the frame paints from screen row 0, hence `row`/`col` are exposed
* 0-based for direct indexing into rendered lines.
*/
/** A decoded SGR mouse report. */
export interface SgrMouseEvent {
/** Raw button code (bit 32 = motion, bit 64 = wheel, low bits = button). */
button: number;
/** 0-based column of the event. */
col: number;
/** 0-based row of the event. */
row: number;
/** True for a release report (`m` suffix). */
release: boolean;
/** Wheel direction: -1 up, 1 down, null when not a wheel event. */
wheel: -1 | 1 | null;
/** True when the pointer moved (hover or drag) rather than clicked. */
motion: boolean;
/** True for a left-button press (not motion, not release, not wheel). */
leftClick: boolean;
}
/**
* Decode an SGR mouse report, or return null when `data` is not one.
* Callers on hot keypress paths should pre-check `data.startsWith("\x1b[<")`
* before paying for the regex.
*/
export function parseSgrMouse(data: string): SgrMouseEvent | null {
const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data);
if (!match) return null;
const button = Number(match[1]);
const col = Number(match[2]) - 1;
const row = Number(match[3]) - 1;
const release = match[4] === "m";
const wheel = button & 64 ? ((button & 1 ? 1 : -1) as 1 | -1) : null;
const motion = (button & 32) !== 0 && wheel === null;
const leftClick = !release && wheel === null && !motion && (button & 3) === 0;
return { button, col, row, release, wheel, motion, leftClick };
}
/** Handler invoked with a decoded SGR event; returning `false` reports unhandled. */
export type SgrMouseHandler = (event: SgrMouseEvent) => boolean | undefined;
/**
* Decode an SGR mouse report and forward it to `handler`. Returns `false` when
* `data` is not an SGR mouse report (or fails to parse), so callers can fall
* through to other input handling. Centralizes the repeated
* `data.startsWith("\x1b[<")` + `parseSgrMouse()` pattern.
*/
export function routeSgrMouseInput(data: string, handler: SgrMouseHandler): boolean {
if (!data.startsWith("\x1b[<")) return false;
const event = parseSgrMouse(data);
if (!event) return false;
return handler(event) !== false;
}
/**
* Structural view of a SelectList-like target for mouse routing. Declared here
* (rather than importing the component) to keep this core module free of any
* component-to-core import cycle.
*/
export interface SelectListMouseTarget {
handleWheel(delta: -1 | 1): void;
hitTest(line: number): number | undefined;
setHoverIndex(index: number | null): void;
clickItem(index: number): void;
}
/**
* Route a decoded mouse event against a SelectList-like target at the given
* 0-based frame-local `line`. Centralizes the repeated wheel/hit-test/hover/
* click pattern. Returns `true` when the event was consumed.
*/
export function routeSelectListMouse(target: SelectListMouseTarget, event: SgrMouseEvent, line: number): boolean {
if (event.wheel !== null) {
target.handleWheel(event.wheel);
return true;
}
const index = target.hitTest(line);
if (event.motion) {
target.setHoverIndex(index ?? null);
return true;
}
if (event.leftClick && index !== undefined) {
target.clickItem(index);
return true;
}
return false;
}
/**
* Implemented by components that accept routed mouse events at frame-local
* coordinates. Hosts translate screen coordinates to the component's own
* rendered lines before forwarding.
*/
export interface MouseRoutable {
/** `line`/`col` are 0-based within the component's rendered output. */
routeMouse(event: SgrMouseEvent, line: number, col: number): void;
}