mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(tui,coding-agent): use only the terminal cursor when showHardwareCursor is enabled
Editor and Input wrap their drawn cursor in fake cursor markers. The TUI resolves them to reverse video, or drops the focused one when the hardware cursor is shown, so terminals that extend edge cell colors into the window padding no longer stretch the drawn cursor at the first or last column. Also keep CURSOR_MARKER when an overlay ends directly left of the cursor.
This commit is contained in:
parent
75a99721d1
commit
1cedd32724
17 changed files with 183 additions and 28 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the `showHardwareCursor` setting to use only the terminal cursor instead of also drawing Pi's reverse-video cursor
|
||||
|
||||
## [1.1.0] - 2026-10-07
|
||||
|
||||
### New Features
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ See [Compaction Reference](compaction.md) for trigger, summarization, and valida
|
|||
| `editorPaddingX` | number | `0` | Horizontal editor padding from 0 to 3 cells. |
|
||||
| `outputPad` | `0 \| 1` | `1` | Horizontal transcript padding for messages, tool output, `!` command output, and summary blocks. |
|
||||
| `autocompleteMaxVisible` | number | `5` | Visible autocomplete entries, from 3 to 20. |
|
||||
| `showHardwareCursor` | boolean | `false` | Show the terminal cursor while Pi positions it for input methods. |
|
||||
| `showHardwareCursor` | boolean | `false` | Use the terminal cursor instead of Pi's drawn cursor. Pi still positions it for input methods. |
|
||||
| `terminal.showImages` | boolean | `true` | Display inline images when supported. |
|
||||
| `terminal.imageWidthCells` | number | `60` | Preferred inline image width in terminal cells. |
|
||||
| `terminal.clearOnShrink` | boolean | `false` | Clear empty rows when rendered content shrinks. |
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ Prefer these components over rebuilding selection, scrolling, text editing, or w
|
|||
|
||||
Use `matchesKey()` and `Key` for terminal keyboard input. The parser accounts for supported terminal protocols and key modifiers. Extension components should use the injected `KeybindingsManager` for configurable application actions.
|
||||
|
||||
A component that displays a text cursor should implement `Focusable` and place `CURSOR_MARKER` immediately before its visual cursor. The TUI uses that marker to position the hardware cursor for input method editors.
|
||||
A component that displays a text cursor should implement `Focusable` and place `CURSOR_MARKER` immediately before its visual cursor. Wrap the cursor cell in `renderFakeCursor()` so the TUI draws it in reverse video, or hides it after `CURSOR_MARKER` when the hardware cursor is shown. The TUI uses the marker to position the hardware cursor for input method editors.
|
||||
|
||||
Containers that wrap an `Input` or `Editor` must propagate their `focused` state to that child. Without propagation, Chinese, Japanese, Korean, and other IME candidate windows can appear at the wrong screen position.
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
||||
import { CURSOR_MARKER, type Focusable, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { CURSOR_MARKER, type Focusable, matchesKey, renderFakeCursor, visibleWidth } from "@earendil-works/pi-tui";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("overlay-test", {
|
||||
|
|
@ -131,7 +131,7 @@ class OverlayTestComponent implements Focusable {
|
|||
const after = inputDisplay.slice(item.cursor + 1);
|
||||
// Emit hardware cursor marker for IME support when focused
|
||||
const marker = this.focused ? CURSOR_MARKER : "";
|
||||
inputDisplay = `${before}${marker}\x1b[7m${cursorChar}\x1b[27m${after}`;
|
||||
inputDisplay = `${before}${marker}${renderFakeCursor(cursorChar)}${after}`;
|
||||
}
|
||||
content = `${prefix + label} ${inputDisplay}`;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -807,7 +807,7 @@ export class SettingsSelectorComponent extends Container {
|
|||
items.splice(skillCommandsIndex + 1, 0, {
|
||||
id: "show-hardware-cursor",
|
||||
label: "Show hardware cursor",
|
||||
description: "Show the terminal cursor while still positioning it for IME support",
|
||||
description: "Use the terminal cursor instead of Pi's drawn cursor",
|
||||
currentValue: config.showHardwareCursor ? "true" : "false",
|
||||
values: ["true", "false"],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,22 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- `Editor.render()` and `Input.render()` now wrap the drawn cursor in zero-width APC markers instead of emitting `\x1b[7m` directly. TUI renderers resolve them; code that renders these components outside a TUI must strip them, for example with `stripTerminalSequences()`
|
||||
|
||||
### Added
|
||||
|
||||
- Added `renderFakeCursor()`. Components wrap their drawn cursor with it, and the TUI renders it in reverse video, or omits it after `CURSOR_MARKER` when `showHardwareCursor` is enabled
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `showHardwareCursor` to show only the terminal cursor: focused `Editor` and `Input` components no longer draw a reverse-video cursor when it is enabled. This avoids terminals that extend edge cell colors into the window padding stretching the drawn cursor at the first or last column
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed overlay compositing dropping `CURSOR_MARKER` when an overlay ends directly left of the cursor, which lost the hardware cursor position
|
||||
|
||||
## [1.1.0] - 2026-10-07
|
||||
|
||||
### Breaking Changes
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@ const collapsible = new MouseRegion(content, (event) => {
|
|||
Components that display a text cursor and need IME (Input Method Editor) support should implement the `Focusable` interface:
|
||||
|
||||
```typescript
|
||||
import { CURSOR_MARKER, type Component, type Focusable } from "@earendil-works/pi-tui";
|
||||
import { CURSOR_MARKER, type Component, type Focusable, renderFakeCursor } from "@earendil-works/pi-tui";
|
||||
|
||||
class MyInput implements Component, Focusable {
|
||||
focused: boolean = false; // Set by TUI when focus changes
|
||||
|
|
@ -316,7 +316,7 @@ class MyInput implements Component, Focusable {
|
|||
render(width: number): string[] {
|
||||
const marker = this.focused ? CURSOR_MARKER : "";
|
||||
// Emit marker right before the fake cursor
|
||||
return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`];
|
||||
return [`> ${beforeCursor}${marker}${renderFakeCursor(atCursor)}${afterCursor}`];
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
|
@ -329,7 +329,9 @@ When a `Focusable` component has focus, TUI:
|
|||
3. Positions the hardware terminal cursor at that location
|
||||
4. Shows the hardware cursor only when `showHardwareCursor` is enabled
|
||||
|
||||
The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the renderer constructor's `showHardwareCursor` argument or `setShowHardwareCursor(true)`. The `Editor` and `Input` built-in components already implement this interface.
|
||||
`renderFakeCursor()` wraps the cursor cell in zero-width markers. TUI renders them as reverse video. When `showHardwareCursor` is enabled, TUI removes the fake cursor that directly follows `CURSOR_MARKER`, so the focused component shows only the terminal cursor. Fake cursors without a preceding `CURSOR_MARKER` stay visible.
|
||||
|
||||
The hardware cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the renderer constructor's `showHardwareCursor` argument or `setShowHardwareCursor(true)`. The `Editor` and `Input` built-in components already implement this interface.
|
||||
|
||||
**Container components with embedded inputs:** When a container component (dialog, selector, etc.) contains an `Input` or `Editor` child, the container must implement `Focusable` and propagate the focus state to the child:
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
type Component,
|
||||
CURSOR_MARKER,
|
||||
type Focusable,
|
||||
renderFakeCursor,
|
||||
type TUI,
|
||||
type TuiMouseEvent,
|
||||
type TuiMouseEventResult,
|
||||
|
|
@ -587,12 +588,12 @@ export class Editor implements Component, Focusable {
|
|||
const afterGraphemes = [...this.segment(after, "grapheme")];
|
||||
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
||||
const restAfter = after.slice(firstGrapheme.length);
|
||||
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
|
||||
const cursor = renderFakeCursor(firstGrapheme);
|
||||
displayText = before + marker + cursor + restAfter;
|
||||
// lineVisibleWidth stays the same - we're replacing, not adding
|
||||
} else {
|
||||
// Cursor is at the end - add highlighted space
|
||||
const cursor = "\x1b[7m \x1b[0m";
|
||||
const cursor = renderFakeCursor(" ");
|
||||
displayText = before + marker + cursor;
|
||||
lineVisibleWidth = lineVisibleWidth + 1;
|
||||
// If cursor overflows content width into the padding, flag it
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import { getKeybindings } from "../keybindings.ts";
|
||||
import { decodeKittyPrintable } from "../keys.ts";
|
||||
import { KillRing } from "../kill-ring.ts";
|
||||
import { type Component, CURSOR_MARKER, type Focusable, type TuiMouseEvent, type TuiMouseEventResult } from "../tui.ts";
|
||||
import {
|
||||
type Component,
|
||||
CURSOR_MARKER,
|
||||
type Focusable,
|
||||
renderFakeCursor,
|
||||
type TuiMouseEvent,
|
||||
type TuiMouseEventResult,
|
||||
} from "../tui.ts";
|
||||
import { UndoStack } from "../undo-stack.ts";
|
||||
import { getGraphemeSegmenter, isWhitespaceChar, sliceByColumn, truncateToWidth, visibleWidth } from "../utils.ts";
|
||||
import { findWordBackward, findWordForward } from "../word-navigation.ts";
|
||||
|
|
@ -423,7 +430,7 @@ export class Input implements Component, Focusable {
|
|||
const atCursor = graphemes[0]?.segment ?? " ";
|
||||
const afterCursor = placeholder.slice(atCursor.length);
|
||||
const marker = this.focused ? CURSOR_MARKER : "";
|
||||
const cursorChar = `\x1b[7m${this.placeholderStyle(atCursor)}\x1b[27m`;
|
||||
const cursorChar = renderFakeCursor(this.placeholderStyle(atCursor));
|
||||
const textWithCursor = marker + cursorChar + this.placeholderStyle(afterCursor);
|
||||
const padding = " ".repeat(Math.max(0, availableWidth - visibleWidth(textWithCursor)));
|
||||
return [this.prompt + textWithCursor + padding];
|
||||
|
|
@ -480,8 +487,8 @@ export class Input implements Component, Focusable {
|
|||
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
|
||||
const marker = this.focused ? CURSOR_MARKER : "";
|
||||
|
||||
// Use inverse video to show cursor
|
||||
const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal
|
||||
// TUI draws the fake cursor in reverse video, or omits it when the hardware cursor is shown
|
||||
const cursorChar = renderFakeCursor(atCursor);
|
||||
const textWithCursor = beforeCursor + marker + cursorChar + afterCursor;
|
||||
|
||||
// Calculate visual width
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ export {
|
|||
type OverlayMargin,
|
||||
type OverlayOptions,
|
||||
type OverlayUnfocusOptions,
|
||||
renderFakeCursor,
|
||||
type SizeValue,
|
||||
type TUI,
|
||||
type TuiInputListener,
|
||||
|
|
|
|||
|
|
@ -407,7 +407,9 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
this.terminal.write(`${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`);
|
||||
} else {
|
||||
const width = Math.max(1, this.terminal.columns);
|
||||
const documentLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
|
||||
const documentLines = this.resolveFakeCursors(this.render(width)).map((line) =>
|
||||
line.replace(OSC133_ZONE_PREFIX, ""),
|
||||
);
|
||||
this.lastDocument = this.applyLineResets(documentLines.map((line) => line.replaceAll(CURSOR_MARKER, ""))).map(
|
||||
(line) => (isImageLine(line) || visibleWidth(line) <= width ? line : sliceByColumn(line, 0, width, true)),
|
||||
);
|
||||
|
|
@ -1684,7 +1686,8 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
if (this.refreshSearch(nextLayout)) {
|
||||
nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender());
|
||||
}
|
||||
let screen = nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
|
||||
// Resolve fake cursors before highlighting and compositing, which only track SGR codes
|
||||
let screen = this.resolveFakeCursors(nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, "")));
|
||||
screen = this.applySearchHighlights(screen, nextLayout);
|
||||
screen = this.compositeScrollToEndIndicator(screen, nextLayout, width);
|
||||
screen = this.compositeOverlays(screen, width, height);
|
||||
|
|
|
|||
|
|
@ -261,7 +261,8 @@ export class TuiMainScreen extends TuiBase implements TUI {
|
|||
};
|
||||
|
||||
// Render all components to get new lines
|
||||
let newLines = this.render(width);
|
||||
// Resolve fake cursors before compositing so overlays and line slicing see plain SGR codes
|
||||
let newLines = this.resolveFakeCursors(this.render(width));
|
||||
|
||||
// Composite overlays into the rendered lines (before differential compare)
|
||||
if (this.hasOverlayEntries) {
|
||||
|
|
|
|||
|
|
@ -195,6 +195,17 @@ export function isFocusable(component: Component | null): component is Component
|
|||
*/
|
||||
export const CURSOR_MARKER = "\x1b_pi:c\x07";
|
||||
|
||||
/** Fake cursor markers - zero-width APC sequences around the text drawn as the cursor. See resolveFakeCursors(). */
|
||||
export const FAKE_CURSOR_START = "\x1b_pi:fc\x07";
|
||||
export const FAKE_CURSOR_END = "\x1b_pi:/fc\x07";
|
||||
// CURSOR_MARKER + FAKE_CURSOR_START + text + FAKE_CURSOR_END. Truncating the line right after the cursor cell drops the end.
|
||||
const FOCUSED_FAKE_CURSOR = /(\x1b_pi:c\x07)\x1b_pi:fc\x07(.*?)(?:\x1b_pi:\/fc\x07|$)/s;
|
||||
|
||||
/** Wrap text in fake cursor markers. */
|
||||
export function renderFakeCursor(text: string): string {
|
||||
return `${FAKE_CURSOR_START}${text}${FAKE_CURSOR_END}`;
|
||||
}
|
||||
|
||||
export { visibleWidth };
|
||||
|
||||
/**
|
||||
|
|
@ -1359,7 +1370,7 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight);
|
||||
|
||||
// Render component at calculated width
|
||||
let overlayLines = component.render(width);
|
||||
let overlayLines = this.resolveFakeCursors(component.render(width));
|
||||
|
||||
// Apply maxHeight if specified
|
||||
if (maxHeight !== undefined && overlayLines.length > maxHeight) {
|
||||
|
|
@ -1459,6 +1470,19 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve fake cursor markers to reverse video. When the hardware cursor is shown, a fake cursor
|
||||
* directly after CURSOR_MARKER is dropped because the terminal cursor is drawn there. Fake cursors
|
||||
* of components without focus have no CURSOR_MARKER and stay visible.
|
||||
*/
|
||||
protected resolveFakeCursors(lines: string[]): string[] {
|
||||
return lines.map((line) => {
|
||||
if (!line.includes(FAKE_CURSOR_START)) return line;
|
||||
const resolved = this.showHardwareCursor ? line.replace(FOCUSED_FAKE_CURSOR, "$1$2") : line;
|
||||
return resolved.replaceAll(FAKE_CURSOR_START, "\x1b[7m").replaceAll(FAKE_CURSOR_END, "\x1b[27m");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the terminal's theme colors: the default foreground (OSC 10), the default background
|
||||
* (OSC 11), and ANSI colors 0-15 (OSC 4), followed by a DA1 request that marks the end of the
|
||||
|
|
|
|||
|
|
@ -1351,8 +1351,13 @@ export function extractSegments(
|
|||
// Include ANSI codes in their respective segments
|
||||
if (currentCol < beforeEnd) {
|
||||
pendingAnsiBefore += ansi.code;
|
||||
} else if (currentCol >= afterStart && currentCol < afterEnd && afterStarted) {
|
||||
// Only include after we've started "after" (styling already prepended)
|
||||
} else if (
|
||||
currentCol >= afterStart &&
|
||||
currentCol < afterEnd &&
|
||||
(afterStarted || ansi.code.startsWith("\x1b_"))
|
||||
) {
|
||||
// SGR codes are only included after "after" started (styling already prepended). Zero-width APC
|
||||
// markers like CURSOR_MARKER are not tracked styling, so keep them at the boundary too.
|
||||
after += ansi.code;
|
||||
}
|
||||
i += ansi.length;
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import { describe, it } from "node:test";
|
|||
import { stripVTControlCharacters } from "node:util";
|
||||
import { type AutocompleteProvider, CombinedAutocompleteProvider } from "../src/autocomplete.ts";
|
||||
import { Editor, wordWrapLine } from "../src/components/editor.ts";
|
||||
import type { TUI } from "../src/tui.ts";
|
||||
import { FAKE_CURSOR_START, renderFakeCursor, type TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { visibleWidth } from "../src/utils.ts";
|
||||
import { stripTerminalSequences, visibleWidth } from "../src/utils.ts";
|
||||
import { defaultEditorTheme } from "./test-themes.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
|
|
@ -800,7 +800,7 @@ describe("Editor component", () => {
|
|||
}
|
||||
|
||||
// Verify content split correctly
|
||||
const contentLines = lines.slice(1, -1).map((l) => stripVTControlCharacters(l).trim());
|
||||
const contentLines = lines.slice(1, -1).map((l) => stripTerminalSequences(l).trim());
|
||||
assert.strictEqual(contentLines.length, 2);
|
||||
assert.strictEqual(contentLines[0], "日本語テス"); // 5 chars = 10 columns
|
||||
assert.strictEqual(contentLines[1], "ト"); // 1 char = 2 columns (+ padding)
|
||||
|
|
@ -832,7 +832,7 @@ describe("Editor component", () => {
|
|||
|
||||
// The cursor (reverse video space) should be visible
|
||||
const contentLine = lines[1]!;
|
||||
assert.ok(contentLine.includes("\x1b[7m"), "Should have reverse video cursor");
|
||||
assert.ok(contentLine.includes(FAKE_CURSOR_START), "Should have fake cursor");
|
||||
|
||||
// Line should still be correct width
|
||||
assert.strictEqual(visibleWidth(contentLine), width);
|
||||
|
|
@ -863,7 +863,7 @@ describe("Editor component", () => {
|
|||
let lines = editor.render(width + paddingX);
|
||||
let contentLines = lines.slice(1, -1);
|
||||
assert.strictEqual(contentLines.length, 1, "Should be 1 content line before wrap");
|
||||
assert.ok(contentLines[0]!.endsWith("\x1b[7m \x1b[0m"), "Cursor should be at end of line");
|
||||
assert.ok(contentLines[0]!.endsWith(renderFakeCursor(" ")), "Cursor should be at end of line");
|
||||
|
||||
// Type 1 more → text wraps to second line
|
||||
editor.handleInput("a");
|
||||
|
|
@ -883,7 +883,7 @@ describe("Editor component", () => {
|
|||
const lines = editor.render(width);
|
||||
|
||||
// Get content lines (between borders)
|
||||
const contentLines = lines.slice(1, -1).map((l) => stripVTControlCharacters(l).trim());
|
||||
const contentLines = lines.slice(1, -1).map((l) => stripTerminalSequences(l).trim());
|
||||
|
||||
// Should NOT break mid-word
|
||||
// Line 1 should end with a complete word
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
resetCapabilitiesCache,
|
||||
setCapabilities,
|
||||
} from "../src/terminal-image.ts";
|
||||
import type { TuiMouseEvent } from "../src/tui.ts";
|
||||
import { renderFakeCursor, type TuiMouseEvent } from "../src/tui.ts";
|
||||
import { TuiAltScreen } from "../src/tui-alt-screen.ts";
|
||||
import { stripTerminalSequences, visibleWidth } from "../src/utils.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
|
@ -1362,6 +1362,26 @@ describe("TuiAltScreen", () => {
|
|||
tui.stop();
|
||||
});
|
||||
|
||||
it("keeps the selection highlight after a fake cursor cell", async () => {
|
||||
const terminal = new RecordingTerminal(20, 4);
|
||||
const tui = new TuiAltScreen(terminal);
|
||||
tui.addChild(new Text(`> ${renderFakeCursor("a")}bcd`, 0, 0));
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
terminal.sendInput("\x1b[<0;1;1M");
|
||||
terminal.sendInput("\x1b[<32;6;1M");
|
||||
terminal.sendInput("\x1b[<3;6;1m");
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.ok(
|
||||
terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[27m\x1b[7mbcd")),
|
||||
"selection inverse must be reapplied after the fake cursor ends",
|
||||
);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("uses an injected copySelection handler instead of OSC 52 and reports success", async () => {
|
||||
const terminal = new RecordingTerminal(20, 4);
|
||||
const copied: string[] = [];
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { join } from "node:path";
|
|||
import { describe, it } from "node:test";
|
||||
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
||||
import { Image } from "../src/components/image.ts";
|
||||
import { Input } from "../src/components/input.ts";
|
||||
import type { Terminal } from "../src/terminal.ts";
|
||||
import {
|
||||
deleteKittyImage,
|
||||
|
|
@ -13,8 +14,9 @@ import {
|
|||
setCapabilities,
|
||||
setCellDimensions,
|
||||
} from "../src/terminal-image.ts";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { type Component, CURSOR_MARKER, renderFakeCursor, type TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { sliceByColumn } from "../src/utils.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
class TestComponent implements Component {
|
||||
|
|
@ -951,3 +953,72 @@ describe("TUI differential rendering", () => {
|
|||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TUI fake cursor", () => {
|
||||
it("draws the focused fake cursor in reverse video and omits it when the hardware cursor is shown", async () => {
|
||||
const terminal = new LoggingVirtualTerminal(20, 5);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const input = new Input();
|
||||
input.setValue("abc");
|
||||
tui.addChild(input);
|
||||
tui.setFocus(input);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
assert.ok(terminal.getWrites().includes("> \x1b[7ma\x1b[27mbc"));
|
||||
|
||||
terminal.clearWrites();
|
||||
tui.setShowHardwareCursor(true);
|
||||
await terminal.waitForRender();
|
||||
assert.ok(!terminal.getWrites().includes("\x1b[7m"));
|
||||
assert.ok(!terminal.getWrites().includes("\x1b_pi:"));
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("keeps the fake cursor of an unfocused input when the hardware cursor is shown", async () => {
|
||||
const terminal = new LoggingVirtualTerminal(20, 5);
|
||||
const tui: TUI = new TuiMainScreen(terminal, true);
|
||||
const input = new Input();
|
||||
input.setValue("abc");
|
||||
tui.addChild(input);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
assert.ok(terminal.getWrites().includes("> \x1b[7ma\x1b[27mbc"));
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("omits the focused fake cursor when truncation dropped its end marker", async () => {
|
||||
const terminal = new LoggingVirtualTerminal(20, 5);
|
||||
const tui: TUI = new TuiMainScreen(terminal, true);
|
||||
const base = new TestComponent();
|
||||
base.lines = [sliceByColumn(`ab${CURSOR_MARKER}${renderFakeCursor("c")}def`, 0, 3, true)];
|
||||
tui.addChild(base);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
assert.ok(!terminal.getWrites().includes("\x1b[7m"));
|
||||
assert.ok(terminal.getWrites().endsWith("\x1b[3G"));
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("keeps the cursor when an overlay ends directly left of it", async () => {
|
||||
for (const showHardwareCursor of [false, true]) {
|
||||
const terminal = new LoggingVirtualTerminal(20, 5);
|
||||
const tui: TUI = new TuiMainScreen(terminal, showHardwareCursor);
|
||||
const base = new TestComponent();
|
||||
base.lines = [`abc${CURSOR_MARKER}${renderFakeCursor("d")}ef`];
|
||||
tui.addChild(base);
|
||||
const overlay = new TestComponent();
|
||||
overlay.lines = ["XXX"];
|
||||
tui.showOverlay(overlay, { row: 0, col: 0, width: 3 });
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const writes = terminal.getWrites();
|
||||
assert.ok(writes.includes("XXX"));
|
||||
assert.strictEqual(writes.includes("\x1b[7md"), !showHardwareCursor);
|
||||
// The hardware cursor is positioned at column 4 in both modes
|
||||
assert.ok(writes.endsWith("\x1b[4G"));
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue