pi-libtui 0.3.2
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 +205 -0
- package/package.json +55 -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,125 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { type TuiBackgroundPaint, type TuiBackgroundToken, tuiTheme } from "./color/theme.ts";
|
|
4
|
+
import { TEXT_INTERACTION_TARGET, type TextInteractionTarget, type TuiMouseEvent } from "./mouse.ts";
|
|
5
|
+
import { getTuiRenderEpoch } from "./render-epoch.ts";
|
|
6
|
+
|
|
7
|
+
export interface BackgroundSurfaceOptions {
|
|
8
|
+
theme: Theme;
|
|
9
|
+
component: Component;
|
|
10
|
+
background?: TuiBackgroundPaint;
|
|
11
|
+
/** Leave shorter content unpainted. Defaults to one rendered row. */
|
|
12
|
+
minimumRows?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type HalfBlockSurfaceEdge = "top" | "bottom";
|
|
16
|
+
|
|
17
|
+
/** Canonical background for multiline tool and foldable content. */
|
|
18
|
+
export const TOOL_SURFACE_BACKGROUND: TuiBackgroundToken = "surface.raised";
|
|
19
|
+
|
|
20
|
+
/** Paint a half-cell cap for a raised or selected surface. */
|
|
21
|
+
export function halfBlockSurfaceEdge(
|
|
22
|
+
theme: Theme,
|
|
23
|
+
background: TuiBackgroundPaint,
|
|
24
|
+
edge: HalfBlockSurfaceEdge,
|
|
25
|
+
width: number,
|
|
26
|
+
): string {
|
|
27
|
+
const boundedWidth = Math.max(0, Math.floor(width));
|
|
28
|
+
const colors = tuiTheme(theme);
|
|
29
|
+
const foreground = typeof background === "string" ? colors.color(background) : background;
|
|
30
|
+
return colors.fg(foreground, (edge === "top" ? "▄" : "▀").repeat(boundedWidth));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Paint a component's rows edge-to-edge without adding a border, padding, or indentation. */
|
|
34
|
+
export class BackgroundSurface implements Component, TextInteractionTarget {
|
|
35
|
+
readonly [TEXT_INTERACTION_TARGET] = true as const;
|
|
36
|
+
private cachedWidth = -1;
|
|
37
|
+
private cachedEpoch = -1;
|
|
38
|
+
private cachedInput: string[] | undefined;
|
|
39
|
+
private cachedOutput: string[] | undefined;
|
|
40
|
+
|
|
41
|
+
constructor(private readonly options: BackgroundSurfaceOptions) {}
|
|
42
|
+
|
|
43
|
+
render(width: number): string[] {
|
|
44
|
+
const boundedWidth = Math.max(0, Math.floor(width));
|
|
45
|
+
if (boundedWidth === 0) return [];
|
|
46
|
+
const epoch = getTuiRenderEpoch();
|
|
47
|
+
const input = this.options.component.render(boundedWidth);
|
|
48
|
+
if (
|
|
49
|
+
this.cachedWidth === boundedWidth &&
|
|
50
|
+
this.cachedEpoch === epoch &&
|
|
51
|
+
this.cachedInput?.length === input.length &&
|
|
52
|
+
this.cachedInput.every((line, index) => line === input[index]) &&
|
|
53
|
+
this.cachedOutput
|
|
54
|
+
)
|
|
55
|
+
return this.cachedOutput;
|
|
56
|
+
const minimumRows = Math.max(1, Math.floor(this.options.minimumRows ?? 1));
|
|
57
|
+
const output =
|
|
58
|
+
input.length < minimumRows
|
|
59
|
+
? input
|
|
60
|
+
: paintRows(
|
|
61
|
+
input,
|
|
62
|
+
boundedWidth,
|
|
63
|
+
tuiTheme(this.options.theme).bgAnsi(this.options.background ?? TOOL_SURFACE_BACKGROUND),
|
|
64
|
+
);
|
|
65
|
+
this.cachedWidth = boundedWidth;
|
|
66
|
+
this.cachedEpoch = epoch;
|
|
67
|
+
this.cachedInput = [...input];
|
|
68
|
+
this.cachedOutput = output;
|
|
69
|
+
return output;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
invalidate(): void {
|
|
73
|
+
this.cachedInput = undefined;
|
|
74
|
+
this.cachedOutput = undefined;
|
|
75
|
+
this.options.component.invalidate();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setViewportFocus(focused: boolean): void {
|
|
79
|
+
this.target().setViewportFocus?.(focused);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
isViewportFocused(): boolean {
|
|
83
|
+
return this.target().isViewportFocused?.() ?? false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
handleViewportInput(data: string): boolean {
|
|
87
|
+
const target = this.target();
|
|
88
|
+
return target.handleViewportInput?.(data) ?? target.handleInput?.(data) ?? false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
handleInput(data: string): void {
|
|
92
|
+
this.target().handleInput?.(data);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
onMouse(event: TuiMouseEvent): boolean {
|
|
96
|
+
return this.target().onMouse?.(event) ?? false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
dispose(): void {
|
|
100
|
+
(this.options.component as Component & { dispose?(): void }).dispose?.();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private target() {
|
|
104
|
+
return this.options.component as Component & {
|
|
105
|
+
setViewportFocus?(focused: boolean): void;
|
|
106
|
+
isViewportFocused?(): boolean;
|
|
107
|
+
handleViewportInput?(data: string): boolean;
|
|
108
|
+
handleInput?(data: string): boolean;
|
|
109
|
+
onMouse?(event: TuiMouseEvent): boolean;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function paintRows(input: readonly string[], width: number, background: string): string[] {
|
|
115
|
+
return input.map((line) => {
|
|
116
|
+
const clipped = truncateToWidth(line, width, "…")
|
|
117
|
+
.replaceAll("\x1b[49m", `\x1b[49m${background}`)
|
|
118
|
+
.replaceAll("\x1b[0m", `\x1b[0m${background}`);
|
|
119
|
+
// Child content may leave a foreground/style SGR active (for example an
|
|
120
|
+
// underline or an external background). Reset before painting padding so
|
|
121
|
+
// that a short row cannot leak that style across the rest of the surface.
|
|
122
|
+
const padded = `${clipped}\x1b[0m${background}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
|
|
123
|
+
return `${background}${padded}\x1b[49m`;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
export interface RgbColor {
|
|
2
|
+
readonly r: number;
|
|
3
|
+
readonly g: number;
|
|
4
|
+
readonly b: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type TuiHue = "red" | "green" | "yellow" | "blue" | "magenta" | "cyan" | "gray";
|
|
8
|
+
export type TuiShade = 0 | 1 | 2 | 3 | 4 | 5;
|
|
9
|
+
|
|
10
|
+
export interface TuiSwatch {
|
|
11
|
+
readonly hue: TuiHue;
|
|
12
|
+
readonly shade: TuiShade;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
declare const COLOR256_INDEX: unique symbol;
|
|
16
|
+
|
|
17
|
+
/** An ANSI 0..255 index produced by one of the validated palette constructors. */
|
|
18
|
+
export type Color256Index = number & { readonly [COLOR256_INDEX]: true };
|
|
19
|
+
|
|
20
|
+
// CIELAB conversion and interpolation are a TypeScript port of the public-domain
|
|
21
|
+
// color256 implementation: https://github.com/jake-stewart/color256
|
|
22
|
+
|
|
23
|
+
type LabColor = readonly [number, number, number];
|
|
24
|
+
|
|
25
|
+
const XTERM_BASE_16 = [
|
|
26
|
+
[0, 0, 0],
|
|
27
|
+
[128, 0, 0],
|
|
28
|
+
[0, 128, 0],
|
|
29
|
+
[128, 128, 0],
|
|
30
|
+
[0, 0, 128],
|
|
31
|
+
[128, 0, 128],
|
|
32
|
+
[0, 128, 128],
|
|
33
|
+
[192, 192, 192],
|
|
34
|
+
[128, 128, 128],
|
|
35
|
+
[255, 0, 0],
|
|
36
|
+
[0, 255, 0],
|
|
37
|
+
[255, 255, 0],
|
|
38
|
+
[0, 0, 255],
|
|
39
|
+
[255, 0, 255],
|
|
40
|
+
[0, 255, 255],
|
|
41
|
+
[255, 255, 255],
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
export function rgb(r: number, g: number, b: number): RgbColor {
|
|
45
|
+
return Object.freeze({ r: channel(r), g: channel(g), b: channel(b) });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Simple YIQ luminance for choosing between light and dark text. */
|
|
49
|
+
export function yiqLuminance(color: RgbColor): number {
|
|
50
|
+
return color.r * 0.299 + color.g * 0.587 + color.b * 0.114;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function xtermColor(index: number): RgbColor {
|
|
54
|
+
const bounded = Math.max(0, Math.min(255, Math.floor(index)));
|
|
55
|
+
if (bounded < 16) {
|
|
56
|
+
const [red, green, blue] = XTERM_BASE_16[bounded]!;
|
|
57
|
+
return rgb(red, green, blue);
|
|
58
|
+
}
|
|
59
|
+
if (bounded >= 232) {
|
|
60
|
+
const value = 8 + (bounded - 232) * 10;
|
|
61
|
+
return rgb(value, value, value);
|
|
62
|
+
}
|
|
63
|
+
const cubeOffset = bounded - 16;
|
|
64
|
+
return rgb(
|
|
65
|
+
xtermChannel(Math.floor(cubeOffset / 36) % 6),
|
|
66
|
+
xtermChannel(Math.floor(cubeOffset / 6) % 6),
|
|
67
|
+
xtermChannel(cubeOffset % 6),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Select a color256 entry using logical red, green, and blue channels from 0 through 5. */
|
|
72
|
+
export function color256Index(red: number, green: number, blue: number): Color256Index {
|
|
73
|
+
validateColor256Level(red);
|
|
74
|
+
validateColor256Level(green);
|
|
75
|
+
validateColor256Level(blue);
|
|
76
|
+
return (16 + red * 36 + green * 6 + blue) as Color256Index;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Select a grayscale entry while preserving logical dark/light ordering. */
|
|
80
|
+
export function gray256Index(value: number): Color256Index {
|
|
81
|
+
if (!Number.isInteger(value) || value < 0 || value > 23) {
|
|
82
|
+
throw new RangeError(`Gray palette channel must be an integer from 0 through 23; received ${value}`);
|
|
83
|
+
}
|
|
84
|
+
return (232 + value) as Color256Index;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Resolve a six-level hue and shade to its corresponding color256 index. */
|
|
88
|
+
export function swatch(hue: TuiHue, shade: TuiShade): Color256Index {
|
|
89
|
+
if (hue === "gray") return gray256Index(Math.round((shade * 23) / 5));
|
|
90
|
+
const channels: Record<Exclude<TuiHue, "gray">, readonly [number, number, number]> = {
|
|
91
|
+
red: [shade, 0, 0],
|
|
92
|
+
green: [0, shade, 0],
|
|
93
|
+
yellow: [shade, shade, 0],
|
|
94
|
+
blue: [0, 0, shade],
|
|
95
|
+
magenta: [shade, 0, shade],
|
|
96
|
+
cyan: [0, shade, shade],
|
|
97
|
+
};
|
|
98
|
+
return color256Index(...channels[hue]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function validateColor256Level(value: number): void {
|
|
102
|
+
if (!Number.isInteger(value) || value < 0 || value > 5) {
|
|
103
|
+
throw new RangeError(`Color cube channel must be an integer from 0 through 5; received ${value}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Generate the extended palette described by jake-stewart/color256. */
|
|
108
|
+
export function generateColor256(
|
|
109
|
+
base16: readonly RgbColor[],
|
|
110
|
+
background: RgbColor,
|
|
111
|
+
foreground: RgbColor,
|
|
112
|
+
): readonly RgbColor[] {
|
|
113
|
+
if (base16.length < 16) throw new RangeError("A base16 palette needs 16 colors");
|
|
114
|
+
const backgroundLab = rgbToLab(background);
|
|
115
|
+
const foregroundLab = rgbToLab(foreground);
|
|
116
|
+
const corners = [
|
|
117
|
+
backgroundLab,
|
|
118
|
+
rgbToLab(base16[1]!),
|
|
119
|
+
rgbToLab(base16[2]!),
|
|
120
|
+
rgbToLab(base16[3]!),
|
|
121
|
+
rgbToLab(base16[4]!),
|
|
122
|
+
rgbToLab(base16[5]!),
|
|
123
|
+
rgbToLab(base16[6]!),
|
|
124
|
+
foregroundLab,
|
|
125
|
+
] as const;
|
|
126
|
+
const palette: RgbColor[] = base16.slice(0, 16).map((color) => rgb(color.r, color.g, color.b));
|
|
127
|
+
|
|
128
|
+
for (let red = 0; red < 6; red += 1) {
|
|
129
|
+
const c0 = lerpLab(red / 5, corners[0], corners[1]);
|
|
130
|
+
const c1 = lerpLab(red / 5, corners[2], corners[3]);
|
|
131
|
+
const c2 = lerpLab(red / 5, corners[4], corners[5]);
|
|
132
|
+
const c3 = lerpLab(red / 5, corners[6], corners[7]);
|
|
133
|
+
for (let green = 0; green < 6; green += 1) {
|
|
134
|
+
const c4 = lerpLab(green / 5, c0, c1);
|
|
135
|
+
const c5 = lerpLab(green / 5, c2, c3);
|
|
136
|
+
for (let blue = 0; blue < 6; blue += 1) {
|
|
137
|
+
palette.push(labToRgb(lerpLab(blue / 5, c4, c5)));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (let shade = 0; shade < 24; shade += 1) {
|
|
143
|
+
palette.push(labToRgb(lerpLab((shade + 1) / 25, corners[0], corners[7])));
|
|
144
|
+
}
|
|
145
|
+
return Object.freeze(palette);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function rgbToLab(color: RgbColor): LabColor {
|
|
149
|
+
const [red, green, blue] = [color.r, color.g, color.b].map((value) => {
|
|
150
|
+
const normalized = value / 255;
|
|
151
|
+
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
|
152
|
+
});
|
|
153
|
+
const x = (red! * 0.4124 + green! * 0.3576 + blue! * 0.1805) / 0.95047;
|
|
154
|
+
const y = red! * 0.2126 + green! * 0.7152 + blue! * 0.0722;
|
|
155
|
+
const z = (red! * 0.0193 + green! * 0.1192 + blue! * 0.9505) / 1.08883;
|
|
156
|
+
const [fx, fy, fz] = [x, y, z].map((value) => (value > 0.008856 ? value ** (1 / 3) : 7.787 * value + 16 / 116));
|
|
157
|
+
return [116 * fy! - 16, 500 * (fx! - fy!), 200 * (fy! - fz!)];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function labToRgb([lightness, a, b]: LabColor): RgbColor {
|
|
161
|
+
const fy = (lightness + 16) / 116;
|
|
162
|
+
const fx = a / 500 + fy;
|
|
163
|
+
const fz = fy - b / 200;
|
|
164
|
+
const [x0, y0, z0] = [fx, fy, fz].map((value) => (value ** 3 > 0.008856 ? value ** 3 : (value - 16 / 116) / 7.787));
|
|
165
|
+
const x = x0! * 0.95047;
|
|
166
|
+
const y = y0!;
|
|
167
|
+
const z = z0! * 1.08883;
|
|
168
|
+
const values = [
|
|
169
|
+
x * 3.2406 + y * -1.5372 + z * -0.4986,
|
|
170
|
+
x * -0.9689 + y * 1.8758 + z * 0.0415,
|
|
171
|
+
x * 0.0557 + y * -0.204 + z * 1.057,
|
|
172
|
+
].map((value) => (value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055));
|
|
173
|
+
return rgb(values[0]! * 255, values[1]! * 255, values[2]! * 255);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function lerpLab(t: number, left: LabColor, right: LabColor): LabColor {
|
|
177
|
+
return [left[0] + t * (right[0] - left[0]), left[1] + t * (right[1] - left[1]), left[2] + t * (right[2] - left[2])];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function channel(value: number): number {
|
|
181
|
+
return Math.max(0, Math.min(255, Math.round(value)));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function xtermChannel(value: number): number {
|
|
185
|
+
return value === 0 ? 0 : 55 + value * 40;
|
|
186
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Component, type Focusable, matchesKey } from "@earendil-works/pi-tui";
|
|
3
|
+
import { color256Index } from "./palette.ts";
|
|
4
|
+
|
|
5
|
+
type ChannelMask = readonly [red: boolean, green: boolean, blue: boolean];
|
|
6
|
+
|
|
7
|
+
const HUE_EDGES: readonly ChannelMask[] = [
|
|
8
|
+
[true, false, false],
|
|
9
|
+
[true, true, false],
|
|
10
|
+
[false, true, false],
|
|
11
|
+
[false, true, true],
|
|
12
|
+
[false, false, true],
|
|
13
|
+
[true, false, true],
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/** Width of the upstream color256 terminal preview, including its base16 legend. */
|
|
17
|
+
export const COLOR256_PREVIEW_WIDTH = 69;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Render the active terminal's 256-color palette in color256's perceptual grid.
|
|
21
|
+
* Layout ported from the public-domain `preview_theme` implementation:
|
|
22
|
+
* https://github.com/jake-stewart/color256/blob/master/color256.py
|
|
23
|
+
*/
|
|
24
|
+
export function renderColor256Preview(name: string): string[] {
|
|
25
|
+
const grays = grayCells(24, 6);
|
|
26
|
+
const colors = [
|
|
27
|
+
...colorSlices({ reverse: true, includeFinal: true, background: true }),
|
|
28
|
+
...colorSlices({ reverse: false, includeFinal: false, background: false }),
|
|
29
|
+
];
|
|
30
|
+
const body = grays.map((gray, row) => `${gray}${colors[row] ?? ""}${gray}`);
|
|
31
|
+
const title = `${grayCells(5, 0).join("")}${center(name.slice(0, 24), 24)}${grayCells(0, 5).join("")}`;
|
|
32
|
+
const grid = [...body, title];
|
|
33
|
+
const legend = base16Legend();
|
|
34
|
+
return grid.map((line, row) => `${line} ${legend[row] ?? " "}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Focusable screen used by the `/libtui:colors` diagnostic command. */
|
|
38
|
+
export class Color256Preview implements Component, Focusable {
|
|
39
|
+
focused = false;
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
private readonly theme: Theme,
|
|
43
|
+
private readonly close: () => void,
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
render(): string[] {
|
|
47
|
+
return renderColor256Preview(this.theme.name ?? "Active Theme");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
handleInput(data: string): void {
|
|
51
|
+
if (data === "q" || matchesKey(data, "escape")) this.close();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
invalidate(): void {}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface SliceOptions {
|
|
58
|
+
reverse: boolean;
|
|
59
|
+
includeFinal: boolean;
|
|
60
|
+
background: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function colorSlices(options: SliceOptions): string[] {
|
|
64
|
+
const columns: string[][] = [];
|
|
65
|
+
for (let edge = 0; edge < HUE_EDGES.length; edge += 1) {
|
|
66
|
+
const current = HUE_EDGES[edge]!;
|
|
67
|
+
const next = HUE_EDGES[(edge + 1) % HUE_EDGES.length]!;
|
|
68
|
+
for (let step = 0; step < 3; step += 1) {
|
|
69
|
+
columns.push(
|
|
70
|
+
colorSlice(
|
|
71
|
+
[...Array.from({ length: 3 - step }, () => current), ...Array.from({ length: step }, () => next)],
|
|
72
|
+
options,
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const rows = Array.from({ length: columns[0]?.length ?? 0 }, () => "");
|
|
78
|
+
for (const column of columns) {
|
|
79
|
+
for (let row = 0; row < rows.length; row += 1) rows[row] += column[row] ?? " ";
|
|
80
|
+
}
|
|
81
|
+
return rows;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function colorSlice(masks: readonly ChannelMask[], options: SliceOptions): string[] {
|
|
85
|
+
const cells: string[] = [];
|
|
86
|
+
for (let level = 1; level < 6; level += 1) {
|
|
87
|
+
const [red, green, blue] = averageChannels(masks, level, false);
|
|
88
|
+
const brightness = Math.floor(((red + green + blue) / 15) * 16).toString(16);
|
|
89
|
+
cells.push(indexedCell(color256Index(red, green, blue), ` ${brightness} `, options.background));
|
|
90
|
+
}
|
|
91
|
+
for (let level = 1; level < (options.includeFinal ? 6 : 5); level += 1) {
|
|
92
|
+
const [red, green, blue] = averageChannels(masks, level, true);
|
|
93
|
+
const brightness = Math.floor(((red + green + blue) / 16) * 16).toString(16);
|
|
94
|
+
cells.push(indexedCell(color256Index(red, green, blue), ` ${brightness} `, options.background));
|
|
95
|
+
}
|
|
96
|
+
if (options.reverse) cells.reverse();
|
|
97
|
+
return cells;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function averageChannels(masks: readonly ChannelMask[], level: number, inverted: boolean): [number, number, number] {
|
|
101
|
+
const channels = [0, 0, 0];
|
|
102
|
+
for (const mask of masks) {
|
|
103
|
+
for (let channel = 0; channel < 3; channel += 1) {
|
|
104
|
+
channels[channel] += mask[channel] ? (inverted ? 5 : level) : inverted ? level : 0;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return channels.map((value) => Math.floor(value / masks.length)) as [number, number, number];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function grayCells(from: number, to: number): string[] {
|
|
111
|
+
const direction = from <= to ? 1 : -1;
|
|
112
|
+
const cells: string[] = [];
|
|
113
|
+
for (let shade = from; shade !== to + direction; shade += direction) {
|
|
114
|
+
const index = shade === 24 ? 231 : 232 + shade;
|
|
115
|
+
const label = Math.min(15, Math.floor((shade / 24) * 16)).toString(16);
|
|
116
|
+
cells.push(indexedCell(index, ` ${label} `, true));
|
|
117
|
+
}
|
|
118
|
+
return cells;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function base16Legend(): string[] {
|
|
122
|
+
return [
|
|
123
|
+
" ",
|
|
124
|
+
...Array.from(
|
|
125
|
+
{ length: 8 },
|
|
126
|
+
(_, index) =>
|
|
127
|
+
`${indexedCell(index, ` ${index.toString(16)} `, true)}${indexedCell(index + 8, ` ${(index + 8).toString(16)} `, true)}`,
|
|
128
|
+
),
|
|
129
|
+
" ",
|
|
130
|
+
" ",
|
|
131
|
+
...Array.from(
|
|
132
|
+
{ length: 8 },
|
|
133
|
+
(_, index) =>
|
|
134
|
+
`${indexedCell(index, ` ${index.toString(16)} `, false)}${indexedCell(index + 8, ` ${(index + 8).toString(16)} `, false)}`,
|
|
135
|
+
),
|
|
136
|
+
" ",
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function center(text: string, width: number): string {
|
|
141
|
+
const left = Math.floor((width - text.length) / 2);
|
|
142
|
+
return `${" ".repeat(left)}${text}${" ".repeat(width - left - text.length)}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function indexedCell(index: number, text: string, background: boolean): string {
|
|
146
|
+
if (!background) return `\x1b[38;5;${index}m${text}\x1b[0m`;
|
|
147
|
+
return isDark(index) ? `\x1b[48;5;${index}m${text}\x1b[0m` : `\x1b[38;5;${index};7m${text}\x1b[0m`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isDark(index: number): boolean {
|
|
151
|
+
if (index >= 16 && index <= 231) {
|
|
152
|
+
const cube = index - 16;
|
|
153
|
+
return Math.floor(cube / 36) < 4 && Math.floor(cube / 6) % 6 < 4 && cube % 6 < 4;
|
|
154
|
+
}
|
|
155
|
+
if (index >= 232) return index - 232 < 11;
|
|
156
|
+
return index % 8 === 0;
|
|
157
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type Color256Index, color256Index, type RgbColor, xtermColor } from "./palette.ts";
|
|
2
|
+
|
|
3
|
+
/** A resolved palette entry or an exact color measured at a terminal boundary. */
|
|
4
|
+
export type ColorValue = Color256Index | RgbColor;
|
|
5
|
+
|
|
6
|
+
const closestXtermCache = new Map<string, number>();
|
|
7
|
+
|
|
8
|
+
/** Render and measure one direct value through the active terminal policy. */
|
|
9
|
+
export function createColorResolver(context: {
|
|
10
|
+
readonly resolvedPalette?: readonly RgbColor[];
|
|
11
|
+
readonly output: "direct-indexed" | "quantized-indexed" | "truecolor";
|
|
12
|
+
}) {
|
|
13
|
+
const dark = color256Index(0, 0, 0);
|
|
14
|
+
const light = color256Index(5, 5, 5);
|
|
15
|
+
const rgb = (value: ColorValue): RgbColor => {
|
|
16
|
+
return typeof value === "number" ? (context.resolvedPalette?.[value] ?? xtermColor(value)) : value;
|
|
17
|
+
};
|
|
18
|
+
const ansi = (value: ColorValue, background: boolean): string => {
|
|
19
|
+
const prefix = background ? 48 : 38;
|
|
20
|
+
if (context.output !== "truecolor") {
|
|
21
|
+
const index =
|
|
22
|
+
typeof value === "number" && context.output === "direct-indexed" ? value : closestXtermIndex(rgb(value));
|
|
23
|
+
return `\x1b[${prefix};5;${index}m`;
|
|
24
|
+
}
|
|
25
|
+
const color = rgb(value);
|
|
26
|
+
return `\x1b[${prefix};2;${color.r};${color.g};${color.b}m`;
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
rgb,
|
|
30
|
+
ansi,
|
|
31
|
+
contrast(value: ColorValue) {
|
|
32
|
+
const background = rgb(value);
|
|
33
|
+
return contrastRatio(background, rgb(dark)) >= contrastRatio(background, rgb(light)) ? dark : light;
|
|
34
|
+
},
|
|
35
|
+
contrastRatio(left: ColorValue, right: ColorValue) {
|
|
36
|
+
return contrastRatio(rgb(left), rgb(right));
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function contrastRatio(left: RgbColor, right: RgbColor): number {
|
|
42
|
+
const lighter = Math.max(relativeLuminance(left), relativeLuminance(right));
|
|
43
|
+
const darker = Math.min(relativeLuminance(left), relativeLuminance(right));
|
|
44
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function relativeLuminance(color: RgbColor): number {
|
|
48
|
+
const channel = (value: number): number => {
|
|
49
|
+
const normalized = value / 255;
|
|
50
|
+
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
|
51
|
+
};
|
|
52
|
+
return channel(color.r) * 0.2126 + channel(color.g) * 0.7152 + channel(color.b) * 0.0722;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function closestXtermIndex(color: RgbColor): number {
|
|
56
|
+
const key = `${color.r},${color.g},${color.b}`;
|
|
57
|
+
const cached = closestXtermCache.get(key);
|
|
58
|
+
if (cached !== undefined) return cached;
|
|
59
|
+
let best = 0;
|
|
60
|
+
let distance = Number.POSITIVE_INFINITY;
|
|
61
|
+
for (let index = 0; index < 256; index += 1) {
|
|
62
|
+
const candidate = xtermColor(index);
|
|
63
|
+
const next =
|
|
64
|
+
(color.r - candidate.r) ** 2 * 0.299 +
|
|
65
|
+
(color.g - candidate.g) ** 2 * 0.587 +
|
|
66
|
+
(color.b - candidate.b) ** 2 * 0.114;
|
|
67
|
+
if (next < distance) {
|
|
68
|
+
best = index;
|
|
69
|
+
distance = next;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (closestXtermCache.size >= 4096) closestXtermCache.clear();
|
|
73
|
+
closestXtermCache.set(key, best);
|
|
74
|
+
return best;
|
|
75
|
+
}
|