diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0b7de0847..2b49767da 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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. diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index 31dbf58b4..3dbfc682f 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -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. diff --git a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts index 0afaeb135..c02e37165 100644 --- a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts @@ -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(); 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)); } diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index b1420b36c..a35c1d8fc 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -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 { + 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; } } diff --git a/packages/coding-agent/test/assistant-message.test.ts b/packages/coding-agent/test/assistant-message.test.ts index 2ab64c09f..ea2618aac 100644 --- a/packages/coding-agent/test/assistant-message.test.ts +++ b/packages/coding-agent/test/assistant-message.test.ts @@ -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"); diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 14d3b3f04..e45774c18 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -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", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index ae7c20028..0cd3316b9 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/README.md b/packages/tui/README.md index 58e829370..9b799bdd0 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -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 diff --git a/packages/tui/src/components/box.ts b/packages/tui/src/components/box.ts index 3573ab092..c66f70f67 100644 --- a/packages/tui/src/components/box.ts +++ b/packages/tui/src/components/box.ts @@ -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 []; diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 54eb4455f..682cf34c5 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -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 { diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index a054076d0..8943d59a8 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -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; diff --git a/packages/tui/src/components/mouse-region.ts b/packages/tui/src/components/mouse-region.ts new file mode 100644 index 000000000..a77ded0c1 --- /dev/null +++ b/packages/tui/src/components/mouse-region.ts @@ -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(); + } +} diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index 26fdb685a..80b1becb4 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -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, diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index 7dce91b87..75ccd54bf 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -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) { diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 0d5a4a109..cd1707235 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -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"; diff --git a/packages/tui/src/layout.ts b/packages/tui/src/layout.ts index 51f738ecd..9d6b99e30 100644 --- a/packages/tui/src/layout.ts +++ b/packages/tui/src/layout.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; diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 783a92ab8..71ac878b5 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -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> = {}, + ): 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(); + 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; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 5172a9a14..ff9cec676 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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 diff --git a/packages/tui/test/mouse-components.test.ts b/packages/tui/test/mouse-components.test.ts new file mode 100644 index 000000000..de93da3f6 --- /dev/null +++ b/packages/tui/test/mouse-components.test.ts @@ -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(); + }); +}); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 2166debe7..bb051f03b 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -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);