kimi-code / packages /pi-tui /test /mouse-components.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
4e23b01 verified
Raw History Blame Contribute Delete
11.2 kB
import assert from "node:assert";
import { describe, it } from "node:test";
import { AltScreenSearchComponent } from "../src/alt-screen-search.ts";
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("positions the input cursor relative to a custom prompt", () => {
const input = new Input({ prompt: " " });
input.setValue("hello");
input.render(20);
assert.strictEqual(input.handleMouse(mouse("press", 3, 0, 20, 1))?.handled, true);
input.handleInput("X");
assert.strictEqual(input.getValue(), "heXllo");
});
it("routes search field presses to the embedded input", () => {
let query = "";
const component = new AltScreenSearchComponent((value) => {
query = value;
});
component.render(40);
component.handleInput("hello");
assert.strictEqual(component.handleMouse(mouse("press", 4, 1, 40, 3))?.handled, true);
component.handleInput("X");
assert.strictEqual(query, "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" }]);
});
for (const row of [0, 4]) {
it(`ignores hover and clicks visible select-list row ${row} after scrolling`, () => {
const list = new SelectList(
Array.from({ length: 12 }, (_, i) => ({ value: `item-${i}`, label: `Item ${i}` })),
5,
selectTheme,
);
const changes: string[] = [];
let selected: string | undefined;
list.onSelectionChange = (item) => changes.push(item.value);
list.onSelect = (item) => {
selected = item.value;
};
list.setSelectedIndex(5);
list.handleMouse({ ...mouse("wheel", 1, row), wheelDelta: 1 });
assert.strictEqual(list.getSelectedItem()?.value, "item-6");
assert.deepStrictEqual(changes, ["item-6"]);
const before = list.render(80);
assert.match(before[row], new RegExp(`Item ${4 + row}$`));
for (const y of [0, 1, 2, 3, 4, row]) {
assert.strictEqual(list.handleMouse({ ...mouse("move", 1, y), button: "none" }), undefined);
assert.deepStrictEqual(list.render(80), before);
}
assert.strictEqual(list.getSelectedItem()?.value, "item-6");
assert.deepStrictEqual(changes, ["item-6"]);
assert.strictEqual(selected, undefined);
list.handleMouse(mouse("press", 1, row));
list.render(80);
list.handleMouse(mouse("click", 1, row));
assert.strictEqual(selected, `item-${4 + row}`);
assert.deepStrictEqual(changes, ["item-6", `item-${4 + row}`]);
});
it(`ignores hover and clicks visible settings row ${row} after scrolling`, () => {
const changes: Array<{ id: string; value: string }> = [];
const list = new SettingsList(
Array.from({ length: 12 }, (_, i) => ({
id: `item-${i}`,
label: `Item ${i}`,
description: `Description ${i}`,
currentValue: "off",
values: ["off", "on"],
})),
5,
settingsTheme,
(id, value) => changes.push({ id, value }),
() => {},
{ enableSearch: true },
);
list.selectItem("item-5");
list.handleMouse({ ...mouse("wheel", 1, row + 2), wheelDelta: 1 });
const before = list.render(80);
assert.match(before[4], /^> Item 6/);
assert.match(before[row + 2], new RegExp(`Item ${4 + row} `));
for (const y of [0, 1, 2, 3, 4, row]) {
assert.strictEqual(list.handleMouse({ ...mouse("move", 1, y + 2), button: "none" }), undefined);
assert.deepStrictEqual(list.render(80), before);
}
assert.deepStrictEqual(changes, []);
list.handleMouse(mouse("press", 1, row + 2));
list.render(80);
list.handleMouse(mouse("click", 1, row + 2));
assert.deepStrictEqual(changes, [{ id: `item-${4 + row}`, value: "on" }]);
});
}
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();
});
it("translates clicks by the cropped line offset of an over-tall component", async () => {
const terminal = new VirtualTerminal(20, 5);
const tui = new TuiAltScreen(terminal);
const editor = new Editor(tui, editorTheme);
editor.setText("l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10");
tui.setLayoutRoot(editor);
tui.start();
tui.setFocus(editor);
await terminal.waitForRender();
// The editor renders 7 rows into 5, so the layout crops the top row to
// keep the cursor row visible; the second screen row displays "l7".
terminal.sendInput("\x1b[<0;2;2M");
terminal.sendInput("\x1b[<0;2;2m");
terminal.sendInput("X");
await terminal.waitForRender();
assert.strictEqual(editor.getText(), "l1\nl2\nl3\nl4\nl5\nl6\nlX7\nl8\nl9\nl10");
tui.stop();
});
it("dispatches cropped rows within the uncropped height to container subclasses", async () => {
const terminal = new VirtualTerminal(20, 5);
const tui = new TuiAltScreen(terminal);
const editor = new Editor(tui, editorTheme);
editor.setText("l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10");
// A Container subclass with its own handleMouse (like the app's
// GutterContainer) receives dispatch instead of being skipped.
const wrapper = new (class extends Container {
override handleMouse(event: TuiMouseEvent) {
return super.handleMouse(event);
}
})();
wrapper.addChild(editor);
tui.setLayoutRoot(wrapper);
tui.start();
tui.setFocus(editor);
await terminal.waitForRender();
// The wrapper renders 7 rows into 5 and is cropped by one row; the last
// screen row displays "l10".
terminal.sendInput("\x1b[<0;2;5M");
terminal.sendInput("\x1b[<0;2;5m");
terminal.sendInput("X");
await terminal.waitForRender();
assert.strictEqual(editor.getText(), "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nlX10");
tui.stop();
});
it("maps clicks from the grapheme-aligned scroll origin", () => {
const input = new Input({ prompt: "" });
input.setValue("😀a😀bc");
// setValue keeps the cursor at 0; walk to the end, then back to just
// after "a" so horizontal scrolling starts inside the first emoji.
for (let i = 0; i < 5; i++) input.handleInput("\x1b[C");
for (let i = 0; i < 3; i++) input.handleInput("\x1b[D");
input.render(5);
// "a" is the first displayed character; clicking it must place the
// cursor after "😀" rather than at the raw scroll column.
assert.strictEqual(input.handleMouse(mouse("press", 0, 0, 5, 1))?.handled, true);
input.handleInput("X");
assert.strictEqual(input.getValue(), "😀Xa😀bc");
});
it("selects and copies editor text on drag instead of moving the cursor", async () => {
const terminal = new VirtualTerminal(20, 6);
const copied: string[] = [];
const tui = new TuiAltScreen(terminal, undefined, undefined, {
copySelection: async (text) => {
copied.push(text);
return true;
},
});
const editor = new Editor(tui, editorTheme);
editor.setText("hello world");
tui.addChild(editor);
tui.start();
await terminal.waitForRender();
const cursorBefore = editor.getCursor();
terminal.sendInput("\x1b[<0;1;2M");
terminal.sendInput("\x1b[<32;5;2M");
terminal.sendInput("\x1b[<0;5;2m");
await terminal.waitForRender();
assert.deepStrictEqual(copied, ["hello"]);
assert.deepStrictEqual(editor.getCursor(), cursorBefore);
tui.stop();
});
});