Open History to see the last commit
1import { describe, expect, it } from "vite-plus/test";
2
3import type { GhosttyCell, GhosttySnapshot } from "./core";
4import { renderGhosttySnapshot } from "./renderer";
5
6const background = { r: 255, g: 255, b: 255 };
7
8const blank: GhosttyCell = {
9 text: "",
10 wide: 0,
11 foreground: { r: 0, g: 0, b: 0 },
12 background,
13 bold: false,
14 italic: false,
15 invisible: false,
16 strikethrough: false,
17 overline: false,
18 underline: false,
19 selected: false,
20};
21
22/** Counts device pixels that are not the terminal background. */
23function pixelsOffBackground(context: CanvasRenderingContext2D): number {
24 const { data } = context.getImageData(0, 0, context.canvas.width, context.canvas.height);
25 let count = 0;
26 for (let index = 0; index < data.length; index += 4) {
27 if (data[index] !== 255 || data[index + 1] !== 255 || data[index + 2] !== 255) count += 1;
28 }
29 return count;
30}
31
32/** Whether the device pixel under a CSS pixel position is closer to black than white. */
33function isDarkAt(context: CanvasRenderingContext2D, x: number, y: number): boolean {
34 const { a, d } = context.getTransform();
35 const { data } = context.getImageData(Math.floor(x * a), Math.floor(y * d), 1, 1);
36 return data[0]! < 128 && data[1]! < 128 && data[2]! < 128;
37}
38
39/** A 40 by 60 CSS pixel canvas drawn at the given device pixel ratio. */
40function scaledContext(scale: number): CanvasRenderingContext2D {
41 const canvas = document.createElement("canvas");
42 canvas.width = Math.round(40 * scale);
43 canvas.height = Math.round(60 * scale);
44 const context = canvas.getContext("2d");
45 if (!context) throw new Error("2D canvas unavailable");
46 context.setTransform(scale, 0, 0, scale, 0, 0);
47 return context;
48}
49
50const layout = {
51 metrics: { width: 7.8, height: 17, baseline: 13 },
52 fontSize: 13,
53 fontFamily: "monospace",
54 padding: 4,
55 originY: 4,
56};
57
58describe.each([1.25, 2.2])("renderGhosttySnapshot at a %sx device pixel ratio", (scale) => {
59 it.each([
60 ["bar", 0],
61 ["block", 1],
62 ["underline", 2],
63 ["hollow block", 3],
64 ])("erases a %s cursor completely when it blinks off", (_name, cursorStyle) => {
65 const context = scaledContext(scale);
66 const snapshot: GhosttySnapshot = {
67 cols: 3,
68 rows: 3,
69 foreground: { r: 0, g: 0, b: 0 },
70 background,
71 cursor: { r: 0, g: 0, b: 0 },
72 cursorX: 2,
73 cursorY: 1,
74 cursorVisible: true,
75 cursorBlinking: true,
76 cursorStyle,
77 dirtyRows: new Set(),
78 rowData: [0, 1, 2].map(() => ({
79 cells: [blank, blank, blank],
80 text: "",
81 isWrapContinuation: false,
82 wrapsToNext: false,
83 })),
84 };
85 const options = { ...layout, context, snapshot };
86
87 renderGhosttySnapshot({ ...options, forceFull: true, cursorOn: true });
88 expect(pixelsOffBackground(context)).toBeGreaterThan(0);
89
90 renderGhosttySnapshot({ ...options, forceFull: false, cursorOn: false, previousCursorY: 1 });
91 expect(pixelsOffBackground(context)).toBe(0);
92 });
93
94 it("erases a Powerline separator completely when only its row repaints", () => {
95 const context = scaledContext(scale);
96 const firstRow = (cell: GhosttyCell): GhosttySnapshot => ({
97 cols: 3,
98 rows: 3,
99 foreground: { r: 0, g: 0, b: 0 },
100 background,
101 cursor: { r: 0, g: 0, b: 0 },
102 cursorX: -1,
103 cursorY: -1,
104 cursorVisible: false,
105 cursorBlinking: false,
106 cursorStyle: 1,
107 dirtyRows: new Set([0]),
108 rowData: [cell, blank, blank].map((middle) => ({
109 cells: [blank, middle, blank],
110 text: "",
111 isWrapContinuation: false,
112 wrapsToNext: false,
113 })),
114 });
115 const options = { ...layout, context, forceFull: false, cursorOn: false };
116
117 renderGhosttySnapshot({ ...options, snapshot: firstRow(blank), forceFull: true });
118 renderGhosttySnapshot({ ...options, snapshot: firstRow({ ...blank, text: "\ue0b0" }) });
119 expect(pixelsOffBackground(context)).toBeGreaterThan(0);
120
121 renderGhosttySnapshot({ ...options, snapshot: firstRow(blank) });
122 expect(pixelsOffBackground(context)).toBe(0);
123 });
124
125 it("keeps concealed text and its decorations hidden, under a block cursor too", () => {
126 const context = scaledContext(scale);
127 const concealed = { ...blank, text: "█", invisible: true, underline: true };
128 const snapshot = (cursorVisible: boolean): GhosttySnapshot => ({
129 cols: 3,
130 rows: 1,
131 foreground: { r: 0, g: 0, b: 0 },
132 background,
133 cursor: { r: 0, g: 0, b: 0 },
134 cursorX: 1,
135 cursorY: 0,
136 cursorVisible,
137 cursorBlinking: false,
138 cursorStyle: 1,
139 dirtyRows: new Set([0]),
140 rowData: [
141 {
142 cells: [blank, concealed, blank],
143 text: "",
144 isWrapContinuation: false,
145 wrapsToNext: false,
146 },
147 ],
148 });
149 const options = { ...layout, context, forceFull: true };
150
151 renderGhosttySnapshot({ ...options, snapshot: snapshot(false), cursorOn: false });
152 expect(pixelsOffBackground(context)).toBe(0);
153
154 renderGhosttySnapshot({ ...options, snapshot: snapshot(true), cursorOn: true });
155 expect(isDarkAt(context, 15.7, 12.5)).toBe(true);
156 });
157
158 // Cell 1 spans x 11.8 to 19.6 and row 0 spans y 4 to 21 in CSS pixels.
159 it.each([
160 ["right", "\ue0b4", { flat: 12.3, round: 19.1, neighbour: 0, outside: 11.3 }],
161 ["left", "\ue0b6", { flat: 19.1, round: 12.3, neighbour: 2, outside: 20.1 }],
162 ])("fills the rounded %s cap to the cell edges, under a block cursor too", (_side, text, x) => {
163 const context = scaledContext(scale);
164 const segment = { ...blank, background: { r: 0, g: 0, b: 0 } };
165 const cells = [blank, { ...blank, text }, blank];
166 cells[x.neighbour] = segment;
167 const snapshot = (cursorVisible: boolean): GhosttySnapshot => ({
168 cols: 3,
169 rows: 1,
170 foreground: { r: 0, g: 0, b: 0 },
171 background,
172 cursor: { r: 0, g: 0, b: 0 },
173 cursorX: 1,
174 cursorY: 0,
175 cursorVisible,
176 cursorBlinking: false,
177 cursorStyle: 1,
178 dirtyRows: new Set([0]),
179 rowData: [{ cells, text: "", isWrapContinuation: false, wrapsToNext: false }],
180 });
181
182 renderGhosttySnapshot({
183 ...layout,
184 context,
185 snapshot: snapshot(false),
186 forceFull: true,
187 cursorOn: false,
188 });
189 expect(isDarkAt(context, x.flat, 4.5)).toBe(true);
190 expect(isDarkAt(context, x.flat, 20.5)).toBe(true);
191 expect(isDarkAt(context, x.round, 12.5)).toBe(true);
192 expect(isDarkAt(context, x.round, 4.5)).toBe(false);
193
194 renderGhosttySnapshot({
195 ...layout,
196 context,
197 snapshot: snapshot(true),
198 forceFull: true,
199 cursorOn: true,
200 });
201 expect(isDarkAt(context, x.outside, 12.5)).toBe(true);
202 expect(isDarkAt(context, x.flat, 12.5)).toBe(false);
203 });
204});