@luan.sh/pi-libtui 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/native-revision +1 -0
- package/package.json +58 -0
- package/src/activity-presentation.ts +75 -0
- package/src/appearance.ts +632 -0
- package/src/background-surface.ts +125 -0
- package/src/color/palette.ts +186 -0
- package/src/color/preview.ts +157 -0
- package/src/color/resolver.ts +75 -0
- package/src/color/theme.ts +531 -0
- package/src/component-stack.ts +247 -0
- package/src/content/markdown-code.ts +60 -0
- package/src/content/terminal-text.ts +135 -0
- package/src/content/text.ts +105 -0
- package/src/controls/action-panel.ts +420 -0
- package/src/controls/dialog-button-bar.ts +287 -0
- package/src/controls/multi-select.ts +467 -0
- package/src/controls/picker-panel.ts +447 -0
- package/src/controls/screen-icon-actions.ts +134 -0
- package/src/controls/searchable-select.ts +394 -0
- package/src/controls/select-box.ts +224 -0
- package/src/controls/selectable-list.ts +433 -0
- package/src/controls/selection-action-bar.ts +382 -0
- package/src/controls/semantic-input.ts +23 -0
- package/src/controls/tab.ts +189 -0
- package/src/cursor.ts +288 -0
- package/src/decoration/editor-pills.ts +156 -0
- package/src/decoration/glyphs.ts +326 -0
- package/src/decoration/pointer-interaction.ts +123 -0
- package/src/decoration/powerline-pill.ts +104 -0
- package/src/decoration/status.ts +185 -0
- package/src/decoration/transient-pill.ts +176 -0
- package/src/diff/index.ts +21 -0
- package/src/diff/model.ts +76 -0
- package/src/diff/parse.ts +310 -0
- package/src/diff/pierre-syntax.ts +86 -0
- package/src/diff/render.ts +504 -0
- package/src/diff/structured.ts +264 -0
- package/src/diff/view.ts +75 -0
- package/src/editor/chrome.ts +341 -0
- package/src/editor/composition.ts +53 -0
- package/src/editor/layout.ts +81 -0
- package/src/editor/presentation.ts +35 -0
- package/src/editor/protocol.ts +188 -0
- package/src/editor.ts +41 -0
- package/src/extension.ts +30 -0
- package/src/folding.ts +192 -0
- package/src/host/cursor-bridge.ts +210 -0
- package/src/host/editor-bridge.ts +148 -0
- package/src/host/extension-host.ts +231 -0
- package/src/host/mouse-bridge.ts +212 -0
- package/src/host/mouse-dispatch.ts +400 -0
- package/src/host/mouse-input.ts +85 -0
- package/src/host/mouse-targets.ts +238 -0
- package/src/host/pi-layout-adapter.ts +298 -0
- package/src/host/pi-native-selection.ts +227 -0
- package/src/host/split-pane-bridge.ts +720 -0
- package/src/host/transcript-bridge.ts +236 -0
- package/src/index.ts +334 -0
- package/src/line-layout.ts +6 -0
- package/src/motion.ts +1409 -0
- package/src/mouse/events.ts +55 -0
- package/src/mouse/fullscreen-layout.ts +260 -0
- package/src/mouse/registry.ts +348 -0
- package/src/mouse.ts +37 -0
- package/src/native-binary.ts +189 -0
- package/src/overlay/anchored.ts +91 -0
- package/src/overlay/detail-card.ts +158 -0
- package/src/overlay/dialog.ts +266 -0
- package/src/overlay/floating.ts +106 -0
- package/src/overlay/framed-editor.ts +236 -0
- package/src/overlay/fullscreen.ts +170 -0
- package/src/overlay/hover-tooltip.ts +109 -0
- package/src/overlay/modal-mount.ts +118 -0
- package/src/panels.ts +207 -0
- package/src/render-cache.ts +80 -0
- package/src/render-epoch.ts +48 -0
- package/src/request-animation.ts +160 -0
- package/src/scrollbar.ts +27 -0
- package/src/selection.ts +148 -0
- package/src/split-pane.ts +232 -0
- package/src/status-presentation.ts +411 -0
- package/src/stream.ts +543 -0
- package/src/syntax.ts +316 -0
- package/src/terminal/bridge-client.ts +243 -0
- package/src/terminal/embedding.ts +6 -0
- package/src/terminal/output.ts +151 -0
- package/src/terminal/projection.ts +362 -0
- package/src/terminal/pty-host.ts +263 -0
- package/src/terminal/pty-pane.ts +308 -0
- package/src/terminal-colors.ts +334 -0
- package/src/tool/action.ts +170 -0
- package/src/tool/activity.ts +500 -0
- package/src/tool/call-preview.ts +44 -0
- package/src/tool/disclosure-action.ts +89 -0
- package/src/tool/expanded-region-viewport.ts +103 -0
- package/src/tool/index.ts +26 -0
- package/src/tool/output.ts +271 -0
- package/src/tool/transcript.ts +150 -0
- package/src/tool/view-region.ts +581 -0
- package/test/appearance.test.ts +183 -0
- package/test/bounded-stream.test.ts +150 -0
- package/test/bridge-client.test.ts +42 -0
- package/test/color-preview.test.ts +23 -0
- package/test/component-stack.test.ts +206 -0
- package/test/components.test.ts +1165 -0
- package/test/content-text.test.ts +21 -0
- package/test/cursor-bridge.test.ts +151 -0
- package/test/cursor.test.ts +147 -0
- package/test/editor-chrome.test.ts +119 -0
- package/test/editor-layout.test.ts +30 -0
- package/test/editor-registry.test.ts +203 -0
- package/test/editor-theme.test.ts +33 -0
- package/test/editor-token-pill.test.ts +72 -0
- package/test/floating-overlay.test.ts +57 -0
- package/test/folding.test.ts +147 -0
- package/test/harmonious-theme.test.ts +23 -0
- package/test/interaction.test.ts +104 -0
- package/test/key-icons.test.ts +62 -0
- package/test/markdown-code.test.ts +24 -0
- package/test/modal-overlay-mount.test.ts +94 -0
- package/test/motion.test.ts +649 -0
- package/test/mouse/bridge.test.ts +1302 -0
- package/test/mouse/extension.test.ts +205 -0
- package/test/mouse/registry.test.ts +140 -0
- package/test/mouse/screen-decoration.test.ts +167 -0
- package/test/native-binary.test.ts +52 -0
- package/test/palette.test.ts +32 -0
- package/test/panels.test.ts +74 -0
- package/test/pointer-components.test.ts +151 -0
- package/test/progress.test.ts +54 -0
- package/test/pty-host.test.ts +126 -0
- package/test/pty-pane.test.ts +62 -0
- package/test/public-surface.test.ts +75 -0
- package/test/render-cache.test.ts +25 -0
- package/test/render-epoch.test.ts +119 -0
- package/test/request-animation.test.ts +193 -0
- package/test/screen-icon-actions.test.ts +69 -0
- package/test/select-box.test.ts +86 -0
- package/test/select-pointer.test.ts +369 -0
- package/test/selectable-list.test.ts +258 -0
- package/test/selection/registry.test.ts +44 -0
- package/test/selection-action-bar.test.ts +204 -0
- package/test/split-pane-bridge.test.ts +944 -0
- package/test/split-pane.test.ts +134 -0
- package/test/status-presentation.test.ts +39 -0
- package/test/syntax.test.ts +59 -0
- package/test/terminal-colors.test.ts +115 -0
- package/test/terminal-embedding.test.ts +12 -0
- package/test/terminal-projection.test.ts +312 -0
- package/test/theme-colors.test.ts +175 -0
- package/test/tool-output.test.ts +160 -0
- package/test/tool-transcript.test.ts +1190 -0
- package/test/unified-diff.test.ts +691 -0
- package/themes/harmonious.json +61 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { semanticEditorTheme } from "../src/editor.ts";
|
|
4
|
+
import { tuiTheme } from "../src/index.ts";
|
|
5
|
+
|
|
6
|
+
describe("semantic editor theme", () => {
|
|
7
|
+
test("maps every native editor role through pi-libtui tokens", () => {
|
|
8
|
+
// type-boundary: the test fixture supplies the Pi Theme methods read by tuiTheme.
|
|
9
|
+
type ThemeFixture = unknown;
|
|
10
|
+
const theme = {
|
|
11
|
+
name: "editor-test",
|
|
12
|
+
getColorMode: () => "truecolor",
|
|
13
|
+
getFgAnsi: (token: string) =>
|
|
14
|
+
({
|
|
15
|
+
accent: "\x1b[38;2;80;120;240m",
|
|
16
|
+
border: "\x1b[38;2;70;80;100m",
|
|
17
|
+
dim: "\x1b[38;2;60;65;75m",
|
|
18
|
+
muted: "\x1b[38;2;90;95;105m",
|
|
19
|
+
text: "\x1b[38;2;230;230;235m",
|
|
20
|
+
})[token] ?? "\x1b[39m",
|
|
21
|
+
getBgAnsi: () => "\x1b[48;2;20;22;28m",
|
|
22
|
+
} as ThemeFixture as Theme;
|
|
23
|
+
const colors = tuiTheme(theme);
|
|
24
|
+
const editor = semanticEditorTheme(theme);
|
|
25
|
+
|
|
26
|
+
expect(editor.borderColor("border")).toBe(colors.fg("border", "border"));
|
|
27
|
+
expect(editor.selectList.selectedPrefix("prefix")).toBe(colors.fg("accent", "prefix"));
|
|
28
|
+
expect(editor.selectList.selectedText("selected")).toBe(colors.fg("accent", "selected"));
|
|
29
|
+
expect(editor.selectList.description("description")).toBe(colors.fg("text.muted", "description"));
|
|
30
|
+
expect(editor.selectList.scrollInfo("scroll")).toBe(colors.fg("text.muted", "scroll"));
|
|
31
|
+
expect(editor.selectList.noMatch("empty")).toBe(colors.fg("text.muted", "empty"));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
4
|
+
import { parseBackgroundAnsi, tuiTheme } from "../src/color/theme.ts";
|
|
5
|
+
import { renderEditorPasteMarkerPills, renderEditorTokenPills } from "../src/decoration/editor-pills.ts";
|
|
6
|
+
|
|
7
|
+
const theme = {
|
|
8
|
+
name: "editor-token-test",
|
|
9
|
+
getColorMode: () => "truecolor",
|
|
10
|
+
getFgAnsi: () => "\x1b[38;2;220;220;220m",
|
|
11
|
+
getBgAnsi: () => "\x1b[48;2;30;34;42m",
|
|
12
|
+
} as never as Theme;
|
|
13
|
+
|
|
14
|
+
describe("editor token pills", () => {
|
|
15
|
+
test("chooses a subdued pill surface distinct from a dark editor destination", () => {
|
|
16
|
+
const destination = "\x1b[48;2;30;34;42m";
|
|
17
|
+
const rendered = renderEditorPasteMarkerPills([`${destination}[paste #1 +30 lines]\x1b[49m`], 40, theme).lines[0]!;
|
|
18
|
+
const backgrounds = [...rendered.matchAll(/\x1b\[48;2;\d+;\d+;\d+m/gu)].map((match) => match[0]);
|
|
19
|
+
expect(backgrounds).toContain(destination);
|
|
20
|
+
expect(backgrounds).toContain("\x1b[48;2;68;71;78m");
|
|
21
|
+
expect(stripTerminalSequences(rendered)).toContain("paste #1 +30 lines");
|
|
22
|
+
expect(parseBackgroundAnsi(backgrounds.find((background) => background !== destination) ?? "")).toBeDefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("chooses a subdued pill surface distinct from a light editor destination", () => {
|
|
26
|
+
const lightTheme = {
|
|
27
|
+
name: "light-editor-token-test",
|
|
28
|
+
getColorMode: () => "truecolor",
|
|
29
|
+
getFgAnsi: () => "\x1b[38;2;25;25;25m",
|
|
30
|
+
getBgAnsi: () => "\x1b[48;2;235;235;235m",
|
|
31
|
+
} as never as Theme;
|
|
32
|
+
const destination = "\x1b[48;2;235;235;235m";
|
|
33
|
+
const rendered = renderEditorPasteMarkerPills([`${destination}[paste #1 +30 lines]\x1b[49m`], 40, lightTheme)
|
|
34
|
+
.lines[0]!;
|
|
35
|
+
|
|
36
|
+
expect(rendered).toContain("\x1b[48;2;193;193;193m");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("renders a private atomic token without changing the cursor text around it", () => {
|
|
40
|
+
const token = "\ue100";
|
|
41
|
+
const rendered = renderEditorTokenPills([`before \x1b[7m${token}\x1b[0m after`], 40, theme, [
|
|
42
|
+
{ token, label: "Image #1", icon: "view-image", iconTone: { hue: "magenta", shade: 2 } },
|
|
43
|
+
]).lines[0]!;
|
|
44
|
+
const plain = stripTerminalSequences(rendered);
|
|
45
|
+
expect(plain).toContain("before ");
|
|
46
|
+
expect(plain).toContain("Image #1");
|
|
47
|
+
expect(plain).toContain(" after");
|
|
48
|
+
expect(rendered).not.toContain(token);
|
|
49
|
+
expect(rendered).toContain(tuiTheme(theme).fgAnsi({ hue: "magenta", shade: 2 }));
|
|
50
|
+
expect(rendered).toContain("\x1b[48;2;68;71;78m");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("lets a feature renderer preserve inverse and destination state", () => {
|
|
54
|
+
const token = "\ue101";
|
|
55
|
+
const contexts: Array<{ inverse: boolean; destination: string }> = [];
|
|
56
|
+
const destination = "\x1b[48;5;22m";
|
|
57
|
+
const result = renderEditorTokenPills([`${destination}before \x1b[7m${token}\x1b[0m after`], 40, theme, [
|
|
58
|
+
{
|
|
59
|
+
token,
|
|
60
|
+
icon: false,
|
|
61
|
+
label: "Token",
|
|
62
|
+
render: ({ content, destinationBackgroundAnsi, inverse }) => {
|
|
63
|
+
contexts.push({ inverse, destination: destinationBackgroundAnsi });
|
|
64
|
+
return content.label;
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
]);
|
|
68
|
+
expect(contexts).toEqual([{ inverse: true, destination }]);
|
|
69
|
+
expect(result.pills).toEqual([{ line: 0, x: 7, width: 5, token }]);
|
|
70
|
+
expect(stripTerminalSequences(result.lines[0]!)).toContain("before Token after");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { type Component, stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
3
|
+
import { FloatingOverlay } from "../src/overlay/floating.ts";
|
|
4
|
+
|
|
5
|
+
test("floating overlays own placement and pointer translation", () => {
|
|
6
|
+
const baseEvents: number[] = [];
|
|
7
|
+
const overlayEvents: number[] = [];
|
|
8
|
+
const base = {
|
|
9
|
+
render: () => ["abcdefgh"],
|
|
10
|
+
invalidate() {},
|
|
11
|
+
onMouse: (event: { col: number }) => {
|
|
12
|
+
baseEvents.push(event.col);
|
|
13
|
+
return true;
|
|
14
|
+
},
|
|
15
|
+
} as Component;
|
|
16
|
+
const overlay = {
|
|
17
|
+
render: () => ["XY"],
|
|
18
|
+
invalidate() {},
|
|
19
|
+
onMouse: (event: { col: number }) => {
|
|
20
|
+
overlayEvents.push(event.col);
|
|
21
|
+
return true;
|
|
22
|
+
},
|
|
23
|
+
} as Component;
|
|
24
|
+
const floating = new FloatingOverlay({ base, overlay, overlayWidth: () => 2 });
|
|
25
|
+
|
|
26
|
+
expect(floating.render(8).map(stripTerminalSequences)).toEqual(["abcdefXY"]);
|
|
27
|
+
expect(floating.onMouse({ type: "press", row: 0, col: 7, button: 0 } as never)).toBe(true);
|
|
28
|
+
expect(floating.onMouse({ type: "press", row: 0, col: 2, button: 0 } as never)).toBe(true);
|
|
29
|
+
expect(overlayEvents).toEqual([1]);
|
|
30
|
+
expect(baseEvents).toEqual([2]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("floating overlays can consume outside presses for dismissal", () => {
|
|
34
|
+
let dismissed = 0;
|
|
35
|
+
let basePresses = 0;
|
|
36
|
+
const component = {
|
|
37
|
+
render: () => ["abcdefgh"],
|
|
38
|
+
invalidate() {},
|
|
39
|
+
onMouse: () => {
|
|
40
|
+
basePresses += 1;
|
|
41
|
+
return true;
|
|
42
|
+
},
|
|
43
|
+
} as Component;
|
|
44
|
+
const floating = new FloatingOverlay({
|
|
45
|
+
base: component,
|
|
46
|
+
overlay: { render: () => ["XY"], invalidate() {} },
|
|
47
|
+
overlayWidth: () => 2,
|
|
48
|
+
onOutsidePress: () => {
|
|
49
|
+
dismissed += 1;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
floating.render(8);
|
|
54
|
+
expect(floating.onMouse({ type: "press", row: 0, col: 2, button: 0 } as never)).toBe(true);
|
|
55
|
+
expect(dismissed).toBe(1);
|
|
56
|
+
expect(basePresses).toBe(0);
|
|
57
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { runInNewContext } from "node:vm";
|
|
3
|
+
import {
|
|
4
|
+
clearFoldingCurrent,
|
|
5
|
+
ensureFoldingRegistry,
|
|
6
|
+
FOLD_TARGET_AT_ROW,
|
|
7
|
+
FOLDING_REGISTRY_KEY,
|
|
8
|
+
type FoldingRegistry,
|
|
9
|
+
type FoldTarget,
|
|
10
|
+
foldTargetAt,
|
|
11
|
+
} from "../src/folding.ts";
|
|
12
|
+
|
|
13
|
+
function target(): FoldTarget & { openCount: number; closeCount: number; folded: boolean } {
|
|
14
|
+
return {
|
|
15
|
+
folded: true,
|
|
16
|
+
openCount: 0,
|
|
17
|
+
closeCount: 0,
|
|
18
|
+
isFolded() {
|
|
19
|
+
return this.folded;
|
|
20
|
+
},
|
|
21
|
+
open() {
|
|
22
|
+
this.folded = false;
|
|
23
|
+
this.openCount += 1;
|
|
24
|
+
},
|
|
25
|
+
close() {
|
|
26
|
+
this.folded = true;
|
|
27
|
+
this.closeCount += 1;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test("applies Vim fold operations to an explicitly resolved target and all registered targets", () => {
|
|
33
|
+
const registry = ensureFoldingRegistry({} as typeof globalThis);
|
|
34
|
+
const first = target();
|
|
35
|
+
const second = target();
|
|
36
|
+
const removeFirst = registry.register(first);
|
|
37
|
+
const removeSecond = registry.register(second);
|
|
38
|
+
expect(registry.apply("open", first)).toBe(true);
|
|
39
|
+
expect(first.folded).toBe(false);
|
|
40
|
+
expect(second.folded).toBe(true);
|
|
41
|
+
expect(registry.apply("close-all")).toBe(true);
|
|
42
|
+
expect(first.closeCount).toBe(1);
|
|
43
|
+
expect(second.closeCount).toBe(1);
|
|
44
|
+
expect(registry.apply("open-all")).toBe(true);
|
|
45
|
+
expect(first.openCount).toBe(2);
|
|
46
|
+
expect(second.openCount).toBe(1);
|
|
47
|
+
|
|
48
|
+
removeFirst();
|
|
49
|
+
removeSecond();
|
|
50
|
+
expect(registry.apply("open")).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("reuses structurally valid hidden state from another JavaScript realm", () => {
|
|
54
|
+
// type-boundary: node:vm returns values from an isolated realm; the folding registry validator narrows it.
|
|
55
|
+
type ForeignRealmValue = unknown;
|
|
56
|
+
const foreignRegistry = runInNewContext(`
|
|
57
|
+
const state = { targets: [], current: undefined };
|
|
58
|
+
const registry = {
|
|
59
|
+
protocol: "pi-libtui/folding/registry/v2",
|
|
60
|
+
version: 2,
|
|
61
|
+
register(target) { state.targets.push(target); return () => state.targets.splice(state.targets.indexOf(target), 1); },
|
|
62
|
+
setCurrent(target) { state.current = state.targets.includes(target) ? target : undefined; },
|
|
63
|
+
get current() { return state.current; },
|
|
64
|
+
has(target) { return state.targets.includes(target); },
|
|
65
|
+
apply(operation, target) {
|
|
66
|
+
const targets = operation.endsWith("-all") ? [...state.targets] : target ? [target] : state.current ? [state.current] : [];
|
|
67
|
+
for (const item of targets) operation.startsWith("open") ? item.open() : item.close();
|
|
68
|
+
return targets.length > 0;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
Object.defineProperty(registry, Symbol.for("pi-libtui/folding/registry-state/v2"), { value: state });
|
|
72
|
+
registry;
|
|
73
|
+
`) as ForeignRealmValue as FoldingRegistry;
|
|
74
|
+
const scope = Object.create(null) as Record<PropertyKey, ForeignRealmValue>;
|
|
75
|
+
scope[FOLDING_REGISTRY_KEY] = foreignRegistry;
|
|
76
|
+
const registry = ensureFoldingRegistry(scope as typeof globalThis);
|
|
77
|
+
const owned = target();
|
|
78
|
+
|
|
79
|
+
registry.register(owned);
|
|
80
|
+
expect(registry).toBe(foreignRegistry);
|
|
81
|
+
expect(registry.apply("open-all")).toBe(true);
|
|
82
|
+
expect(owned.folded).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("replaces a compatible-looking registry with malformed hidden state", () => {
|
|
86
|
+
const scope = Object.create(null) as Record<PropertyKey, unknown>;
|
|
87
|
+
const malformed = {
|
|
88
|
+
protocol: "pi-libtui/folding/registry/v2",
|
|
89
|
+
version: 2,
|
|
90
|
+
register: () => () => {},
|
|
91
|
+
setCurrent() {},
|
|
92
|
+
has: () => false,
|
|
93
|
+
apply: () => false,
|
|
94
|
+
};
|
|
95
|
+
Object.defineProperty(malformed, Symbol.for("pi-libtui/folding/registry-state/v2"), {
|
|
96
|
+
value: { targets: [null] },
|
|
97
|
+
});
|
|
98
|
+
scope[FOLDING_REGISTRY_KEY] = malformed;
|
|
99
|
+
|
|
100
|
+
const registry = ensureFoldingRegistry(scope as typeof globalThis);
|
|
101
|
+
expect(registry).not.toBe(malformed);
|
|
102
|
+
expect(registry.apply("open-all")).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("never guesses a current row from registration order", () => {
|
|
106
|
+
const registry = ensureFoldingRegistry({} as typeof globalThis);
|
|
107
|
+
const first = target();
|
|
108
|
+
const second = target();
|
|
109
|
+
const removeFirst = registry.register(first);
|
|
110
|
+
const removeSecond = registry.register(second);
|
|
111
|
+
expect(registry.current).toBeUndefined();
|
|
112
|
+
expect(registry.apply("open")).toBe(false);
|
|
113
|
+
expect(first.openCount).toBe(0);
|
|
114
|
+
expect(second.openCount).toBe(0);
|
|
115
|
+
removeFirst();
|
|
116
|
+
removeSecond();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("resolves a fold only through explicit rendered-row ownership", () => {
|
|
120
|
+
const owned = target();
|
|
121
|
+
const component = {
|
|
122
|
+
[FOLD_TARGET_AT_ROW](row: number) {
|
|
123
|
+
return row === 2 ? owned : undefined;
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
expect(foldTargetAt(component, 2)).toBe(owned);
|
|
127
|
+
expect(foldTargetAt(component, 1)).toBeUndefined();
|
|
128
|
+
expect(foldTargetAt({}, 2)).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("clears only the target that still owns the current row", () => {
|
|
132
|
+
const scope = {} as typeof globalThis;
|
|
133
|
+
const registry = ensureFoldingRegistry(scope);
|
|
134
|
+
const first = target();
|
|
135
|
+
const second = target();
|
|
136
|
+
const removeFirst = registry.register(first);
|
|
137
|
+
const removeSecond = registry.register(second);
|
|
138
|
+
|
|
139
|
+
registry.setCurrent(second);
|
|
140
|
+
clearFoldingCurrent(first, scope);
|
|
141
|
+
expect(registry.current).toBe(second);
|
|
142
|
+
clearFoldingCurrent(second, scope);
|
|
143
|
+
expect(registry.current).toBeUndefined();
|
|
144
|
+
|
|
145
|
+
removeFirst();
|
|
146
|
+
removeSecond();
|
|
147
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { rgb } from "../src/color/palette.ts";
|
|
4
|
+
import { tuiTheme } from "../src/color/theme.ts";
|
|
5
|
+
import { terminalColorsRegistry } from "../src/terminal-colors.ts";
|
|
6
|
+
|
|
7
|
+
afterEach(() => terminalColorsRegistry().publish(undefined));
|
|
8
|
+
|
|
9
|
+
describe("harmonious theme", () => {
|
|
10
|
+
test("generates colors from a measured custom base16 palette", () => {
|
|
11
|
+
const base16 = Array.from({ length: 16 }, (_, index) => rgb(index * 16, index * 8, index * 4));
|
|
12
|
+
terminalColorsRegistry().publish({
|
|
13
|
+
scheme: "dark",
|
|
14
|
+
indexedPalette: "custom",
|
|
15
|
+
defaultBackground: base16[0],
|
|
16
|
+
defaultForeground: base16[15],
|
|
17
|
+
ansiBase16: base16,
|
|
18
|
+
});
|
|
19
|
+
const colors = tuiTheme({ name: "harmonious", getColorMode: () => "truecolor" } as Theme);
|
|
20
|
+
expect(colors.bgAnsi("surface.base")).toContain("0;0;0m");
|
|
21
|
+
expect(colors.fgAnsi("accent")).toMatch(/^\x1b\[38;2;/);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { PointerInteractionController } from "../src/decoration/pointer-interaction.ts";
|
|
3
|
+
import type { TuiMouseEvent, TuiMouseEventType } from "../src/mouse.ts";
|
|
4
|
+
|
|
5
|
+
interface Target {
|
|
6
|
+
id: string;
|
|
7
|
+
text: string;
|
|
8
|
+
box: { x: number; y: number; width: number; height: number };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function mouse(type: TuiMouseEventType, row: number, col: number, button?: 0 | 1 | 2): TuiMouseEvent {
|
|
12
|
+
return {
|
|
13
|
+
type,
|
|
14
|
+
row,
|
|
15
|
+
col,
|
|
16
|
+
screenRow: row,
|
|
17
|
+
screenCol: col,
|
|
18
|
+
button,
|
|
19
|
+
wheel: undefined,
|
|
20
|
+
shift: false,
|
|
21
|
+
alt: false,
|
|
22
|
+
ctrl: false,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("PointerInteractionController", () => {
|
|
27
|
+
test("tracks current targets and computes their union bounds", () => {
|
|
28
|
+
const controller = new PointerInteractionController<Target>({
|
|
29
|
+
key: (target) => target.id,
|
|
30
|
+
rect: (target) => target.box,
|
|
31
|
+
});
|
|
32
|
+
const targets: Target[] = [
|
|
33
|
+
{ id: "one", text: "one", box: { x: 2, y: 1, width: 3, height: 1 } },
|
|
34
|
+
{ id: "two", text: "two", box: { x: 8, y: 2, width: 2, height: 2 } },
|
|
35
|
+
];
|
|
36
|
+
controller.setTargets(targets);
|
|
37
|
+
|
|
38
|
+
expect(controller.targetAt(3, 1)).toBe(targets[0]);
|
|
39
|
+
expect(controller.targetAt(7, 1)).toBeUndefined();
|
|
40
|
+
expect(controller.getBounds()).toEqual({ x: 2, y: 1, width: 8, height: 3 });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("activates only a primary click that stays on the pressed target", () => {
|
|
44
|
+
const controller = new PointerInteractionController<Target>({
|
|
45
|
+
key: (target) => target.id,
|
|
46
|
+
rect: (target) => target.box,
|
|
47
|
+
});
|
|
48
|
+
const targets: Target[] = [
|
|
49
|
+
{ id: "one", text: "one", box: { x: 0, y: 0, width: 3, height: 1 } },
|
|
50
|
+
{ id: "two", text: "two", box: { x: 4, y: 0, width: 3, height: 1 } },
|
|
51
|
+
];
|
|
52
|
+
controller.setTargets(targets);
|
|
53
|
+
const activated: string[] = [];
|
|
54
|
+
const hover: string[] = [];
|
|
55
|
+
const handlers = {
|
|
56
|
+
onHoverChange: (target: Target | undefined) => hover.push(target?.id ?? "none"),
|
|
57
|
+
onActivate: (target: Target) => activated.push(target.id),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
expect(controller.handleMouse(mouse("enter", 0, 1), handlers)).toBe(true);
|
|
61
|
+
controller.handleMouse(mouse("press", 0, 1, 0), handlers);
|
|
62
|
+
controller.handleMouse(mouse("release", 0, 5, 0), handlers);
|
|
63
|
+
controller.handleMouse(mouse("press", 0, 1, 1), handlers);
|
|
64
|
+
controller.handleMouse(mouse("release", 0, 1, 1), handlers);
|
|
65
|
+
controller.handleMouse(mouse("press", 0, 1, 0), handlers);
|
|
66
|
+
controller.handleMouse(mouse("release", 0, 1, 0), handlers);
|
|
67
|
+
|
|
68
|
+
expect(hover).toEqual(["one"]);
|
|
69
|
+
expect(activated).toEqual(["one"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("clears hover and captured presses when targets leave the frame", () => {
|
|
73
|
+
const controller = new PointerInteractionController<Target>({
|
|
74
|
+
key: (target) => target.id,
|
|
75
|
+
rect: (target) => target.box,
|
|
76
|
+
});
|
|
77
|
+
const target: Target = { id: "one", text: "one", box: { x: 0, y: 0, width: 3, height: 1 } };
|
|
78
|
+
controller.setTargets([target]);
|
|
79
|
+
controller.handleMouse(mouse("enter", 0, 1));
|
|
80
|
+
controller.handleMouse(mouse("press", 0, 1, 0));
|
|
81
|
+
controller.setTargets([]);
|
|
82
|
+
|
|
83
|
+
expect(controller.hoveredTarget()).toBeUndefined();
|
|
84
|
+
expect(controller.getBounds()).toBeUndefined();
|
|
85
|
+
const activated: string[] = [];
|
|
86
|
+
controller.handleMouse(mouse("release", 0, 1, 0), { onActivate: (value) => activated.push(value.id) });
|
|
87
|
+
expect(activated).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("activates with the target geometry captured on press", () => {
|
|
91
|
+
const controller = new PointerInteractionController<Target>({
|
|
92
|
+
key: (target) => target.id,
|
|
93
|
+
rect: (target) => target.box,
|
|
94
|
+
});
|
|
95
|
+
const pressed: Target = { id: "one", text: "pressed", box: { x: 2, y: 1, width: 3, height: 1 } };
|
|
96
|
+
controller.setTargets([pressed]);
|
|
97
|
+
controller.handleMouse(mouse("press", 1, 3, 0));
|
|
98
|
+
controller.setTargets([{ id: "one", text: "rerendered", box: { x: 4, y: 1, width: 3, height: 1 } }]);
|
|
99
|
+
const activated: Target[] = [];
|
|
100
|
+
controller.handleMouse(mouse("release", 1, 5, 0), { onActivate: (target) => activated.push(target) });
|
|
101
|
+
|
|
102
|
+
expect(activated).toEqual([pressed]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { tuiTheme } from "../src/color/theme.ts";
|
|
5
|
+
import { keyIcon, renderKeyHint } from "../src/decoration/glyphs.ts";
|
|
6
|
+
|
|
7
|
+
// type-boundary: The test fixture implements only the Theme methods consumed by tuiTheme.
|
|
8
|
+
const theme = {
|
|
9
|
+
name: "dark",
|
|
10
|
+
getColorMode: () => "256color",
|
|
11
|
+
getFgAnsi: () => "\x1b[38;5;245m",
|
|
12
|
+
getBgAnsi: () => "\x1b[48;5;16m",
|
|
13
|
+
} as unknown as Theme;
|
|
14
|
+
|
|
15
|
+
describe("key icons", () => {
|
|
16
|
+
test("maps letters, modifiers, and named keys through Nerd Font glyphs", () => {
|
|
17
|
+
expect(keyIcon("a", "nerd-fonts")).toBe("");
|
|
18
|
+
expect(keyIcon("c", "nerd-fonts")).toBe("");
|
|
19
|
+
expect(keyIcon("z", "nerd-fonts")).toBe("");
|
|
20
|
+
expect(keyIcon("ctrl+super+alt+shift+c", "nerd-fonts")).toBe(" ");
|
|
21
|
+
expect(keyIcon("space", "nerd-fonts")).toBe("");
|
|
22
|
+
expect(keyIcon("tab", "nerd-fonts")).toBe("");
|
|
23
|
+
expect(keyIcon("return", "nerd-fonts")).toBe("");
|
|
24
|
+
expect(keyIcon("backspace", "nerd-fonts")).toBe("");
|
|
25
|
+
expect(keyIcon("escape", "nerd-fonts")).toBe("⎋");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("maps portable Unicode hints and keeps unsupported bases literal", () => {
|
|
29
|
+
expect(keyIcon("a", "unicode")).toBe("🅰");
|
|
30
|
+
expect(keyIcon("z", "unicode")).toBe("🆉");
|
|
31
|
+
expect(keyIcon("ctrl+super+alt+shift+c", "unicode")).toBe("⌃ ⌘ ⌥ ⇧ 🅲");
|
|
32
|
+
expect(keyIcon("space", "unicode")).toBe("␣");
|
|
33
|
+
expect(keyIcon("f12", "unicode")).toBe("f12");
|
|
34
|
+
expect(stripTerminalSequences(renderKeyHint(theme, "c", "unicode"))).toBe("🅲︎");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("uses a subdued semantic keycap, contrast foreground, and destination reset", () => {
|
|
38
|
+
const colors = tuiTheme(theme);
|
|
39
|
+
const dark = renderKeyHint(theme, "enter", "unicode", colors.color("surface.base"));
|
|
40
|
+
const light = renderKeyHint(theme, "enter", "unicode", colors.color("text.primary"));
|
|
41
|
+
expect(stripTerminalSequences(dark)).toBe("⏎");
|
|
42
|
+
expect(stripTerminalSequences(light)).toBe("⏎");
|
|
43
|
+
expect(dark).toContain(colors.bgAnsi("badge.neutral"));
|
|
44
|
+
expect(light).toContain(colors.bgAnsi("badge.neutral"));
|
|
45
|
+
expect(dark).toContain(colors.bgAnsi(colors.color("surface.base")));
|
|
46
|
+
expect(dark).toEndWith(colors.bgAnsi(colors.color("surface.base")));
|
|
47
|
+
expect(light).toEndWith(colors.bgAnsi(colors.color("text.primary")));
|
|
48
|
+
expect(visibleWidth(dark)).toBe(1);
|
|
49
|
+
expect(dark).not.toBe(light);
|
|
50
|
+
for (const destination of [
|
|
51
|
+
"surface.selected",
|
|
52
|
+
"badge.neutral",
|
|
53
|
+
"diff.added",
|
|
54
|
+
"diff.removed",
|
|
55
|
+
"diff.hunk",
|
|
56
|
+
] as const) {
|
|
57
|
+
const rendered = renderKeyHint(theme, "c", "unicode", colors.color(destination));
|
|
58
|
+
expect(rendered).toEndWith(colors.bgAnsi(destination));
|
|
59
|
+
expect(visibleWidth(rendered)).toBe(1);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { markdownCodeRanges } from "../src/content/markdown-code.ts";
|
|
3
|
+
|
|
4
|
+
test("finds fenced and inline Markdown code without hiding ordinary text", () => {
|
|
5
|
+
expect(
|
|
6
|
+
markdownCodeRanges([
|
|
7
|
+
"before `$inline` after",
|
|
8
|
+
"```ts",
|
|
9
|
+
"$fenced",
|
|
10
|
+
"``` still fenced",
|
|
11
|
+
"```",
|
|
12
|
+
"$ordinary",
|
|
13
|
+
String.raw`escaped \`$ordinary`,
|
|
14
|
+
]),
|
|
15
|
+
).toEqual([
|
|
16
|
+
[{ start: 7, end: 16 }],
|
|
17
|
+
[{ start: 0, end: 5 }],
|
|
18
|
+
[{ start: 0, end: 7 }],
|
|
19
|
+
[{ start: 0, end: 16 }],
|
|
20
|
+
[{ start: 0, end: 3 }],
|
|
21
|
+
[],
|
|
22
|
+
[],
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import type { ModalOverlayComponent, ModalOverlayMouseEvent } from "../src/index.ts";
|
|
3
|
+
import { ensureMouseRegistry, getMouseRegistryState } from "../src/mouse/registry.ts";
|
|
4
|
+
import { mountModalOverlay } from "../src/overlay/modal-mount.ts";
|
|
5
|
+
|
|
6
|
+
test("modal mounts route local component input and own registration disposal", () => {
|
|
7
|
+
const registry = ensureMouseRegistry(Object.create(null) as typeof globalThis);
|
|
8
|
+
const pointerEvents: ModalOverlayMouseEvent[] = [];
|
|
9
|
+
const inputs: string[] = [];
|
|
10
|
+
let invalidations = 0;
|
|
11
|
+
let disposals = 0;
|
|
12
|
+
const component: ModalOverlayComponent = {
|
|
13
|
+
focused: false,
|
|
14
|
+
render: (width) => [`width:${width}`],
|
|
15
|
+
invalidate: () => invalidations++,
|
|
16
|
+
handleInput: (data) => inputs.push(data),
|
|
17
|
+
handleMouse: (event) => {
|
|
18
|
+
pointerEvents.push(event);
|
|
19
|
+
return true;
|
|
20
|
+
},
|
|
21
|
+
dispose: () => disposals++,
|
|
22
|
+
};
|
|
23
|
+
const mounted = mountModalOverlay(component, {
|
|
24
|
+
registry,
|
|
25
|
+
id: "test.dialog",
|
|
26
|
+
getRect: () => ({ x: 12, y: 4, width: 20, height: 6 }),
|
|
27
|
+
getShieldRect: () => ({ x: 0, y: 0, width: 80, height: 24 }),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(getMouseRegistryState(registry).regions.map(({ id, priority }) => ({ id, priority }))).toEqual([
|
|
31
|
+
{ id: "test.dialog", priority: 10_000 },
|
|
32
|
+
{ id: "test.dialog.shield", priority: 9_999 },
|
|
33
|
+
]);
|
|
34
|
+
const region = getMouseRegistryState(registry).regions[0]!;
|
|
35
|
+
expect(
|
|
36
|
+
region.onMouse({
|
|
37
|
+
type: "press",
|
|
38
|
+
row: 2,
|
|
39
|
+
col: 3,
|
|
40
|
+
screenRow: 6,
|
|
41
|
+
screenCol: 15,
|
|
42
|
+
button: 0,
|
|
43
|
+
wheel: undefined,
|
|
44
|
+
shift: false,
|
|
45
|
+
alt: false,
|
|
46
|
+
ctrl: false,
|
|
47
|
+
}),
|
|
48
|
+
).toBe(true);
|
|
49
|
+
expect(pointerEvents).toEqual([{ type: "press", row: 2, col: 3, button: 0 }]);
|
|
50
|
+
|
|
51
|
+
mounted.focused = true;
|
|
52
|
+
mounted.handleInput("x");
|
|
53
|
+
expect(component.focused).toBe(true);
|
|
54
|
+
expect(mounted.render(17)).toEqual(["width:17"]);
|
|
55
|
+
mounted.invalidate();
|
|
56
|
+
expect(inputs).toEqual(["x"]);
|
|
57
|
+
expect(invalidations).toBe(1);
|
|
58
|
+
|
|
59
|
+
mounted.dispose();
|
|
60
|
+
mounted.dispose();
|
|
61
|
+
expect(getMouseRegistryState(registry).regions).toEqual([]);
|
|
62
|
+
expect(disposals).toBe(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("modal mounts leave unsupported pointer phases to the shield", () => {
|
|
66
|
+
const registry = ensureMouseRegistry(Object.create(null) as typeof globalThis);
|
|
67
|
+
const component: ModalOverlayComponent = {
|
|
68
|
+
focused: false,
|
|
69
|
+
render: () => [],
|
|
70
|
+
invalidate() {},
|
|
71
|
+
handleInput() {},
|
|
72
|
+
handleMouse: () => true,
|
|
73
|
+
};
|
|
74
|
+
mountModalOverlay(component, {
|
|
75
|
+
registry,
|
|
76
|
+
id: "test.dialog",
|
|
77
|
+
getRect: () => ({ x: 10, y: 5, width: 20, height: 6 }),
|
|
78
|
+
getShieldRect: () => ({ x: 0, y: 0, width: 80, height: 24 }),
|
|
79
|
+
});
|
|
80
|
+
const [region, shield] = getMouseRegistryState(registry).regions;
|
|
81
|
+
const event = {
|
|
82
|
+
row: 1,
|
|
83
|
+
col: 1,
|
|
84
|
+
screenRow: 6,
|
|
85
|
+
screenCol: 11,
|
|
86
|
+
button: 0 as const,
|
|
87
|
+
wheel: undefined,
|
|
88
|
+
shift: false,
|
|
89
|
+
alt: false,
|
|
90
|
+
ctrl: false,
|
|
91
|
+
};
|
|
92
|
+
expect(region!.onMouse({ ...event, type: "drag" })).toBe(false);
|
|
93
|
+
expect(shield!.onMouse({ ...event, type: "drag" })).toBe(true);
|
|
94
|
+
});
|