mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(tui): add reusable mouse interaction support
This commit is contained in:
parent
19fbf94110
commit
71026970a0
20 changed files with 1252 additions and 57 deletions
|
|
@ -12,6 +12,7 @@
|
|||
|
||||
### Added
|
||||
|
||||
- Added per-block fullscreen mouse expansion for thinking sections and tool results, while preserving drag selection and link activation.
|
||||
- Added fullscreen transcript search with `Ctrl+Shift+F`, incremental match highlighting, configurable search match theme colors, and next/previous navigation with `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G`.
|
||||
- Added experimental strict JSON-schema constrained sampling for the default `read`, `bash`, `edit`, and `write` tools under `PI_EXPERIMENTAL=1`.
|
||||
- Added a fullscreen exit output setting to choose between printing the final transcript and only a session resume hint.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ All components implement:
|
|||
interface Component {
|
||||
render(width: number): string[];
|
||||
handleInput?(data: string): void;
|
||||
handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
|
||||
wantsKeyRelease?: boolean;
|
||||
invalidate(): void;
|
||||
}
|
||||
|
|
@ -23,6 +24,7 @@ interface Component {
|
|||
|--------|-------------|
|
||||
| `render(width)` | Return array of strings (one per line). Each line **must not exceed `width`**. |
|
||||
| `handleInput?(data)` | Receive keyboard input when component has focus. |
|
||||
| `handleMouse?(event)` | Receive normalized pointer input in fullscreen mode. |
|
||||
| `wantsKeyRelease?` | If true, component receives key release events (Kitty protocol). Default: false. |
|
||||
| `invalidate()` | Clear cached render state. Called on theme changes. |
|
||||
|
||||
|
|
@ -307,6 +309,22 @@ handleInput(data: string) {
|
|||
- With modifiers: `Key.ctrl("c")`, `Key.shift("tab")`, `Key.alt("left")`, `Key.ctrlShift("p")`
|
||||
- String format also works: `"enter"`, `"ctrl+c"`, `"shift+tab"`, `"ctrl+shift+p"`
|
||||
|
||||
## Mouse Input
|
||||
|
||||
Fullscreen mode routes normalized press, release, click, move, drag, and wheel events to components and overlays. Return `{ handled: true }` to suppress default behavior, `capture: true` to retain drag/release ownership, `focus: true` to request keyboard focus, and `render: true` when a hover or release visibly changes the component. Press, click, drag, and wheel render by default; no-op move/release events do not.
|
||||
|
||||
```typescript
|
||||
import { MouseRegion } from "@earendil-works/pi-tui";
|
||||
|
||||
const clickable = new MouseRegion(content, (event) => {
|
||||
if (event.type !== "click" || event.button !== "left") return undefined;
|
||||
expanded = !expanded;
|
||||
return { handled: true };
|
||||
});
|
||||
```
|
||||
|
||||
Unhandled wheel input scrolls the nearest `ScrollView`; unhandled primary-button drags retain transcript selection. OSC 8 links take precedence over parent click regions. `Input`, `Editor`, `SelectList`, and `SettingsList` include fullscreen mouse behavior. Regular mode does not capture mouse input because the terminal owns its scrollback.
|
||||
|
||||
## Line Width
|
||||
|
||||
**Critical:** Each line from `render()` must not exceed the `width` parameter.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { Container, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui";
|
||||
import { Container, Markdown, type MarkdownTheme, MouseRegion, Spacer, Text } from "@earendil-works/pi-tui";
|
||||
import type { MarkdownTransformer } from "../../../core/extensions/types.ts";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.ts";
|
||||
import { createMarkdownTransform } from "./markdown-transform.ts";
|
||||
|
|
@ -21,6 +21,7 @@ export class AssistantMessageComponent extends Container {
|
|||
private lastMessage?: AssistantMessage;
|
||||
private hasToolCalls = false;
|
||||
private isStreaming = false;
|
||||
private thinkingVisibilityOverrides = new Map<number, boolean>();
|
||||
|
||||
constructor(
|
||||
message?: AssistantMessage,
|
||||
|
|
@ -56,6 +57,7 @@ export class AssistantMessageComponent extends Container {
|
|||
|
||||
setHideThinkingBlock(hide: boolean): void {
|
||||
this.hideThinkingBlock = hide;
|
||||
this.thinkingVisibilityOverrides.clear();
|
||||
if (this.lastMessage) {
|
||||
this.updateContent(this.lastMessage);
|
||||
}
|
||||
|
|
@ -102,6 +104,7 @@ export class AssistantMessageComponent extends Container {
|
|||
}
|
||||
|
||||
// Render content in order
|
||||
let thinkingRunIndex = 0;
|
||||
for (let i = 0; i < message.content.length; i++) {
|
||||
const content = message.content[i];
|
||||
if (content.type === "text" && content.text.trim()) {
|
||||
|
|
@ -136,15 +139,11 @@ export class AssistantMessageComponent extends Container {
|
|||
.slice(i + 1)
|
||||
.some((c) => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()));
|
||||
|
||||
if (this.hideThinkingBlock) {
|
||||
// Show one static label for each run of thinking blocks when hidden.
|
||||
this.contentContainer.addChild(
|
||||
new Text(theme.italic(theme.fg("thinkingText", this.hiddenThinkingLabel)), this.outputPad, 0),
|
||||
);
|
||||
} else {
|
||||
// Render each run of thinking blocks as one Markdown section.
|
||||
this.contentContainer.addChild(
|
||||
new Markdown(
|
||||
const runIndex = thinkingRunIndex++;
|
||||
const hidden = this.thinkingVisibilityOverrides.get(runIndex) ?? this.hideThinkingBlock;
|
||||
const thinkingComponent = hidden
|
||||
? new Text(theme.italic(theme.fg("thinkingText", this.hiddenThinkingLabel)), this.outputPad, 0)
|
||||
: new Markdown(
|
||||
thinkingBlocks.join("\n\n"),
|
||||
this.outputPad,
|
||||
0,
|
||||
|
|
@ -160,9 +159,15 @@ export class AssistantMessageComponent extends Container {
|
|||
this.markdownTransformers,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
this.contentContainer.addChild(
|
||||
new MouseRegion(thinkingComponent, (event) => {
|
||||
if (event.type !== "click" || event.button !== "left") return undefined;
|
||||
this.thinkingVisibilityOverrides.set(runIndex, !hidden);
|
||||
if (this.lastMessage) this.updateContent(this.lastMessage);
|
||||
return { handled: true };
|
||||
}),
|
||||
);
|
||||
if (hasVisibleContentAfter) {
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,15 @@
|
|||
import { Box, type Component, Container, getCapabilities, Image, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
|
||||
import {
|
||||
Box,
|
||||
type Component,
|
||||
Container,
|
||||
getCapabilities,
|
||||
Image,
|
||||
MouseRegion,
|
||||
Spacer,
|
||||
Text,
|
||||
type TUI,
|
||||
type TuiMouseEvent,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import type { ToolDefinition, ToolRenderContext } from "../../../core/extensions/types.ts";
|
||||
import { createAllToolDefinitions, type ToolName } from "../../../core/tools/index.ts";
|
||||
import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.ts";
|
||||
|
|
@ -16,7 +27,9 @@ export interface ToolExecutionOptions {
|
|||
export class ToolExecutionComponent extends Container {
|
||||
private contentBox: Box;
|
||||
private contentText: Text;
|
||||
private contentTextRegion: MouseRegion;
|
||||
private selfRenderContainer: Container;
|
||||
private selfRenderHeight = 0;
|
||||
private callRendererComponent?: Component;
|
||||
private resultRendererComponent?: Component;
|
||||
private rendererState: any = {};
|
||||
|
|
@ -70,12 +83,13 @@ export class ToolExecutionComponent extends Container {
|
|||
// contentText is reserved for generic fallback rendering when no tool definition exists.
|
||||
this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
|
||||
this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text));
|
||||
this.contentTextRegion = this.createResultRegion(this.contentText);
|
||||
this.selfRenderContainer = new Container();
|
||||
|
||||
if (this.hasRendererDefinition()) {
|
||||
this.addChild(this.getRenderShell() === "self" ? this.selfRenderContainer : this.contentBox);
|
||||
} else {
|
||||
this.addChild(this.contentText);
|
||||
this.addChild(this.contentTextRegion);
|
||||
}
|
||||
|
||||
this.updateDisplay();
|
||||
|
|
@ -155,6 +169,14 @@ export class ToolExecutionComponent extends Container {
|
|||
return new Text(text, 0, 0);
|
||||
}
|
||||
|
||||
private createResultRegion(component: Component): MouseRegion {
|
||||
return new MouseRegion(component, (event) => {
|
||||
if (!this.result || event.type !== "click" || event.button !== "left") return undefined;
|
||||
this.setExpanded(!this.expanded);
|
||||
return { handled: true };
|
||||
});
|
||||
}
|
||||
|
||||
updateArgs(args: any): void {
|
||||
this.args = args;
|
||||
this.updateDisplay();
|
||||
|
|
@ -236,6 +258,7 @@ export class ToolExecutionComponent extends Container {
|
|||
|
||||
if (this.hasRendererDefinition() && this.getRenderShell() === "self") {
|
||||
const contentLines = this.selfRenderContainer.render(width);
|
||||
this.selfRenderHeight = contentLines.length;
|
||||
if (contentLines.length === 0 && this.imageComponents.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -261,6 +284,16 @@ export class ToolExecutionComponent extends Container {
|
|||
return super.render(width);
|
||||
}
|
||||
|
||||
override handleMouse(event: TuiMouseEvent): ReturnType<Container["handleMouse"]> {
|
||||
if (!this.hasRendererDefinition() || this.getRenderShell() !== "self") return super.handleMouse(event);
|
||||
if (event.y <= 0 || event.y > this.selfRenderHeight) return undefined;
|
||||
return this.selfRenderContainer.handleMouse({
|
||||
...event,
|
||||
y: event.y - 1,
|
||||
height: this.selfRenderHeight,
|
||||
});
|
||||
}
|
||||
|
||||
private updateDisplay(): void {
|
||||
const bgFn = this.isPartial
|
||||
? (text: string) => theme.bg("toolPendingBg", text)
|
||||
|
|
@ -279,17 +312,17 @@ export class ToolExecutionComponent extends Container {
|
|||
|
||||
const callRenderer = this.getCallRenderer();
|
||||
if (!callRenderer) {
|
||||
renderContainer.addChild(this.createCallFallback());
|
||||
renderContainer.addChild(this.createResultRegion(this.createCallFallback()));
|
||||
hasContent = true;
|
||||
} else {
|
||||
try {
|
||||
const component = callRenderer(this.args, theme, this.getRenderContext(this.callRendererComponent));
|
||||
this.callRendererComponent = component;
|
||||
renderContainer.addChild(component);
|
||||
renderContainer.addChild(this.createResultRegion(component));
|
||||
hasContent = true;
|
||||
} catch {
|
||||
this.callRendererComponent = undefined;
|
||||
renderContainer.addChild(this.createCallFallback());
|
||||
renderContainer.addChild(this.createResultRegion(this.createCallFallback()));
|
||||
hasContent = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -299,7 +332,7 @@ export class ToolExecutionComponent extends Container {
|
|||
if (!resultRenderer) {
|
||||
const component = this.createResultFallback();
|
||||
if (component) {
|
||||
renderContainer.addChild(component);
|
||||
renderContainer.addChild(this.createResultRegion(component));
|
||||
hasContent = true;
|
||||
}
|
||||
} else {
|
||||
|
|
@ -311,13 +344,13 @@ export class ToolExecutionComponent extends Container {
|
|||
this.getRenderContext(this.resultRendererComponent),
|
||||
);
|
||||
this.resultRendererComponent = component;
|
||||
renderContainer.addChild(component);
|
||||
renderContainer.addChild(this.createResultRegion(component));
|
||||
hasContent = true;
|
||||
} catch {
|
||||
this.resultRendererComponent = undefined;
|
||||
const component = this.createResultFallback();
|
||||
if (component) {
|
||||
renderContainer.addChild(component);
|
||||
renderContainer.addChild(this.createResultRegion(component));
|
||||
hasContent = true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import type { TuiMouseEvent } from "@earendil-works/pi-tui";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { AssistantMessageComponent } from "../src/modes/interactive/components/assistant-message.ts";
|
||||
import { UserMessageComponent } from "../src/modes/interactive/components/user-message.ts";
|
||||
|
|
@ -91,6 +92,41 @@ describe("AssistantMessageComponent", () => {
|
|||
expect(rendered).toContain("answer");
|
||||
});
|
||||
|
||||
test("collapses individual thinking runs when clicked", () => {
|
||||
initTheme("dark");
|
||||
const component = new AssistantMessageComponent(
|
||||
createAssistantMessage([
|
||||
{ type: "thinking", thinking: "first reasoning" },
|
||||
{ type: "text", text: "answer" },
|
||||
{ type: "thinking", thinking: "second reasoning" },
|
||||
]),
|
||||
);
|
||||
const width = 80;
|
||||
const lines = component.render(width);
|
||||
const firstThinkingRow = lines.findIndex((line) => stripAnsi(line).includes("first reasoning"));
|
||||
expect(firstThinkingRow).toBeGreaterThanOrEqual(0);
|
||||
const event: TuiMouseEvent = {
|
||||
type: "click",
|
||||
button: "left",
|
||||
x: 1,
|
||||
y: firstThinkingRow,
|
||||
screenX: 1,
|
||||
screenY: firstThinkingRow,
|
||||
width,
|
||||
height: lines.length,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
clickCount: 1,
|
||||
};
|
||||
expect(component.handleMouse(event)?.handled).toBe(true);
|
||||
|
||||
const collapsed = stripAnsi(component.render(width).join("\n"));
|
||||
expect(collapsed).not.toContain("first reasoning");
|
||||
expect(collapsed).toContain("Thinking...");
|
||||
expect(collapsed).toContain("second reasoning");
|
||||
});
|
||||
|
||||
test("uses configured output padding for text and thinking", () => {
|
||||
initTheme("dark");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { join, resolve } from "node:path";
|
||||
import { Text, type TUI } from "@earendil-works/pi-tui";
|
||||
import { Text, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
||||
import { Type } from "typebox";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
import { getReadmePath } from "../src/config.ts";
|
||||
|
|
@ -429,6 +429,42 @@ describe("ToolExecutionComponent parity", () => {
|
|||
expect(rendered).toContain(theme.fg("toolOutput", error));
|
||||
});
|
||||
|
||||
test("expands a collapsed tool result when clicked", () => {
|
||||
const component = new ToolExecutionComponent(
|
||||
"read",
|
||||
"tool-click-expand",
|
||||
{ path: "notes.txt" },
|
||||
{},
|
||||
createReadToolDefinition(process.cwd()),
|
||||
createFakeTui(),
|
||||
process.cwd(),
|
||||
);
|
||||
component.updateResult(
|
||||
{ content: [{ type: "text", text: "hidden content" }], details: undefined, isError: false },
|
||||
false,
|
||||
);
|
||||
const width = 120;
|
||||
const lines = component.render(width);
|
||||
const resultRow = lines.findIndex((line) => stripAnsi(line).includes("notes.txt"));
|
||||
expect(resultRow).toBeGreaterThanOrEqual(0);
|
||||
const event: TuiMouseEvent = {
|
||||
type: "click",
|
||||
button: "left",
|
||||
x: 2,
|
||||
y: resultRow,
|
||||
screenX: 2,
|
||||
screenY: resultRow,
|
||||
width,
|
||||
height: lines.length,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
clickCount: 1,
|
||||
};
|
||||
expect(component.handleMouse(event)?.handled).toBe(true);
|
||||
expect(stripAnsi(component.render(width).join("\n"))).toContain("hidden content");
|
||||
});
|
||||
|
||||
test("collapses ordinary read results until expanded", () => {
|
||||
const component = new ToolExecutionComponent(
|
||||
"read",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added fullscreen component mouse dispatch with hit-testing, bubbling through `Container` and `Box`, pointer capture, focus transfer, overlays, render control, and the `MouseRegion` wrapper; `Input`, `Editor`, `SelectList`, and `SettingsList` now support pointer interaction.
|
||||
|
||||
## [0.84.2] - 2026-08-14
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Minimal terminal UI framework with differential rendering and synchronized outpu
|
|||
- **Bracketed Paste Mode**: Handles large pastes correctly with markers for >10 line pastes
|
||||
- **Component-based**: Simple Component interface with render() method
|
||||
- **Theme Support**: Components accept theme interfaces for customizable styling
|
||||
- **Built-in Components**: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, Spacer, Image, Box, Container, VStack, HStack, ScrollView
|
||||
- **Built-in Components**: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, MouseRegion, Spacer, Image, Box, Container, VStack, HStack, ScrollView
|
||||
- **Inline Images**: Renders images in terminals that support Kitty or iTerm2 graphics protocols
|
||||
- **Autocomplete Support**: File paths and slash commands
|
||||
|
||||
|
|
@ -211,6 +211,7 @@ All components implement:
|
|||
interface Component {
|
||||
render(width: number): string[];
|
||||
handleInput?(data: string): void;
|
||||
handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
|
||||
invalidate?(): void;
|
||||
}
|
||||
```
|
||||
|
|
@ -219,10 +220,50 @@ interface Component {
|
|||
|--------|-------------|
|
||||
| `render(width)` | Returns an array of strings, one per line. Each line **must not exceed `width`** or the TUI will error. Use `truncateToWidth()` or manual wrapping to ensure this. |
|
||||
| `handleInput?(data)` | Called when the component has focus and receives keyboard input. The `data` string contains raw terminal input (may include ANSI escape sequences). |
|
||||
| `handleMouse?(event)` | Called by `TuiAltScreen` for normalized pointer input targeted at the component. |
|
||||
| `invalidate?()` | Called to clear any cached render state. Components should re-render from scratch on the next `render()` call. |
|
||||
|
||||
The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered line. Styles do not carry across lines. If you emit multi-line text with styling, reapply styles per line or use `wrapTextWithAnsi()` so styles are preserved for each wrapped line.
|
||||
|
||||
### Mouse Input
|
||||
|
||||
`TuiAltScreen` normalizes SGR mouse input and hit-tests components and overlays. Events contain component-local `x`/`y`, absolute `screenX`/`screenY`, bounds, button, modifiers, click count, and wheel delta. `TuiMainScreen` does not capture mouse input because the terminal owns its scrollback.
|
||||
|
||||
```typescript
|
||||
import type { TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
|
||||
|
||||
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
||||
if (event.type === "click" && event.button === "left") {
|
||||
this.expanded = !this.expanded;
|
||||
return { handled: true };
|
||||
}
|
||||
if (event.type === "press" && event.button === "left") {
|
||||
return { handled: true, capture: true, focus: true };
|
||||
}
|
||||
if (event.type === "drag") {
|
||||
this.updateFromPointer(event.x, event.y);
|
||||
return { handled: true, render: true };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
```
|
||||
|
||||
Returning `handled` suppresses renderer-level fallback behavior. `capture` keeps subsequent drag and release events routed to the same component. `focus` requests keyboard focus. The optional `render` flag controls repainting: press, click, drag, and wheel default to rendering; move and release do not. Set `render: true` for a hover state that visibly changed, or `render: false` for a handled no-op. Render requests are coalesced and terminal output remains differential.
|
||||
|
||||
Unhandled gestures retain alternate-screen defaults: wheel input scrolls the nearest `ScrollView` and chains unused delta, primary-button drags select text, OSC 8 links open before parent click handlers, and unhandled right-click preserves configured paste behavior. A click is emitted only when press/release completes without a drag.
|
||||
|
||||
Use `MouseRegion` to add mouse behavior without changing a component's rendering:
|
||||
|
||||
```typescript
|
||||
const collapsible = new MouseRegion(content, (event) => {
|
||||
if (event.type !== "click" || event.button !== "left") return undefined;
|
||||
expanded = !expanded;
|
||||
return { handled: true };
|
||||
});
|
||||
```
|
||||
|
||||
`Container` and `Box` route events to nested children using geometry recorded by the last rendered frame, so pointer motion does not rerender children merely to hit-test them. Explicit `VStack`, `HStack`, and `ScrollView` layouts use the alternate-screen layout frame directly.
|
||||
|
||||
### Focusable Interface (IME Support)
|
||||
|
||||
Components that display a text cursor and need IME (Input Method Editor) support should implement the `Focusable` interface:
|
||||
|
|
@ -339,6 +380,8 @@ input.setValue("initial");
|
|||
input.getValue();
|
||||
```
|
||||
|
||||
Clicking positions the cursor and gives the input keyboard focus in alternate-screen mode.
|
||||
|
||||
**Key Bindings:**
|
||||
- `Enter` - Submit
|
||||
- `Ctrl+A` / `Ctrl+E` - Line start/end
|
||||
|
|
@ -374,6 +417,7 @@ editor.getPaddingX(); // Get current padding
|
|||
```
|
||||
|
||||
**Features:**
|
||||
- Click-to-position cursor and clickable autocomplete rows in alternate-screen mode
|
||||
- Multi-line editing with word wrap
|
||||
- Slash command autocomplete (type `/`)
|
||||
- File path autocomplete (press `Tab`)
|
||||
|
|
@ -514,6 +558,8 @@ list.setFilter("opt"); // Filter items
|
|||
```
|
||||
|
||||
**Controls:**
|
||||
- Mouse move/wheel: Highlight rows in alternate-screen mode
|
||||
- Click: Select a row
|
||||
- Arrow keys: Navigate
|
||||
- Enter: Select
|
||||
- Escape: Cancel
|
||||
|
|
@ -554,6 +600,8 @@ settings.updateValue("theme", "light");
|
|||
```
|
||||
|
||||
**Controls:**
|
||||
- Mouse move/wheel: Highlight rows in alternate-screen mode
|
||||
- Click: Activate a row
|
||||
- Arrow keys: Navigate
|
||||
- Enter/Space: Activate (cycle value or open submenu)
|
||||
- Escape: Cancel
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Component } from "../tui.ts";
|
||||
import { type Component, dispatchMouseEvent, type TuiMouseDispatchResult, type TuiMouseEvent } from "../tui.ts";
|
||||
import { applyBackgroundToLine, visibleWidth } from "../utils.ts";
|
||||
|
||||
type RenderCache = {
|
||||
|
|
@ -19,6 +19,7 @@ export class Box implements Component {
|
|||
|
||||
// Cache for rendered output
|
||||
private cache?: RenderCache;
|
||||
private mouseLayout?: { width: number; children: Array<{ component: Component; height: number }> };
|
||||
|
||||
constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) {
|
||||
this.paddingX = paddingX;
|
||||
|
|
@ -71,6 +72,32 @@ export class Box implements Component {
|
|||
}
|
||||
}
|
||||
|
||||
handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
|
||||
const contentWidth = Math.max(1, event.width - this.paddingX * 2);
|
||||
const contentY = event.y - this.paddingY;
|
||||
const contentX = event.x - this.paddingX;
|
||||
if (contentY < 0 || contentX < 0 || contentX >= contentWidth) return undefined;
|
||||
|
||||
const mouseChildren =
|
||||
this.mouseLayout?.width === contentWidth
|
||||
? this.mouseLayout.children
|
||||
: this.children.map((component) => ({ component, height: component.render(contentWidth).length }));
|
||||
let childY = 0;
|
||||
for (const { component: child, height: childHeight } of mouseChildren) {
|
||||
if (contentY >= childY && contentY < childY + childHeight) {
|
||||
return dispatchMouseEvent(child, {
|
||||
...event,
|
||||
x: contentX,
|
||||
y: contentY - childY,
|
||||
width: contentWidth,
|
||||
height: childHeight,
|
||||
});
|
||||
}
|
||||
childY += childHeight;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.children.length === 0) {
|
||||
return [];
|
||||
|
|
@ -81,12 +108,15 @@ export class Box implements Component {
|
|||
|
||||
// Render all children
|
||||
const childLines: string[] = [];
|
||||
const mouseChildren: Array<{ component: Component; height: number }> = [];
|
||||
for (const child of this.children) {
|
||||
const lines = child.render(contentWidth);
|
||||
mouseChildren.push({ component: child, height: lines.length });
|
||||
for (const line of lines) {
|
||||
childLines.push(leftPad + line);
|
||||
}
|
||||
}
|
||||
this.mouseLayout = { width: contentWidth, children: mouseChildren };
|
||||
|
||||
if (childLines.length === 0) {
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@ import type { AutocompleteProvider, AutocompleteSuggestions } from "../autocompl
|
|||
import { getKeybindings } from "../keybindings.ts";
|
||||
import { decodePrintableKey, matchesKey } from "../keys.ts";
|
||||
import { KillRing } from "../kill-ring.ts";
|
||||
import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.ts";
|
||||
import {
|
||||
type Component,
|
||||
CURSOR_MARKER,
|
||||
type Focusable,
|
||||
type TUI,
|
||||
type TuiMouseEvent,
|
||||
type TuiMouseEventResult,
|
||||
} from "../tui.ts";
|
||||
import { UndoStack } from "../undo-stack.ts";
|
||||
import {
|
||||
cjkBreakRegex,
|
||||
|
|
@ -281,8 +288,10 @@ export class Editor implements Component, Focusable {
|
|||
private theme: EditorTheme;
|
||||
private paddingX: number = 0;
|
||||
|
||||
// Store last render width for cursor navigation
|
||||
// Store last render geometry for cursor navigation and mouse hit-testing.
|
||||
private lastWidth: number = 80;
|
||||
private renderedVisibleLineCount = 1;
|
||||
private renderedAutocompleteHeight = 0;
|
||||
|
||||
// Vertical scrolling support
|
||||
private scrollOffset: number = 0;
|
||||
|
|
@ -517,6 +526,7 @@ export class Editor implements Component, Focusable {
|
|||
|
||||
// Get visible lines slice
|
||||
const visibleLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);
|
||||
this.renderedVisibleLineCount = visibleLines.length;
|
||||
|
||||
const result: string[] = [];
|
||||
const leftPadding = " ".repeat(paddingX);
|
||||
|
|
@ -588,8 +598,10 @@ export class Editor implements Component, Focusable {
|
|||
}
|
||||
|
||||
// Add autocomplete list if active
|
||||
this.renderedAutocompleteHeight = 0;
|
||||
if (this.autocompleteState && this.autocompleteList) {
|
||||
const autocompleteResult = this.autocompleteList.render(contentWidth);
|
||||
this.renderedAutocompleteHeight = autocompleteResult.length;
|
||||
for (const line of autocompleteResult) {
|
||||
const lineWidth = visibleWidth(line);
|
||||
const linePadding = " ".repeat(Math.max(0, contentWidth - lineWidth));
|
||||
|
|
@ -600,6 +612,65 @@ export class Editor implements Component, Focusable {
|
|||
return result;
|
||||
}
|
||||
|
||||
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
||||
const autocompleteStartRow = this.renderedVisibleLineCount + 2;
|
||||
if (
|
||||
this.autocompleteState &&
|
||||
this.autocompleteList &&
|
||||
event.y >= autocompleteStartRow &&
|
||||
event.y < autocompleteStartRow + this.renderedAutocompleteHeight
|
||||
) {
|
||||
const maxPadding = Math.max(0, Math.floor((event.width - 1) / 2));
|
||||
const paddingX = Math.min(this.paddingX, maxPadding);
|
||||
const contentWidth = Math.max(1, event.width - paddingX * 2);
|
||||
const result = this.autocompleteList.handleMouse?.({
|
||||
...event,
|
||||
x: event.x - paddingX,
|
||||
y: event.y - autocompleteStartRow,
|
||||
width: contentWidth,
|
||||
height: this.renderedAutocompleteHeight,
|
||||
});
|
||||
return result ? { ...result, focus: true } : undefined;
|
||||
}
|
||||
|
||||
if (event.type !== "press" || event.button !== "left") return undefined;
|
||||
if (event.y <= 0 || event.y > this.renderedVisibleLineCount) return { handled: true, focus: true };
|
||||
|
||||
const visualLines = this.buildVisualLineMap(this.lastWidth);
|
||||
const visualLineIndex = this.scrollOffset + event.y - 1;
|
||||
const visualLine = visualLines[visualLineIndex];
|
||||
if (!visualLine) return { handled: true, focus: true };
|
||||
const logicalLine = this.state.lines[visualLine.logicalLine] ?? "";
|
||||
const chunkEnd = visualLine.startCol + visualLine.length;
|
||||
const chunk = logicalLine.slice(visualLine.startCol, chunkEnd);
|
||||
const maxPadding = Math.max(0, Math.floor((event.width - 1) / 2));
|
||||
const paddingX = Math.min(this.paddingX, maxPadding);
|
||||
const targetColumn = Math.max(0, event.x - paddingX);
|
||||
let visibleColumn = 0;
|
||||
let targetIndex = chunk.length;
|
||||
let lastGraphemeIndex = 0;
|
||||
for (const grapheme of this.segment(chunk, "grapheme")) {
|
||||
const nextColumn = visibleColumn + visibleWidth(grapheme.segment);
|
||||
lastGraphemeIndex = grapheme.index;
|
||||
if (targetColumn < nextColumn) {
|
||||
targetIndex = grapheme.index;
|
||||
break;
|
||||
}
|
||||
visibleColumn = nextColumn;
|
||||
}
|
||||
const isLastSegment =
|
||||
visualLineIndex === visualLines.length - 1 ||
|
||||
visualLines[visualLineIndex + 1]?.logicalLine !== visualLine.logicalLine;
|
||||
if (!isLastSegment && targetIndex === chunk.length && chunk.length > 0) targetIndex = lastGraphemeIndex;
|
||||
|
||||
this.state.cursorLine = visualLine.logicalLine;
|
||||
this.setCursorCol(visualLine.startCol + targetIndex);
|
||||
this.lastAction = null;
|
||||
this.exitHistoryBrowsing();
|
||||
if (this.autocompleteState) this.updateAutocomplete();
|
||||
return { handled: true, focus: true };
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
const kb = getKeybindings();
|
||||
|
||||
|
|
@ -2146,7 +2217,25 @@ export class Editor implements Component, Focusable {
|
|||
items: Array<{ value: string; label: string; description?: string }>,
|
||||
): SelectList {
|
||||
const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined;
|
||||
return new SelectList(items, this.autocompleteMaxVisible, this.theme.selectList, layout);
|
||||
const list = new SelectList(items, this.autocompleteMaxVisible, this.theme.selectList, layout);
|
||||
list.onSelect = (selected) => {
|
||||
if (!this.autocompleteProvider) return;
|
||||
this.pushUndoSnapshot();
|
||||
this.lastAction = null;
|
||||
const result = this.autocompleteProvider.applyCompletion(
|
||||
this.state.lines,
|
||||
this.state.cursorLine,
|
||||
this.state.cursorCol,
|
||||
selected,
|
||||
this.autocompletePrefix,
|
||||
);
|
||||
this.state.lines = result.lines;
|
||||
this.state.cursorLine = result.cursorLine;
|
||||
this.setCursorCol(result.cursorCol);
|
||||
this.cancelAutocomplete();
|
||||
this.onChange?.(this.getText());
|
||||
};
|
||||
return list;
|
||||
}
|
||||
|
||||
private tryTriggerAutocomplete(explicitTab: boolean = false): void {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { getKeybindings } from "../keybindings.ts";
|
||||
import { decodeKittyPrintable } from "../keys.ts";
|
||||
import { KillRing } from "../kill-ring.ts";
|
||||
import { type Component, CURSOR_MARKER, type Focusable } from "../tui.ts";
|
||||
import { type Component, CURSOR_MARKER, type Focusable, type TuiMouseEvent, type TuiMouseEventResult } from "../tui.ts";
|
||||
import { UndoStack } from "../undo-stack.ts";
|
||||
import { getGraphemeSegmenter, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.ts";
|
||||
import { findWordBackward, findWordForward } from "../word-navigation.ts";
|
||||
|
|
@ -19,6 +19,7 @@ interface InputState {
|
|||
export class Input implements Component, Focusable {
|
||||
private value: string = "";
|
||||
private cursor: number = 0; // Cursor position in the value
|
||||
private renderedStartColumn = 0;
|
||||
public onSubmit?: (value: string) => void;
|
||||
public onEscape?: () => void;
|
||||
|
||||
|
|
@ -210,6 +211,24 @@ export class Input implements Component, Focusable {
|
|||
}
|
||||
}
|
||||
|
||||
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
||||
if (event.type !== "press" || event.button !== "left" || event.y !== 0) return undefined;
|
||||
const visibleColumn = Math.max(0, event.x - 2);
|
||||
const targetColumn = this.renderedStartColumn + visibleColumn;
|
||||
let currentColumn = 0;
|
||||
this.cursor = this.value.length;
|
||||
for (const grapheme of segmenter.segment(this.value)) {
|
||||
const nextColumn = currentColumn + visibleWidth(grapheme.segment);
|
||||
if (targetColumn < nextColumn) {
|
||||
this.cursor = grapheme.index;
|
||||
break;
|
||||
}
|
||||
currentColumn = nextColumn;
|
||||
}
|
||||
this.lastAction = null;
|
||||
return { handled: true, focus: true };
|
||||
}
|
||||
|
||||
private insertCharacter(char: string): void {
|
||||
// Undo coalescing: consecutive word chars coalesce into one undo unit
|
||||
if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
|
||||
|
|
@ -386,6 +405,7 @@ export class Input implements Component, Focusable {
|
|||
|
||||
let visibleText = "";
|
||||
let cursorDisplay = this.cursor;
|
||||
this.renderedStartColumn = 0;
|
||||
const totalWidth = visibleWidth(this.value);
|
||||
|
||||
if (totalWidth < availableWidth) {
|
||||
|
|
@ -412,6 +432,7 @@ export class Input implements Component, Focusable {
|
|||
startCol = Math.max(0, cursorCol - halfWidth);
|
||||
}
|
||||
|
||||
this.renderedStartColumn = startCol;
|
||||
visibleText = sliceByColumn(this.value, startCol, scrollWidth, true);
|
||||
const beforeCursor = sliceByColumn(this.value, startCol, Math.max(0, cursorCol - startCol), true);
|
||||
cursorDisplay = beforeCursor.length;
|
||||
|
|
|
|||
33
packages/tui/src/components/mouse-region.ts
Normal file
33
packages/tui/src/components/mouse-region.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import {
|
||||
type Component,
|
||||
dispatchMouseEvent,
|
||||
type TuiMouseDispatchResult,
|
||||
type TuiMouseEvent,
|
||||
type TuiMouseEventResult,
|
||||
} from "../tui.ts";
|
||||
|
||||
export type MouseRegionHandler = (event: TuiMouseEvent) => TuiMouseEventResult | undefined;
|
||||
|
||||
/** Adds mouse handling to an existing component without changing its rendering. */
|
||||
export class MouseRegion implements Component {
|
||||
private readonly child: Component;
|
||||
private readonly onMouse: MouseRegionHandler;
|
||||
|
||||
constructor(child: Component, onMouse: MouseRegionHandler) {
|
||||
this.child = child;
|
||||
this.onMouse = onMouse;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
return this.child.render(width);
|
||||
}
|
||||
|
||||
handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | TuiMouseEventResult | undefined {
|
||||
const childResult = dispatchMouseEvent(this.child, event);
|
||||
return childResult ?? this.onMouse(event);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.child.invalidate();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { getKeybindings } from "../keybindings.ts";
|
||||
import type { Component } from "../tui.ts";
|
||||
import type { Component, TuiMouseEvent, TuiMouseEventResult } from "../tui.ts";
|
||||
import { truncateToWidth, visibleWidth } from "../utils.ts";
|
||||
|
||||
const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
|
||||
|
|
@ -41,6 +41,7 @@ export class SelectList implements Component {
|
|||
private items: SelectItem[] = [];
|
||||
private filteredItems: SelectItem[] = [];
|
||||
private selectedIndex: number = 0;
|
||||
private mousePressedIndex: number | undefined;
|
||||
private maxVisible: number = 5;
|
||||
private theme: SelectListTheme;
|
||||
private layout: SelectListLayoutOptions;
|
||||
|
|
@ -83,11 +84,7 @@ export class SelectList implements Component {
|
|||
const primaryColumnWidth = this.getPrimaryColumnWidth();
|
||||
|
||||
// Calculate visible range with scrolling
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
|
||||
);
|
||||
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
|
||||
const { startIndex, endIndex } = this.getVisibleRange();
|
||||
|
||||
// Render visible items
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
|
|
@ -109,6 +106,46 @@ export class SelectList implements Component {
|
|||
return lines;
|
||||
}
|
||||
|
||||
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
||||
if (this.filteredItems.length === 0) return undefined;
|
||||
if (event.type === "wheel" && event.wheelDelta) {
|
||||
const delta = event.wheelDelta < 0 ? -1 : 1;
|
||||
const previousIndex = this.selectedIndex;
|
||||
this.selectedIndex = Math.max(0, Math.min(this.filteredItems.length - 1, this.selectedIndex + delta));
|
||||
if (this.selectedIndex !== previousIndex) this.notifySelectionChange();
|
||||
return { handled: true, render: this.selectedIndex !== previousIndex };
|
||||
}
|
||||
if (event.type !== "move" && event.button !== "left") return undefined;
|
||||
const { startIndex, endIndex } = this.getVisibleRange();
|
||||
const itemIndex = startIndex + event.y;
|
||||
if (itemIndex < startIndex || itemIndex >= endIndex) return undefined;
|
||||
|
||||
if (event.type === "move" || event.type === "press") {
|
||||
if (event.type === "press") this.mousePressedIndex = itemIndex;
|
||||
const changed = this.selectedIndex !== itemIndex;
|
||||
if (changed) {
|
||||
this.selectedIndex = itemIndex;
|
||||
this.notifySelectionChange();
|
||||
}
|
||||
return {
|
||||
handled: true,
|
||||
focus: event.type === "press",
|
||||
...(event.type === "move" ? { render: changed } : {}),
|
||||
};
|
||||
}
|
||||
if (event.type === "click") {
|
||||
const clickedIndex = this.mousePressedIndex ?? itemIndex;
|
||||
this.mousePressedIndex = undefined;
|
||||
const changed = this.selectedIndex !== clickedIndex;
|
||||
this.selectedIndex = clickedIndex;
|
||||
if (changed) this.notifySelectionChange();
|
||||
const selectedItem = this.filteredItems[this.selectedIndex];
|
||||
if (selectedItem) this.onSelect?.(selectedItem);
|
||||
return { handled: true };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
const kb = getKeybindings();
|
||||
// Up arrow - wrap to bottom when at top
|
||||
|
|
@ -136,6 +173,17 @@ export class SelectList implements Component {
|
|||
}
|
||||
}
|
||||
|
||||
private getVisibleRange(): { startIndex: number; endIndex: number } {
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
|
||||
);
|
||||
return {
|
||||
startIndex,
|
||||
endIndex: Math.min(startIndex + this.maxVisible, this.filteredItems.length),
|
||||
};
|
||||
}
|
||||
|
||||
private renderItem(
|
||||
item: SelectItem,
|
||||
isSelected: boolean,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { fuzzyFilter } from "../fuzzy.ts";
|
||||
import { getKeybindings } from "../keybindings.ts";
|
||||
import type { Component } from "../tui.ts";
|
||||
import type { Component, TuiMouseEvent, TuiMouseEventResult } from "../tui.ts";
|
||||
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.ts";
|
||||
import { Input } from "./input.ts";
|
||||
|
||||
|
|
@ -36,6 +36,7 @@ export class SettingsList implements Component {
|
|||
private filteredItems: SettingItem[];
|
||||
private theme: SettingsListTheme;
|
||||
private selectedIndex = 0;
|
||||
private mousePressedIndex: number | undefined;
|
||||
private maxVisible: number;
|
||||
private onChange: (id: string, newValue: string) => void;
|
||||
private onCancel: () => void;
|
||||
|
|
@ -103,7 +104,7 @@ export class SettingsList implements Component {
|
|||
return lines;
|
||||
}
|
||||
|
||||
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
||||
const displayItems = this.getDisplayItems();
|
||||
if (displayItems.length === 0) {
|
||||
lines.push(truncateToWidth(this.theme.hint(" No matching settings"), width));
|
||||
this.addHintLine(lines, width);
|
||||
|
|
@ -111,11 +112,7 @@ export class SettingsList implements Component {
|
|||
}
|
||||
|
||||
// Calculate visible range with scrolling
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), displayItems.length - this.maxVisible),
|
||||
);
|
||||
const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length);
|
||||
const { startIndex, endIndex } = this.getVisibleRange(displayItems);
|
||||
|
||||
// Calculate max label width for alignment
|
||||
const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label))));
|
||||
|
|
@ -165,6 +162,53 @@ export class SettingsList implements Component {
|
|||
return lines;
|
||||
}
|
||||
|
||||
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
||||
if (this.submenuComponent) {
|
||||
const result = this.submenuComponent.handleMouse?.(event);
|
||||
return result ? { ...result, focus: true } : undefined;
|
||||
}
|
||||
|
||||
if (this.searchEnabled && this.searchInput) {
|
||||
if (event.y === 0) {
|
||||
const result = this.searchInput.handleMouse?.(event);
|
||||
return result ? { ...result, focus: true } : undefined;
|
||||
}
|
||||
if (event.y === 1) return undefined;
|
||||
}
|
||||
|
||||
const displayItems = this.getDisplayItems();
|
||||
if (displayItems.length === 0) return undefined;
|
||||
if (event.type === "wheel" && event.wheelDelta) {
|
||||
const delta = event.wheelDelta < 0 ? -1 : 1;
|
||||
const previousIndex = this.selectedIndex;
|
||||
this.selectedIndex = Math.max(0, Math.min(displayItems.length - 1, this.selectedIndex + delta));
|
||||
return { handled: true, render: this.selectedIndex !== previousIndex };
|
||||
}
|
||||
if (event.type !== "move" && event.button !== "left") return undefined;
|
||||
|
||||
const rowOffset = this.searchEnabled ? 2 : 0;
|
||||
const { startIndex, endIndex } = this.getVisibleRange(displayItems);
|
||||
const itemIndex = startIndex + event.y - rowOffset;
|
||||
if (itemIndex < startIndex || itemIndex >= endIndex) return undefined;
|
||||
if (event.type === "move" || event.type === "press") {
|
||||
if (event.type === "press") this.mousePressedIndex = itemIndex;
|
||||
const changed = this.selectedIndex !== itemIndex;
|
||||
this.selectedIndex = itemIndex;
|
||||
return {
|
||||
handled: true,
|
||||
focus: event.type === "press",
|
||||
...(event.type === "move" ? { render: changed } : {}),
|
||||
};
|
||||
}
|
||||
if (event.type === "click") {
|
||||
this.selectedIndex = this.mousePressedIndex ?? itemIndex;
|
||||
this.mousePressedIndex = undefined;
|
||||
this.activateItem();
|
||||
return { handled: true };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
// If submenu is active, delegate all input to it
|
||||
// The submenu's onCancel (triggered by escape) will call done() which closes it
|
||||
|
|
@ -175,7 +219,7 @@ export class SettingsList implements Component {
|
|||
|
||||
// Main list input handling
|
||||
const kb = getKeybindings();
|
||||
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
||||
const displayItems = this.getDisplayItems();
|
||||
if (kb.matches(data, "tui.select.up")) {
|
||||
if (displayItems.length === 0) return;
|
||||
this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1;
|
||||
|
|
@ -195,8 +239,20 @@ export class SettingsList implements Component {
|
|||
}
|
||||
}
|
||||
|
||||
private getDisplayItems(): SettingItem[] {
|
||||
return this.searchEnabled ? this.filteredItems : this.items;
|
||||
}
|
||||
|
||||
private getVisibleRange(displayItems: readonly SettingItem[]): { startIndex: number; endIndex: number } {
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), displayItems.length - this.maxVisible),
|
||||
);
|
||||
return { startIndex, endIndex: Math.min(startIndex + this.maxVisible, displayItems.length) };
|
||||
}
|
||||
|
||||
private activateItem(): void {
|
||||
const item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex];
|
||||
const item = this.getDisplayItems()[this.selectedIndex];
|
||||
if (!item) return;
|
||||
|
||||
if (item.submenu) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts
|
|||
export { Input } from "./components/input.ts";
|
||||
export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts";
|
||||
export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.ts";
|
||||
export { MouseRegion, type MouseRegionHandler } from "./components/mouse-region.ts";
|
||||
export {
|
||||
ScrollView,
|
||||
type ScrollViewOptions,
|
||||
|
|
@ -131,6 +132,10 @@ export {
|
|||
type TuiInputListener,
|
||||
type TuiInputListenerResult,
|
||||
type TuiMode,
|
||||
type TuiMouseButton,
|
||||
type TuiMouseEvent,
|
||||
type TuiMouseEventResult,
|
||||
type TuiMouseEventType,
|
||||
type TuiStopOptions,
|
||||
type ViewportTUI,
|
||||
} from "./tui.ts";
|
||||
|
|
|
|||
|
|
@ -385,6 +385,19 @@ function containsPoint(rect: LayoutRect, x: number, y: number): boolean {
|
|||
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
|
||||
}
|
||||
|
||||
/** Return the visual hit path from the deepest component to the layout root. */
|
||||
export function getLayoutBoxesAt(frame: LayoutFrame, x: number, y: number): LayoutBox[] {
|
||||
const result: Array<{ box: LayoutBox; depth: number }> = [];
|
||||
const visit = (box: LayoutBox, depth: number): void => {
|
||||
if (!containsPoint(box.clip, x, y)) return;
|
||||
result.push({ box, depth });
|
||||
for (const child of box.children) visit(child, depth + 1);
|
||||
};
|
||||
visit(frame.root, 0);
|
||||
result.sort((a, b) => b.box.layer - a.box.layer || b.depth - a.depth);
|
||||
return result.map(({ box }) => box);
|
||||
}
|
||||
|
||||
export function getScrollViewBox(frame: LayoutFrame, scrollView: ScrollView): LayoutBox | undefined {
|
||||
const visit = (box: LayoutBox): LayoutBox | undefined => {
|
||||
if (box.scrollView === scrollView) return box;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { ScrollView } from "./components/scroll-view.ts";
|
|||
import { getKeybindings } from "./keybindings.ts";
|
||||
import { isKeyRelease } from "./keys.ts";
|
||||
import {
|
||||
getLayoutBoxesAt,
|
||||
getScrollbarGeometry,
|
||||
getScrollViewBox,
|
||||
getScrollViewsAt,
|
||||
|
|
@ -16,6 +17,7 @@ import {
|
|||
renderLayoutFrame,
|
||||
type ScrollbarGeometry,
|
||||
} from "./layout.ts";
|
||||
import { getLayoutNode } from "./layout-node.ts";
|
||||
import type { Terminal } from "./terminal.ts";
|
||||
import {
|
||||
deleteAllKittyImages,
|
||||
|
|
@ -30,10 +32,17 @@ import {
|
|||
} from "./terminal-image.ts";
|
||||
import {
|
||||
type Component,
|
||||
Container,
|
||||
CURSOR_MARKER,
|
||||
compositeTuiLine,
|
||||
dispatchMouseEvent,
|
||||
type OverlayHandle,
|
||||
retargetMouseEvent,
|
||||
TuiBase,
|
||||
type TuiMouseButton,
|
||||
type TuiMouseDispatchResult,
|
||||
type TuiMouseDispatchTarget,
|
||||
type TuiMouseEvent,
|
||||
type TuiStopOptions,
|
||||
VIEWPORT_TUI,
|
||||
type ViewportTUI,
|
||||
|
|
@ -109,6 +118,7 @@ interface WheelEvent {
|
|||
direction: -1 | 1;
|
||||
x: number;
|
||||
y: number;
|
||||
button: number;
|
||||
}
|
||||
|
||||
interface ScrollbarDrag {
|
||||
|
|
@ -191,6 +201,17 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
private activeSearch?: ActiveSearch;
|
||||
private pressedUrl?: string;
|
||||
private selectionDragged = false;
|
||||
private mouseCapture?: TuiMouseDispatchTarget;
|
||||
private mousePressTarget?: TuiMouseDispatchTarget;
|
||||
private mousePressPoint?: { x: number; y: number };
|
||||
private mousePressMoved = false;
|
||||
private lastComponentClick?: {
|
||||
timestamp: number;
|
||||
count: number;
|
||||
component: Component;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
private readonly wheelScrollLines: number;
|
||||
private readonly mouseEnabled: boolean;
|
||||
private readonly searchMatchStyle: (text: string) => string;
|
||||
|
|
@ -208,6 +229,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
super(terminal, showHardwareCursor, logDirectory);
|
||||
this.implicitDocument = {
|
||||
render: (width) => super.render(width),
|
||||
handleMouse: (event) => super.handleMouse(event),
|
||||
invalidate: () => {
|
||||
for (const child of this.children) child.invalidate();
|
||||
},
|
||||
|
|
@ -274,6 +296,8 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
this.lastClick = undefined;
|
||||
this.pressedUrl = undefined;
|
||||
this.selectionDragged = false;
|
||||
this.clearComponentMouseGesture();
|
||||
this.lastComponentClick = undefined;
|
||||
this.resetRenderState();
|
||||
const term = process.env.TERM?.toLowerCase() ?? "";
|
||||
// Multiplexers can lag when every pointer movement is forwarded. Button-motion
|
||||
|
|
@ -297,6 +321,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
this.selectionPressActive = false;
|
||||
this.stopScrollbarHover();
|
||||
this.stopScrollbarDrag();
|
||||
this.clearComponentMouseGesture();
|
||||
this.flashes.dispose();
|
||||
if (!this.altScreenActive) return;
|
||||
this.terminal.write(
|
||||
|
|
@ -536,6 +561,13 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
return this.isOverlayFocused() && this.activeSearch?.overlay?.isFocused() !== true;
|
||||
}
|
||||
|
||||
private clearComponentMouseGesture(): void {
|
||||
this.mouseCapture = undefined;
|
||||
this.mousePressTarget = undefined;
|
||||
this.mousePressPoint = undefined;
|
||||
this.mousePressMoved = false;
|
||||
}
|
||||
|
||||
private handleViewportInput(data: string): { consume?: boolean } | undefined {
|
||||
if (data === FOCUS_OUT) {
|
||||
const hadActiveSelection = this.selectionPressActive;
|
||||
|
|
@ -546,6 +578,8 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
this.stopScrollbarDrag();
|
||||
this.pressedUrl = undefined;
|
||||
this.selectionDragged = false;
|
||||
this.clearComponentMouseGesture();
|
||||
this.lastComponentClick = undefined;
|
||||
if (hadActiveSelection) {
|
||||
this.selectionAnchor = undefined;
|
||||
this.selectionFocus = undefined;
|
||||
|
|
@ -560,16 +594,22 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
|
||||
const wheelEvent = this.parseWheelEvent(data);
|
||||
if (wheelEvent) {
|
||||
const event = this.createMouseEvent("wheel", wheelEvent.button, wheelEvent.x, wheelEvent.y, {
|
||||
wheelDelta: wheelEvent.direction * this.wheelScrollLines,
|
||||
});
|
||||
const overlay = this.dispatchMouseToOverlay(event);
|
||||
const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(event));
|
||||
if (result) {
|
||||
if (this.applyMouseDispatchResult(event, result)) this.requestRender();
|
||||
return { consume: true };
|
||||
}
|
||||
if (this.shouldDeferViewportInputToOverlay()) return undefined;
|
||||
this.routeWheel(wheelEvent);
|
||||
return { consume: true };
|
||||
}
|
||||
const mouseEvent = this.parseSgrMouseEvent(data);
|
||||
if (mouseEvent) {
|
||||
if (this.handleRightClickPaste(mouseEvent)) return { consume: true };
|
||||
const handled = this.handleScrollbarMouseEvent(mouseEvent);
|
||||
if (!this.scrollbarDrag) this.updateScrollbarHover(mouseEvent.x, mouseEvent.y);
|
||||
if (!handled) this.handleSelectionMouseEvent(mouseEvent);
|
||||
this.handleMouseEvent(mouseEvent);
|
||||
return { consume: true };
|
||||
}
|
||||
if (this.isMouseSequence(data)) return { consume: true };
|
||||
|
|
@ -642,6 +682,171 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
private decodeMouseButton(button: number): TuiMouseButton {
|
||||
switch (button & 3) {
|
||||
case 0:
|
||||
return "left";
|
||||
case 1:
|
||||
return "middle";
|
||||
case 2:
|
||||
return "right";
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
private createMouseEvent(
|
||||
type: TuiMouseEvent["type"],
|
||||
button: number,
|
||||
x: number,
|
||||
y: number,
|
||||
extra: Partial<Pick<TuiMouseEvent, "wheelDelta" | "clickCount">> = {},
|
||||
): TuiMouseEvent {
|
||||
return {
|
||||
type,
|
||||
button: type === "wheel" ? "none" : this.decodeMouseButton(button),
|
||||
x,
|
||||
y,
|
||||
screenX: x,
|
||||
screenY: y,
|
||||
width: Math.max(1, this.terminal.columns),
|
||||
height: Math.max(1, this.terminal.rows),
|
||||
shift: (button & 4) !== 0,
|
||||
alt: (button & 8) !== 0,
|
||||
ctrl: (button & 16) !== 0,
|
||||
...(extra.wheelDelta === undefined ? {} : { wheelDelta: extra.wheelDelta }),
|
||||
...(extra.clickCount === undefined ? {} : { clickCount: extra.clickCount }),
|
||||
};
|
||||
}
|
||||
|
||||
private dispatchMouseToLayout(event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
|
||||
if (!this.currentLayout) return undefined;
|
||||
const visited = new Set<Component>();
|
||||
const boxes = getLayoutBoxesAt(this.currentLayout, event.screenX, event.screenY);
|
||||
for (const box of boxes) {
|
||||
if (visited.has(box.component)) continue;
|
||||
if (getLayoutNode(box.component) && box.component.handleMouse === Container.prototype.handleMouse) continue;
|
||||
visited.add(box.component);
|
||||
const result = dispatchMouseEvent(box.component, {
|
||||
...event,
|
||||
x: event.screenX - box.rect.x,
|
||||
y: event.screenY - box.rect.y,
|
||||
width: box.rect.width,
|
||||
height: box.rect.height,
|
||||
});
|
||||
if (result) return result;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private applyMouseDispatchResult(event: TuiMouseEvent, result: TuiMouseDispatchResult): boolean {
|
||||
const focusTarget = this.resolveMouseFocusTarget(result.focusTarget ?? result.target.component);
|
||||
const focusChanged = result.focus === true && this.getFocusedComponent() !== focusTarget;
|
||||
if (result.focus) this.setFocus(focusTarget);
|
||||
if (result.capture) this.mouseCapture = result.target;
|
||||
return (
|
||||
result.render ??
|
||||
(focusChanged ||
|
||||
event.type === "press" ||
|
||||
event.type === "click" ||
|
||||
event.type === "drag" ||
|
||||
event.type === "wheel")
|
||||
);
|
||||
}
|
||||
|
||||
private dispatchMouseToTarget(
|
||||
event: TuiMouseEvent,
|
||||
target: TuiMouseDispatchTarget,
|
||||
): TuiMouseDispatchResult | undefined {
|
||||
return dispatchMouseEvent(target.component, retargetMouseEvent(event, target));
|
||||
}
|
||||
|
||||
private getComponentClickCount(target: TuiMouseDispatchTarget, x: number, y: number): number {
|
||||
const now = Date.now();
|
||||
const previous = this.lastComponentClick;
|
||||
const count =
|
||||
previous &&
|
||||
now - previous.timestamp <= DOUBLE_CLICK_INTERVAL_MS &&
|
||||
previous.component === target.component &&
|
||||
previous.x === x &&
|
||||
previous.y === y
|
||||
? (previous.count % 3) + 1
|
||||
: 1;
|
||||
this.lastComponentClick = { timestamp: now, count, component: target.component, x, y };
|
||||
return count;
|
||||
}
|
||||
|
||||
private clearTextSelection(): void {
|
||||
this.stopSelectionAutoScroll();
|
||||
this.selectionPressActive = false;
|
||||
this.selectionAnchor = undefined;
|
||||
this.selectionFocus = undefined;
|
||||
this.selectionGranularity = "character";
|
||||
this.selectionInitialRange = undefined;
|
||||
this.pressedUrl = undefined;
|
||||
this.selectionDragged = false;
|
||||
}
|
||||
|
||||
private handleMouseEvent(raw: SgrMouseEvent): void {
|
||||
const isMotion = (raw.button & 32) !== 0;
|
||||
const type: TuiMouseEvent["type"] = raw.release
|
||||
? "release"
|
||||
: isMotion
|
||||
? this.decodeMouseButton(raw.button) === "none"
|
||||
? "move"
|
||||
: "drag"
|
||||
: "press";
|
||||
const event = this.createMouseEvent(type, raw.button, raw.x, raw.y);
|
||||
|
||||
if (this.mouseCapture || this.mousePressTarget) {
|
||||
const target = this.mouseCapture ?? this.mousePressTarget!;
|
||||
if (this.mousePressPoint && (raw.x !== this.mousePressPoint.x || raw.y !== this.mousePressPoint.y)) {
|
||||
this.mousePressMoved = true;
|
||||
this.lastComponentClick = undefined;
|
||||
}
|
||||
let render = false;
|
||||
const targetResult = this.dispatchMouseToTarget(event, target);
|
||||
if (targetResult) render = this.applyMouseDispatchResult(event, targetResult);
|
||||
if (raw.release) {
|
||||
if (!this.mousePressMoved && this.mousePressPoint?.x === raw.x && this.mousePressPoint.y === raw.y) {
|
||||
const clickEvent = this.createMouseEvent("click", raw.button, raw.x, raw.y, {
|
||||
clickCount: this.getComponentClickCount(target, raw.x, raw.y),
|
||||
});
|
||||
const clickResult = this.dispatchMouseToTarget(clickEvent, target);
|
||||
if (clickResult) render = this.applyMouseDispatchResult(clickEvent, clickResult) || render;
|
||||
}
|
||||
this.clearComponentMouseGesture();
|
||||
}
|
||||
if (render) this.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
const overlay = this.dispatchMouseToOverlay(event);
|
||||
if (!overlay.hit) {
|
||||
const scrollbarHandled = this.handleScrollbarMouseEvent(raw);
|
||||
if (!this.scrollbarDrag) this.updateScrollbarHover(raw.x, raw.y);
|
||||
if (scrollbarHandled) return;
|
||||
} else {
|
||||
this.stopScrollbarHover();
|
||||
}
|
||||
|
||||
const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(event));
|
||||
if (result) {
|
||||
const render = this.applyMouseDispatchResult(event, result);
|
||||
if (type === "press") {
|
||||
this.clearTextSelection();
|
||||
this.mousePressTarget = result.target;
|
||||
this.mousePressPoint = { x: raw.x, y: raw.y };
|
||||
this.mousePressMoved = false;
|
||||
}
|
||||
if (render) this.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.handleRightClickPaste(raw)) return;
|
||||
this.handleSelectionMouseEvent(raw);
|
||||
}
|
||||
|
||||
private parseWheelEvent(data: string): WheelEvent | undefined {
|
||||
const sgr = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/.exec(data);
|
||||
if (sgr) {
|
||||
|
|
@ -653,6 +858,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
direction: direction === 0 ? -1 : 1,
|
||||
x: Number.parseInt(sgr[2], 10) - 1,
|
||||
y: Number.parseInt(sgr[3], 10) - 1,
|
||||
button,
|
||||
};
|
||||
}
|
||||
if (data.length === 6 && data.startsWith("\x1b[M")) {
|
||||
|
|
@ -664,6 +870,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
direction: direction === 0 ? -1 : 1,
|
||||
x: data.charCodeAt(4) - 33,
|
||||
y: data.charCodeAt(5) - 33,
|
||||
button,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
|
|
@ -959,13 +1166,12 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
this.stopSelectionAutoScroll();
|
||||
if (!this.selectionAnchor) return;
|
||||
this.updateSelectionFocus(point);
|
||||
const clickedUrl =
|
||||
const isClick =
|
||||
!this.selectionDragged &&
|
||||
this.selectionAnchor.scrollView === point.scrollView &&
|
||||
this.selectionAnchor.row === point.row &&
|
||||
this.selectionAnchor.col === point.col
|
||||
? this.pressedUrl
|
||||
: undefined;
|
||||
this.selectionAnchor.col === point.col;
|
||||
const clickedUrl = isClick ? this.pressedUrl : undefined;
|
||||
this.pressedUrl = undefined;
|
||||
if (clickedUrl && this.openUrl) {
|
||||
this.selectionAnchor = undefined;
|
||||
|
|
@ -978,6 +1184,19 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
this.requestRender();
|
||||
return;
|
||||
}
|
||||
if (isClick) {
|
||||
const clickEvent = this.createMouseEvent("click", event.button, event.x, event.y, {
|
||||
clickCount: this.lastClick?.count ?? 1,
|
||||
});
|
||||
const overlay = this.dispatchMouseToOverlay(clickEvent);
|
||||
const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(clickEvent));
|
||||
if (result) {
|
||||
const render = this.applyMouseDispatchResult(clickEvent, result);
|
||||
this.clearTextSelection();
|
||||
if (render) this.requestRender();
|
||||
return;
|
||||
}
|
||||
}
|
||||
void this.copySelectionToClipboard();
|
||||
this.requestRender();
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,96 @@ import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth
|
|||
/**
|
||||
* Component interface - all components must implement this
|
||||
*/
|
||||
export type TuiMouseEventType = "press" | "release" | "move" | "drag" | "click" | "wheel";
|
||||
export type TuiMouseButton = "left" | "middle" | "right" | "none";
|
||||
|
||||
/** Normalized cell-based mouse event. Coordinates are zero-based. */
|
||||
export interface TuiMouseEvent {
|
||||
type: TuiMouseEventType;
|
||||
button: TuiMouseButton;
|
||||
/** Coordinates local to the receiving component. */
|
||||
x: number;
|
||||
y: number;
|
||||
/** Absolute terminal coordinates. */
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
/** Current component bounds. */
|
||||
width: number;
|
||||
height: number;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
ctrl: boolean;
|
||||
/** Logical lines. Negative values scroll up. */
|
||||
wheelDelta?: number;
|
||||
/** Consecutive click count when type is click. */
|
||||
clickCount?: number;
|
||||
}
|
||||
|
||||
export interface TuiMouseEventResult {
|
||||
/** Stop propagation and suppress renderer-level fallback behavior. */
|
||||
handled?: boolean;
|
||||
/** Route subsequent drag/release events to this component. Implies handled. */
|
||||
capture?: boolean;
|
||||
/** Give keyboard focus to this component. Implies handled. */
|
||||
focus?: boolean;
|
||||
/**
|
||||
* Explicitly request or suppress a render. Move and release default to false;
|
||||
* press, click, drag, and wheel default to true.
|
||||
*/
|
||||
render?: boolean;
|
||||
}
|
||||
|
||||
/** Internal target metadata used by containers and alternate-screen dispatch. */
|
||||
export interface TuiMouseDispatchTarget {
|
||||
component: Component;
|
||||
originX: number;
|
||||
originY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Result of dispatching to a concrete component. */
|
||||
export interface TuiMouseDispatchResult extends TuiMouseEventResult {
|
||||
handled: true;
|
||||
target: TuiMouseDispatchTarget;
|
||||
/** Keyboard focus target, which may be a delegating parent container. */
|
||||
focusTarget?: Component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event to a component and retain the exact target and coordinate
|
||||
* transform. Containers use this when forwarding events to nested children.
|
||||
*/
|
||||
export function dispatchMouseEvent(component: Component, event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
|
||||
const result = component.handleMouse?.(event);
|
||||
if (!result) return undefined;
|
||||
if ("target" in result) return result as TuiMouseDispatchResult;
|
||||
if (!result.handled && !result.capture && !result.focus) return undefined;
|
||||
return {
|
||||
...result,
|
||||
handled: true,
|
||||
...(result.focus ? { focusTarget: component } : {}),
|
||||
target: {
|
||||
component,
|
||||
originX: event.screenX - event.x,
|
||||
originY: event.screenY - event.y,
|
||||
width: event.width,
|
||||
height: event.height,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Recreate local coordinates for a previously dispatched mouse target. */
|
||||
export function retargetMouseEvent(event: TuiMouseEvent, target: TuiMouseDispatchTarget): TuiMouseEvent {
|
||||
return {
|
||||
...event,
|
||||
x: event.screenX - target.originX,
|
||||
y: event.screenY - target.originY,
|
||||
width: target.width,
|
||||
height: target.height,
|
||||
};
|
||||
}
|
||||
|
||||
export interface Component {
|
||||
/**
|
||||
* Render the component to lines for the given viewport width
|
||||
|
|
@ -28,11 +118,12 @@ export interface Component {
|
|||
*/
|
||||
render(width: number): string[];
|
||||
|
||||
/**
|
||||
* Optional handler for keyboard input when component has focus
|
||||
*/
|
||||
/** Optional handler for keyboard input when component has focus. */
|
||||
handleInput?(data: string): void;
|
||||
|
||||
/** Optional normalized mouse handler. */
|
||||
handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
|
||||
|
||||
/**
|
||||
* If true, component receives key release events (Kitty protocol).
|
||||
* Default is false - release events are filtered out.
|
||||
|
|
@ -193,6 +284,14 @@ type OverlayStackEntry = {
|
|||
focusOrder: number;
|
||||
};
|
||||
|
||||
type RenderedOverlayLayout = {
|
||||
entry: OverlayStackEntry;
|
||||
row: number;
|
||||
col: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type OverlayBlockedFocusResume = { status: "restore-overlay" } | { status: "focus-target"; target: Component | null };
|
||||
type EligibleOverlayFocusRestoreState = { status: "eligible"; overlay: OverlayStackEntry };
|
||||
type BlockedOverlayFocusRestoreState = {
|
||||
|
|
@ -210,6 +309,7 @@ type OverlayFocusRestorePolicy = "clear" | "preserve";
|
|||
*/
|
||||
export class Container implements Component {
|
||||
children: Component[] = [];
|
||||
private mouseLayout?: { width: number; children: Array<{ component: Component; height: number }> };
|
||||
|
||||
addChild(component: Component): void {
|
||||
this.children.push(component);
|
||||
|
|
@ -232,14 +332,39 @@ export class Container implements Component {
|
|||
}
|
||||
}
|
||||
|
||||
handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
|
||||
if (event.y < 0 || event.y >= event.height) return undefined;
|
||||
const mouseChildren =
|
||||
this.mouseLayout?.width === event.width
|
||||
? this.mouseLayout.children
|
||||
: this.children.map((component) => ({ component, height: component.render(event.width).length }));
|
||||
let childY = 0;
|
||||
for (const { component: child, height: childHeight } of mouseChildren) {
|
||||
if (event.y >= childY && event.y < childY + childHeight) {
|
||||
const result = dispatchMouseEvent(child, {
|
||||
...event,
|
||||
y: event.y - childY,
|
||||
height: childHeight,
|
||||
});
|
||||
if (result?.focus && (this as Component).handleInput) return { ...result, focusTarget: this };
|
||||
return result;
|
||||
}
|
||||
childY += childHeight;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
const mouseChildren: Array<{ component: Component; height: number }> = [];
|
||||
for (const child of this.children) {
|
||||
const childLines = child.render(width);
|
||||
mouseChildren.push({ component: child, height: childLines.length });
|
||||
for (const line of childLines) {
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
this.mouseLayout = { width, children: mouseChildren };
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
|
@ -354,6 +479,7 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
// Overlay stack for modal components rendered on top of base content
|
||||
private focusOrderCounter = 0;
|
||||
private overlayStack: OverlayStackEntry[] = [];
|
||||
private renderedOverlayLayouts: RenderedOverlayLayout[] = [];
|
||||
|
||||
get hasOverlayEntries(): boolean {
|
||||
return this.overlayStack.length > 0;
|
||||
|
|
@ -669,6 +795,46 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
);
|
||||
}
|
||||
|
||||
/** Keep overlay containers as keyboard focus owners when a nested control is clicked. */
|
||||
protected resolveMouseFocusTarget(component: Component): Component {
|
||||
for (let index = this.overlayStack.length - 1; index >= 0; index--) {
|
||||
const overlay = this.overlayStack[index]!;
|
||||
if (this.isOverlayVisible(overlay) && this.containsComponent(overlay.component, component)) {
|
||||
return overlay.component;
|
||||
}
|
||||
}
|
||||
return component;
|
||||
}
|
||||
|
||||
/** Dispatch to the visually topmost overlay under the pointer. */
|
||||
protected dispatchMouseToOverlay(event: TuiMouseEvent): { hit: boolean; result?: TuiMouseDispatchResult } {
|
||||
for (let index = this.renderedOverlayLayouts.length - 1; index >= 0; index--) {
|
||||
const layout = this.renderedOverlayLayouts[index]!;
|
||||
if (
|
||||
event.screenX < layout.col ||
|
||||
event.screenX >= layout.col + layout.width ||
|
||||
event.screenY < layout.row ||
|
||||
event.screenY >= layout.row + layout.height
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const result = dispatchMouseEvent(layout.entry.component, {
|
||||
...event,
|
||||
x: event.screenX - layout.col,
|
||||
y: event.screenY - layout.row,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
});
|
||||
return result
|
||||
? {
|
||||
hit: true,
|
||||
result: result.focus ? { ...result, focusTarget: layout.entry.component } : result,
|
||||
}
|
||||
: { hit: true };
|
||||
}
|
||||
return { hit: false };
|
||||
}
|
||||
|
||||
/** Check if an overlay entry is currently visible */
|
||||
private isOverlayVisible(entry: OverlayStackEntry): boolean {
|
||||
if (entry.hidden) return false;
|
||||
|
|
@ -1097,11 +1263,14 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
|
||||
/** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
|
||||
protected compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {
|
||||
if (this.overlayStack.length === 0) return lines;
|
||||
if (this.overlayStack.length === 0) {
|
||||
this.renderedOverlayLayouts = [];
|
||||
return lines;
|
||||
}
|
||||
const result = [...lines];
|
||||
|
||||
// Pre-render all visible overlays and calculate positions
|
||||
const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = [];
|
||||
const rendered: { entry: OverlayStackEntry; overlayLines: string[]; row: number; col: number; w: number }[] = [];
|
||||
let minLinesNeeded = result.length;
|
||||
|
||||
const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));
|
||||
|
|
@ -1124,9 +1293,16 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
// Get final row/col with actual overlay height
|
||||
const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);
|
||||
|
||||
rendered.push({ overlayLines, row, col, w: width });
|
||||
rendered.push({ entry, overlayLines, row, col, w: width });
|
||||
minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
|
||||
}
|
||||
this.renderedOverlayLayouts = rendered.map(({ entry, row, col, w, overlayLines }) => ({
|
||||
entry,
|
||||
row,
|
||||
col,
|
||||
width: w,
|
||||
height: overlayLines.length,
|
||||
}));
|
||||
|
||||
// Pad to at least terminal height so overlays have screen-relative positions.
|
||||
// Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing
|
||||
|
|
|
|||
153
packages/tui/test/mouse-components.test.ts
Normal file
153
packages/tui/test/mouse-components.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { Editor, type EditorTheme } from "../src/components/editor.ts";
|
||||
import { Input } from "../src/components/input.ts";
|
||||
import { SelectList, type SelectListTheme } from "../src/components/select-list.ts";
|
||||
import { SettingsList, type SettingsListTheme } from "../src/components/settings-list.ts";
|
||||
import { Container, type TuiMouseEvent, type TuiMouseEventType } from "../src/tui.ts";
|
||||
import { TuiAltScreen } from "../src/tui-alt-screen.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
function mouse(type: TuiMouseEventType, x: number, y: number, width = 80, height = 10): TuiMouseEvent {
|
||||
return {
|
||||
type,
|
||||
button: "left",
|
||||
x,
|
||||
y,
|
||||
screenX: x,
|
||||
screenY: y,
|
||||
width,
|
||||
height,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
...(type === "click" ? { clickCount: 1 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const selectTheme: SelectListTheme = {
|
||||
selectedPrefix: (text) => text,
|
||||
selectedText: (text) => text,
|
||||
description: (text) => text,
|
||||
scrollInfo: (text) => text,
|
||||
noMatch: (text) => text,
|
||||
};
|
||||
|
||||
const settingsTheme: SettingsListTheme = {
|
||||
label: (text) => text,
|
||||
value: (text) => text,
|
||||
description: (text) => text,
|
||||
cursor: "> ",
|
||||
hint: (text) => text,
|
||||
};
|
||||
|
||||
const editorTheme: EditorTheme = {
|
||||
borderColor: (text) => text,
|
||||
selectList: selectTheme,
|
||||
};
|
||||
|
||||
class InputOverlay extends Container {
|
||||
readonly input = new Input();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addChild(this.input);
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.input.handleInput(data);
|
||||
}
|
||||
}
|
||||
|
||||
describe("mouse-aware components", () => {
|
||||
it("positions a single-line input cursor on press", () => {
|
||||
const input = new Input();
|
||||
input.setValue("hello");
|
||||
input.render(20);
|
||||
|
||||
assert.strictEqual(input.handleMouse(mouse("press", 4, 0, 20, 1))?.handled, true);
|
||||
input.handleInput("X");
|
||||
assert.strictEqual(input.getValue(), "heXllo");
|
||||
});
|
||||
|
||||
it("selects and activates list rows", () => {
|
||||
const list = new SelectList(
|
||||
[
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
{ value: "c", label: "C" },
|
||||
{ value: "d", label: "D" },
|
||||
{ value: "e", label: "E" },
|
||||
],
|
||||
3,
|
||||
selectTheme,
|
||||
);
|
||||
let selected: string | undefined;
|
||||
list.onSelect = (item) => {
|
||||
selected = item.value;
|
||||
};
|
||||
|
||||
assert.strictEqual(list.handleMouse(mouse("press", 1, 2, 40, 3))?.handled, true);
|
||||
assert.strictEqual(list.getSelectedItem()?.value, "c");
|
||||
assert.strictEqual(list.handleMouse(mouse("click", 1, 2, 40, 3))?.handled, true);
|
||||
assert.strictEqual(selected, "c");
|
||||
});
|
||||
|
||||
it("activates settings rows", () => {
|
||||
const changes: Array<{ id: string; value: string }> = [];
|
||||
const list = new SettingsList(
|
||||
[
|
||||
{ id: "mode", label: "Mode", currentValue: "one", values: ["one", "two"] },
|
||||
{ id: "other", label: "Other", currentValue: "off", values: ["off", "on"] },
|
||||
{ id: "third", label: "Third", currentValue: "low", values: ["low", "high"] },
|
||||
{ id: "fourth", label: "Fourth", currentValue: "x", values: ["x", "y"] },
|
||||
],
|
||||
3,
|
||||
settingsTheme,
|
||||
(id, value) => changes.push({ id, value }),
|
||||
() => {},
|
||||
);
|
||||
|
||||
list.handleMouse(mouse("press", 1, 2, 40, 5));
|
||||
list.handleMouse(mouse("click", 1, 2, 40, 5));
|
||||
assert.deepStrictEqual(changes, [{ id: "third", value: "high" }]);
|
||||
});
|
||||
|
||||
it("keeps a delegating overlay focused when its nested input is clicked", async () => {
|
||||
const terminal = new VirtualTerminal(20, 4);
|
||||
const tui = new TuiAltScreen(terminal);
|
||||
const overlay = new InputOverlay();
|
||||
overlay.input.setValue("hi");
|
||||
tui.start();
|
||||
tui.showOverlay(overlay, { anchor: "top-left", width: 20 });
|
||||
await terminal.waitForRender();
|
||||
|
||||
terminal.sendInput("\x1b[<0;5;1M");
|
||||
terminal.sendInput("\x1b[<0;5;1m");
|
||||
terminal.sendInput("!");
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.strictEqual(overlay.input.getValue(), "hi!");
|
||||
assert.strictEqual(tui.getFocusedComponent(), overlay);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("positions and focuses the multiline editor through alternate-screen dispatch", async () => {
|
||||
const terminal = new VirtualTerminal(20, 6);
|
||||
const tui = new TuiAltScreen(terminal);
|
||||
const editor = new Editor(tui, editorTheme);
|
||||
editor.setText("hello");
|
||||
tui.addChild(editor);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
terminal.sendInput("\x1b[<0;3;2M");
|
||||
terminal.sendInput("\x1b[<0;3;2m");
|
||||
terminal.sendInput("X");
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.strictEqual(editor.getText(), "heXllo");
|
||||
assert.strictEqual(tui.getFocusedComponent(), editor);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
|
@ -3,7 +3,9 @@ import { describe, it } from "node:test";
|
|||
import { findAltScreenSearchMatches } from "../src/alt-screen-search.ts";
|
||||
import { HStack } from "../src/components/h-stack.ts";
|
||||
import { Image } from "../src/components/image.ts";
|
||||
import { MouseRegion } from "../src/components/mouse-region.ts";
|
||||
import { ScrollView } from "../src/components/scroll-view.ts";
|
||||
import { SelectList } from "../src/components/select-list.ts";
|
||||
import { Text } from "../src/components/text.ts";
|
||||
import { VStack } from "../src/components/v-stack.ts";
|
||||
import { getKeybindings, KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings.ts";
|
||||
|
|
@ -14,6 +16,7 @@ import {
|
|||
resetCapabilitiesCache,
|
||||
setCapabilities,
|
||||
} from "../src/terminal-image.ts";
|
||||
import type { TuiMouseEvent } from "../src/tui.ts";
|
||||
import { TuiAltScreen } from "../src/tui-alt-screen.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
|
|
@ -178,6 +181,43 @@ describe("TuiAltScreen", () => {
|
|||
tui.stop();
|
||||
});
|
||||
|
||||
it("does not vertically redispatch misses through horizontal layout containers", async () => {
|
||||
const terminal = new VirtualTerminal(20, 2);
|
||||
const tui = new TuiAltScreen(terminal);
|
||||
let selections = 0;
|
||||
const list = new SelectList(
|
||||
[
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
],
|
||||
2,
|
||||
{
|
||||
selectedPrefix: (text) => text,
|
||||
selectedText: (text) => text,
|
||||
description: (text) => text,
|
||||
scrollInfo: (text) => text,
|
||||
noMatch: (text) => text,
|
||||
},
|
||||
);
|
||||
list.onSelect = () => {
|
||||
selections += 1;
|
||||
};
|
||||
tui.setLayoutRoot(
|
||||
new HStack([
|
||||
{ component: list, basis: 10 },
|
||||
{ component: new Text("plain", 0, 0), basis: 10 },
|
||||
]),
|
||||
);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
terminal.sendInput("\x1b[<0;15;1M");
|
||||
terminal.sendInput("\x1b[<0;15;1m");
|
||||
await terminal.waitForRender();
|
||||
assert.strictEqual(selections, 0);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("uses button-motion tracking inside terminal multiplexers", () => {
|
||||
const environmentKeys = ["TMUX", "ZELLIJ", "STY", "TERM"] as const;
|
||||
const previousEnvironment = new Map(environmentKeys.map((key) => [key, process.env[key]]));
|
||||
|
|
@ -1295,6 +1335,137 @@ describe("TuiAltScreen", () => {
|
|||
tui.stop();
|
||||
});
|
||||
|
||||
it("dispatches clicks to nested mouse regions without breaking drag selection", async () => {
|
||||
const terminal = new RecordingTerminal(20, 2);
|
||||
const tui = new TuiAltScreen(terminal);
|
||||
let clicks = 0;
|
||||
tui.addChild(
|
||||
new MouseRegion(new Text("clickable\nselectable", 0, 0), (event) => {
|
||||
if (event.type !== "click") return undefined;
|
||||
clicks += 1;
|
||||
return { handled: true };
|
||||
}),
|
||||
);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
terminal.sendInput("\x1b[<0;2;1M");
|
||||
terminal.sendInput("\x1b[<0;2;1m");
|
||||
await terminal.waitForRender();
|
||||
assert.strictEqual(clicks, 1);
|
||||
|
||||
terminal.sendInput("\x1b[<0;1;1M");
|
||||
terminal.sendInput("\x1b[<32;4;2M");
|
||||
terminal.sendInput("\x1b[<0;4;2m");
|
||||
await terminal.waitForRender();
|
||||
assert.strictEqual(clicks, 1);
|
||||
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b]52;c;")));
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("focuses and captures drag gestures for mouse-aware components", async () => {
|
||||
const terminal = new VirtualTerminal(20, 2);
|
||||
const tui = new TuiAltScreen(terminal);
|
||||
const events: string[] = [];
|
||||
const component = {
|
||||
render: () => ["control"],
|
||||
invalidate: () => {},
|
||||
handleMouse: (event: TuiMouseEvent) => {
|
||||
events.push(event.type);
|
||||
return event.type === "press" ? { handled: true, capture: true, focus: true } : { handled: true };
|
||||
},
|
||||
};
|
||||
tui.addChild(component);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
terminal.sendInput("\x1b[<0;1;1M");
|
||||
terminal.sendInput("\x1b[<32;5;2M");
|
||||
terminal.sendInput("\x1b[<0;5;2m");
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.deepStrictEqual(events, ["press", "drag", "release"]);
|
||||
assert.strictEqual(tui.getFocusedComponent(), component);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("reports consecutive click counts to component-owned controls", async () => {
|
||||
const terminal = new VirtualTerminal(20, 1);
|
||||
const tui = new TuiAltScreen(terminal);
|
||||
const clickCounts: number[] = [];
|
||||
tui.addChild({
|
||||
render: () => ["control"],
|
||||
invalidate: () => {},
|
||||
handleMouse: (event) => {
|
||||
if (event.type === "press") return { handled: true };
|
||||
if (event.type === "click") {
|
||||
clickCounts.push(event.clickCount ?? 0);
|
||||
return { handled: true };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
for (let count = 0; count < 3; count++) {
|
||||
terminal.sendInput("\x1b[<0;1;1M");
|
||||
terminal.sendInput("\x1b[<0;1;1m");
|
||||
}
|
||||
await terminal.waitForRender();
|
||||
assert.deepStrictEqual(clickCounts, [1, 2, 3]);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("does not rerender for handled no-op pointer motion", async () => {
|
||||
const terminal = new RecordingTerminal(20, 2);
|
||||
const tui = new TuiAltScreen(terminal);
|
||||
let renderCount = 0;
|
||||
tui.addChild({
|
||||
render: () => {
|
||||
renderCount += 1;
|
||||
return ["hover target"];
|
||||
},
|
||||
invalidate: () => {},
|
||||
handleMouse: (event) => (event.type === "move" ? { handled: true } : undefined),
|
||||
});
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
const renderedBeforeMotion = renderCount;
|
||||
const writesBeforeMotion = terminal.events.filter((event) => event.type === "write").length;
|
||||
|
||||
terminal.sendInput("\x1b[<35;1;1M");
|
||||
await terminal.waitForRender();
|
||||
assert.strictEqual(renderCount, renderedBeforeMotion);
|
||||
assert.strictEqual(terminal.events.filter((event) => event.type === "write").length, writesBeforeMotion);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("lets mouse-aware components consume wheel events before viewport scrolling", async () => {
|
||||
const terminal = new VirtualTerminal(20, 3);
|
||||
const tui = new TuiAltScreen(terminal);
|
||||
let wheelEvents = 0;
|
||||
tui.addChild(
|
||||
new MouseRegion(
|
||||
new Text(Array.from({ length: 8 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0),
|
||||
(event) => {
|
||||
if (event.type !== "wheel") return undefined;
|
||||
wheelEvents += 1;
|
||||
return { handled: true };
|
||||
},
|
||||
),
|
||||
);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
const viewportTop = tui.viewportTop;
|
||||
|
||||
terminal.sendInput("\x1b[<64;1;1M");
|
||||
await terminal.waitForRender();
|
||||
assert.strictEqual(wheelEvents, 1);
|
||||
assert.strictEqual(tui.viewportTop, viewportTop);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("restores keyboard state before leaving alt mode and prints the full document", async () => {
|
||||
const terminal = new RecordingTerminal(20, 3);
|
||||
const tui = new TuiAltScreen(terminal);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue