@aurigax-ai/ghostty-web 0.2.0 → 0.3.0
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/README.md +2 -1
- package/lib/addons/search.ts +190 -0
- package/lib/ghostty.ts +224 -6
- package/lib/glyph-ink.ts +42 -0
- package/lib/index.ts +6 -0
- package/lib/input-handler.ts +42 -1
- package/lib/interfaces.ts +23 -0
- package/lib/providers/osc8-link-provider.ts +4 -10
- package/lib/providers/url-regex-provider.ts +10 -13
- package/lib/providers/web-link.ts +35 -0
- package/lib/renderer.ts +266 -33
- package/lib/terminal.ts +150 -58
- package/lib/types.ts +2 -1
- package/lib/webgl-instances.ts +69 -0
- package/lib/webgl-renderer.ts +176 -103
- package/package.json +1 -1
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* so we just need to scan for contiguous regions with the same ID.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import type { ILinkHandler } from '../interfaces';
|
|
13
14
|
import type { IBufferRange, ILink, ILinkProvider } from '../types';
|
|
15
|
+
import { webLink } from './web-link';
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* OSC 8 Hyperlink Provider
|
|
@@ -96,16 +98,7 @@ export class OSC8LinkProvider implements ILinkProvider {
|
|
|
96
98
|
end: { x: endX, y },
|
|
97
99
|
};
|
|
98
100
|
|
|
99
|
-
links.push(
|
|
100
|
-
text: uri,
|
|
101
|
-
range,
|
|
102
|
-
activate: (event) => {
|
|
103
|
-
// Open link if Ctrl/Cmd is pressed
|
|
104
|
-
if (event.ctrlKey || event.metaKey) {
|
|
105
|
-
window.open(uri, '_blank', 'noopener,noreferrer');
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
});
|
|
101
|
+
links.push(webLink(uri, range, () => this.terminal.options?.linkHandler ?? undefined));
|
|
109
102
|
}
|
|
110
103
|
}
|
|
111
104
|
|
|
@@ -121,6 +114,7 @@ export class OSC8LinkProvider implements ILinkProvider {
|
|
|
121
114
|
* Minimal terminal interface required by OSC8LinkProvider
|
|
122
115
|
*/
|
|
123
116
|
export interface ITerminalForOSC8Provider {
|
|
117
|
+
options?: { linkHandler?: ILinkHandler | null };
|
|
124
118
|
buffer: {
|
|
125
119
|
active: {
|
|
126
120
|
length: number;
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* take precedence over regex-detected URLs.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import type { ILinkHandler } from '../interfaces';
|
|
11
12
|
import type { ILink, ILinkProvider } from '../types';
|
|
13
|
+
import { webLink } from './web-link';
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* URL Regex Provider
|
|
@@ -74,19 +76,13 @@ export class UrlRegexProvider implements ILinkProvider {
|
|
|
74
76
|
|
|
75
77
|
// Skip if URL is too short (e.g., just "http://")
|
|
76
78
|
if (url.length > 8) {
|
|
77
|
-
links.push(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
start: { x: startX, y },
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// Open link if Ctrl/Cmd is pressed
|
|
85
|
-
if (event.ctrlKey || event.metaKey) {
|
|
86
|
-
window.open(url, '_blank', 'noopener,noreferrer');
|
|
87
|
-
}
|
|
88
|
-
},
|
|
89
|
-
});
|
|
79
|
+
links.push(
|
|
80
|
+
webLink(
|
|
81
|
+
url,
|
|
82
|
+
{ start: { x: startX, y }, end: { x: endX, y } },
|
|
83
|
+
() => this.terminal.options?.linkHandler ?? undefined
|
|
84
|
+
)
|
|
85
|
+
);
|
|
90
86
|
}
|
|
91
87
|
|
|
92
88
|
// Get next match
|
|
@@ -130,6 +126,7 @@ export class UrlRegexProvider implements ILinkProvider {
|
|
|
130
126
|
* Minimal terminal interface required by UrlRegexProvider
|
|
131
127
|
*/
|
|
132
128
|
export interface ITerminalForUrlProvider {
|
|
129
|
+
options?: { linkHandler?: ILinkHandler | null };
|
|
133
130
|
buffer: {
|
|
134
131
|
active: {
|
|
135
132
|
getLine(y: number): IBufferLineForUrlProvider | undefined;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ILinkHandler } from '../interfaces';
|
|
2
|
+
import type { IBufferRange, ILink } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A link to a URI. With a host link handler the host decides what a click
|
|
6
|
+
* does (and shows its own hover hint); without one, Ctrl/Cmd+click opens the
|
|
7
|
+
* URI in a new window.
|
|
8
|
+
*/
|
|
9
|
+
export function webLink(
|
|
10
|
+
uri: string,
|
|
11
|
+
range: IBufferRange,
|
|
12
|
+
handler: () => ILinkHandler | undefined
|
|
13
|
+
): ILink {
|
|
14
|
+
return {
|
|
15
|
+
text: uri,
|
|
16
|
+
range,
|
|
17
|
+
activate: (event) => {
|
|
18
|
+
const host = handler();
|
|
19
|
+
if (host) {
|
|
20
|
+
host.activate(event, uri, range);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (event.ctrlKey || event.metaKey) {
|
|
24
|
+
window.open(uri, '_blank', 'noopener,noreferrer');
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
hover: (hovered) => {
|
|
28
|
+
const host = handler();
|
|
29
|
+
if (!host) return;
|
|
30
|
+
const event = new MouseEvent(hovered ? 'mouseover' : 'mouseout');
|
|
31
|
+
if (hovered) host.hover?.(event, uri, range);
|
|
32
|
+
else host.leave?.(event, uri, range);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
package/lib/renderer.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { drawBoxGlyph, isBoxGlyph } from './box-glyphs';
|
|
12
|
-
import type { ITheme } from './interfaces';
|
|
12
|
+
import type { FontWeight, ITheme } from './interfaces';
|
|
13
13
|
import type { SelectionManager } from './selection-manager';
|
|
14
14
|
import type { GhosttyCell } from './types';
|
|
15
15
|
import { CellFlags } from './types';
|
|
@@ -58,6 +58,16 @@ export interface RendererOptions {
|
|
|
58
58
|
cursorBlink?: boolean; // Default: false
|
|
59
59
|
theme?: ITheme;
|
|
60
60
|
devicePixelRatio?: number; // Default: window.devicePixelRatio
|
|
61
|
+
fontWeight?: FontWeight; // Default: 'normal'
|
|
62
|
+
fontWeightBold?: FontWeight; // Default: 'bold'
|
|
63
|
+
lineHeight?: number; // Default: 1
|
|
64
|
+
minimumContrastRatio?: number; // Default: 1 (off)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface FontOptions {
|
|
68
|
+
fontWeight: FontWeight;
|
|
69
|
+
fontWeightBold: FontWeight;
|
|
70
|
+
lineHeight: number;
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
export interface FontMetrics {
|
|
@@ -99,6 +109,19 @@ export const DEFAULT_THEME: Required<ITheme> = {
|
|
|
99
109
|
|
|
100
110
|
export type CursorStyle = 'block' | 'underline' | 'bar';
|
|
101
111
|
|
|
112
|
+
/** A search match drawn on one viewport row, columns inclusive. */
|
|
113
|
+
export interface SearchHighlight {
|
|
114
|
+
row: number;
|
|
115
|
+
start: number;
|
|
116
|
+
end: number;
|
|
117
|
+
active: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface SearchHighlightColors {
|
|
121
|
+
match: string;
|
|
122
|
+
active: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
102
125
|
export interface LinkRange {
|
|
103
126
|
startX: number;
|
|
104
127
|
startY: number;
|
|
@@ -112,6 +135,8 @@ export interface FrameOverlay {
|
|
|
112
135
|
cursorY: number;
|
|
113
136
|
/** Cursor shown: at the bottom of the scrollback, visible, and in the blink's on phase. */
|
|
114
137
|
showCursor: boolean;
|
|
138
|
+
/** The terminal has focus; an unfocused cursor is drawn as an outline and never blinks, as in xterm.js. */
|
|
139
|
+
focused: boolean;
|
|
115
140
|
viewportY: number;
|
|
116
141
|
scrollbackLength: number;
|
|
117
142
|
cols: number;
|
|
@@ -121,19 +146,76 @@ export interface FrameOverlay {
|
|
|
121
146
|
}
|
|
122
147
|
|
|
123
148
|
/** Measures the cell box of a font: width of 'M' and height from its ascent and descent. */
|
|
124
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Measures the cell box the way xterm.js's WebGL renderer does: the advance of
|
|
151
|
+
* 'W' floored to device pixels, and the font's ascent plus descent (its line
|
|
152
|
+
* box) times lineHeight, with the text centered in the extra space.
|
|
153
|
+
*/
|
|
154
|
+
export function measureCell(
|
|
155
|
+
fontSize: number,
|
|
156
|
+
fontFamily: string,
|
|
157
|
+
fontWeight: FontWeight = 'normal',
|
|
158
|
+
lineHeight = 1,
|
|
159
|
+
dpr = 1
|
|
160
|
+
): FontMetrics {
|
|
125
161
|
const canvas = document.createElement('canvas');
|
|
126
162
|
const ctx = canvas.getContext('2d')!;
|
|
127
|
-
ctx.font = `${fontSize}px ${fontFamily}`;
|
|
128
|
-
const
|
|
129
|
-
const width = Math.
|
|
130
|
-
const ascent =
|
|
131
|
-
const descent =
|
|
132
|
-
const
|
|
133
|
-
const
|
|
163
|
+
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
164
|
+
const m = ctx.measureText('W');
|
|
165
|
+
const width = Math.max(1, Math.floor(m.width * dpr)) / dpr;
|
|
166
|
+
const ascent = m.fontBoundingBoxAscent || m.actualBoundingBoxAscent || fontSize * 0.8;
|
|
167
|
+
const descent = m.fontBoundingBoxDescent || m.actualBoundingBoxDescent || fontSize * 0.2;
|
|
168
|
+
const charHeight = Math.ceil((ascent + descent) * dpr) / dpr;
|
|
169
|
+
const height = Math.max(charHeight, Math.floor(charHeight * lineHeight * dpr) / dpr);
|
|
170
|
+
const baseline = Math.round(((height - charHeight) / 2 + ascent) * dpr) / dpr;
|
|
134
171
|
return { width, height, baseline };
|
|
135
172
|
}
|
|
136
173
|
|
|
174
|
+
/** WCAG relative luminance of an sRGB color. */
|
|
175
|
+
function luminance(r: number, g: number, b: number): number {
|
|
176
|
+
const channel = (c: number) => {
|
|
177
|
+
const v = c / 255;
|
|
178
|
+
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
179
|
+
};
|
|
180
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function contrast(a: number, b: number): number {
|
|
184
|
+
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The foreground moved toward black or white, whichever reaches `ratio`
|
|
189
|
+
* against the background first (black first on a light background), in 10%
|
|
190
|
+
* steps; the original when it already reaches it.
|
|
191
|
+
*/
|
|
192
|
+
export function ensureContrast(
|
|
193
|
+
fg: [number, number, number],
|
|
194
|
+
bg: [number, number, number],
|
|
195
|
+
ratio: number
|
|
196
|
+
): [number, number, number] {
|
|
197
|
+
const bgLum = luminance(bg[0], bg[1], bg[2]);
|
|
198
|
+
if (contrast(luminance(fg[0], fg[1], fg[2]), bgLum) >= ratio) return fg;
|
|
199
|
+
const toward = (target: number): [number, number, number] | null => {
|
|
200
|
+
for (let step = 1; step <= 10; step++) {
|
|
201
|
+
const t = step / 10;
|
|
202
|
+
const c: [number, number, number] = [
|
|
203
|
+
Math.round(fg[0] + (target - fg[0]) * t),
|
|
204
|
+
Math.round(fg[1] + (target - fg[1]) * t),
|
|
205
|
+
Math.round(fg[2] + (target - fg[2]) * t),
|
|
206
|
+
];
|
|
207
|
+
if (contrast(luminance(c[0], c[1], c[2]), bgLum) >= ratio) return c;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
};
|
|
211
|
+
const darkFirst = bgLum > 0.5;
|
|
212
|
+
return (
|
|
213
|
+
toward(darkFirst ? 0 : 255) ??
|
|
214
|
+
toward(darkFirst ? 255 : 0) ??
|
|
215
|
+
(darkFirst ? [0, 0, 0] : [255, 255, 255])
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
137
219
|
// ============================================================================
|
|
138
220
|
// TerminalRenderer: what to draw each frame
|
|
139
221
|
// ============================================================================
|
|
@@ -147,8 +229,17 @@ export abstract class TerminalRenderer {
|
|
|
147
229
|
protected theme: Required<ITheme>;
|
|
148
230
|
protected devicePixelRatio: number;
|
|
149
231
|
protected metrics: FontMetrics;
|
|
232
|
+
protected fontWeight: FontWeight;
|
|
233
|
+
protected fontWeightBold: FontWeight;
|
|
234
|
+
protected lineHeight: number;
|
|
235
|
+
protected minimumContrastRatio: number;
|
|
236
|
+
private readonly contrastCache = new Map<number, number>();
|
|
237
|
+
private themeBackgroundRgb: [number, number, number] = [0, 0, 0];
|
|
150
238
|
|
|
151
239
|
protected cursorVisible = true;
|
|
240
|
+
private lastCursorVisible = true;
|
|
241
|
+
protected focused = false;
|
|
242
|
+
private lastFocused = false;
|
|
152
243
|
private cursorBlinkInterval?: number;
|
|
153
244
|
|
|
154
245
|
/** Called when the renderer needs another frame (cursor blink), so an idle render loop wakes. */
|
|
@@ -169,6 +260,10 @@ export abstract class TerminalRenderer {
|
|
|
169
260
|
endRow: number;
|
|
170
261
|
} | null = null;
|
|
171
262
|
|
|
263
|
+
private highlights = new Map<number, SearchHighlight[]>();
|
|
264
|
+
private readonly highlightRows = new Set<number>();
|
|
265
|
+
protected highlightColors: SearchHighlightColors = { match: '#5c6266', active: '#538bb5' };
|
|
266
|
+
|
|
172
267
|
protected hoveredHyperlinkId = 0;
|
|
173
268
|
private previousHoveredHyperlinkId = 0;
|
|
174
269
|
protected hoveredLinkRange: LinkRange | null = null;
|
|
@@ -185,8 +280,13 @@ export abstract class TerminalRenderer {
|
|
|
185
280
|
this.cursorBlink = options.cursorBlink ?? false;
|
|
186
281
|
this.theme = { ...DEFAULT_THEME, ...options.theme };
|
|
187
282
|
this.devicePixelRatio = options.devicePixelRatio ?? window.devicePixelRatio ?? 1;
|
|
188
|
-
this.
|
|
189
|
-
|
|
283
|
+
this.fontWeight = options.fontWeight ?? 'normal';
|
|
284
|
+
this.fontWeightBold = options.fontWeightBold ?? 'bold';
|
|
285
|
+
this.lineHeight = options.lineHeight ?? 1;
|
|
286
|
+
this.minimumContrastRatio = options.minimumContrastRatio ?? 1;
|
|
287
|
+
this.themeBackgroundRgb = cssRgb(this.theme.background);
|
|
288
|
+
this.metrics = this.measure();
|
|
289
|
+
if (this.cursorBlink && this.focused) this.startCursorBlink();
|
|
190
290
|
}
|
|
191
291
|
|
|
192
292
|
/** Draws one viewport row. */
|
|
@@ -230,7 +330,11 @@ export abstract class TerminalRenderer {
|
|
|
230
330
|
|
|
231
331
|
const cursorMoved =
|
|
232
332
|
cursor.x !== this.lastCursorPosition.x || cursor.y !== this.lastCursorPosition.y;
|
|
233
|
-
|
|
333
|
+
const blinked =
|
|
334
|
+
this.cursorVisible !== this.lastCursorVisible || this.focused !== this.lastFocused;
|
|
335
|
+
this.lastCursorVisible = this.cursorVisible;
|
|
336
|
+
this.lastFocused = this.focused;
|
|
337
|
+
if (this.cursorInRows && (cursorMoved || blinked)) {
|
|
234
338
|
if (!forceAll && !buffer.isRowDirty(cursor.y)) {
|
|
235
339
|
const line = buffer.getLine(cursor.y);
|
|
236
340
|
if (line) this.drawLine(line, cursor.y, dims.cols);
|
|
@@ -250,6 +354,8 @@ export abstract class TerminalRenderer {
|
|
|
250
354
|
const coords = this.currentSelectionCoords;
|
|
251
355
|
for (let row = coords.startRow; row <= coords.endRow; row++) selectionRows.add(row);
|
|
252
356
|
}
|
|
357
|
+
for (const row of this.highlightRows) selectionRows.add(row);
|
|
358
|
+
this.highlightRows.clear();
|
|
253
359
|
if (this.selectionManager) {
|
|
254
360
|
const dirtyRows = this.selectionManager.getDirtySelectionRows();
|
|
255
361
|
if (dirtyRows.size > 0) {
|
|
@@ -317,6 +423,7 @@ export abstract class TerminalRenderer {
|
|
|
317
423
|
cursorX: cursor.x,
|
|
318
424
|
cursorY: cursor.y,
|
|
319
425
|
showCursor: viewportY === 0 && cursor.visible && this.cursorVisible,
|
|
426
|
+
focused: this.focused,
|
|
320
427
|
viewportY,
|
|
321
428
|
scrollbackLength,
|
|
322
429
|
cols: dims.cols,
|
|
@@ -354,6 +461,38 @@ export abstract class TerminalRenderer {
|
|
|
354
461
|
return y > startRow && y < endRow;
|
|
355
462
|
}
|
|
356
463
|
|
|
464
|
+
/** Search highlight under a cell: 0 none, 1 a match, 2 the selected match. */
|
|
465
|
+
protected highlightAt(x: number, y: number): 0 | 1 | 2 {
|
|
466
|
+
const ranges = this.highlights.get(y);
|
|
467
|
+
if (!ranges) return 0;
|
|
468
|
+
for (const range of ranges) {
|
|
469
|
+
if (x >= range.start && x <= range.end) return range.active ? 2 : 1;
|
|
470
|
+
}
|
|
471
|
+
return 0;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Shows search matches (null clears them); only rows whose highlights changed are redrawn. */
|
|
475
|
+
public setSearchHighlights(
|
|
476
|
+
highlights: SearchHighlight[] | null,
|
|
477
|
+
colors?: SearchHighlightColors
|
|
478
|
+
): void {
|
|
479
|
+
for (const row of this.highlights.keys()) this.highlightRows.add(row);
|
|
480
|
+
this.highlights = new Map();
|
|
481
|
+
for (const highlight of highlights ?? []) {
|
|
482
|
+
const row = this.highlights.get(highlight.row);
|
|
483
|
+
if (row) row.push(highlight);
|
|
484
|
+
else this.highlights.set(highlight.row, [highlight]);
|
|
485
|
+
this.highlightRows.add(highlight.row);
|
|
486
|
+
}
|
|
487
|
+
if (colors) {
|
|
488
|
+
this.highlightColors = colors;
|
|
489
|
+
this.searchColorsChanged();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Called when the search highlight colors change. */
|
|
494
|
+
protected searchColorsChanged(): void {}
|
|
495
|
+
|
|
357
496
|
/** Whether the cell has a hovered link under it (OSC 8 id or a detected URL range). */
|
|
358
497
|
protected linkHovered(cell: GhosttyCell, x: number, y: number): boolean {
|
|
359
498
|
if (cell.hyperlink_id > 0 && cell.hyperlink_id === this.hoveredHyperlinkId) return true;
|
|
@@ -411,13 +550,68 @@ export abstract class TerminalRenderer {
|
|
|
411
550
|
// Public API
|
|
412
551
|
// ==========================================================================
|
|
413
552
|
|
|
553
|
+
private measure(): FontMetrics {
|
|
554
|
+
return measureCell(
|
|
555
|
+
this.fontSize,
|
|
556
|
+
this.fontFamily,
|
|
557
|
+
this.fontWeight,
|
|
558
|
+
this.lineHeight,
|
|
559
|
+
this.devicePixelRatio
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
414
563
|
public remeasureFont(): void {
|
|
415
|
-
this.metrics =
|
|
564
|
+
this.metrics = this.measure();
|
|
416
565
|
this.fontChanged();
|
|
417
566
|
}
|
|
418
567
|
|
|
568
|
+
public setFontOptions(options: FontOptions): void {
|
|
569
|
+
this.fontWeight = options.fontWeight;
|
|
570
|
+
this.fontWeightBold = options.fontWeightBold;
|
|
571
|
+
this.lineHeight = options.lineHeight;
|
|
572
|
+
this.remeasureFont();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
public setMinimumContrastRatio(ratio: number): void {
|
|
576
|
+
this.minimumContrastRatio = ratio;
|
|
577
|
+
this.contrastCache.clear();
|
|
578
|
+
}
|
|
579
|
+
|
|
419
580
|
public setTheme(theme: ITheme): void {
|
|
420
581
|
this.theme = { ...DEFAULT_THEME, ...theme };
|
|
582
|
+
this.requestFullRedraw();
|
|
583
|
+
this.themeBackgroundRgb = cssRgb(this.theme.background);
|
|
584
|
+
this.contrastCache.clear();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** The CSS font for a cell style (CellFlags.BOLD / ITALIC) at `size` px. */
|
|
588
|
+
protected fontString(style: number, size: number): string {
|
|
589
|
+
const italic = style & CellFlags.ITALIC ? 'italic ' : '';
|
|
590
|
+
const weight = style & CellFlags.BOLD ? this.fontWeightBold : this.fontWeight;
|
|
591
|
+
return `${italic}${weight} ${size}px ${this.fontFamily}`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* The foreground (packed 0xRRGGBB) a cell is drawn with once
|
|
596
|
+
* minimumContrastRatio is applied against its background; `bg` is null for
|
|
597
|
+
* the theme background.
|
|
598
|
+
*/
|
|
599
|
+
protected contrastedForeground(fg: number, bg: number | null): number {
|
|
600
|
+
if (this.minimumContrastRatio <= 1) return fg;
|
|
601
|
+
const back = bg ?? rgbWord(this.themeBackgroundRgb);
|
|
602
|
+
const key = fg * 0x1000000 + back;
|
|
603
|
+
let result = this.contrastCache.get(key);
|
|
604
|
+
if (result === undefined) {
|
|
605
|
+
const [r, g, b] = ensureContrast(
|
|
606
|
+
[(fg >> 16) & 0xff, (fg >> 8) & 0xff, fg & 0xff],
|
|
607
|
+
[(back >> 16) & 0xff, (back >> 8) & 0xff, back & 0xff],
|
|
608
|
+
this.minimumContrastRatio
|
|
609
|
+
);
|
|
610
|
+
result = rgbWord([r, g, b]);
|
|
611
|
+
if (this.contrastCache.size > 4096) this.contrastCache.clear();
|
|
612
|
+
this.contrastCache.set(key, result);
|
|
613
|
+
}
|
|
614
|
+
return result;
|
|
421
615
|
}
|
|
422
616
|
|
|
423
617
|
public setFontSize(size: number): void {
|
|
@@ -435,13 +629,18 @@ export abstract class TerminalRenderer {
|
|
|
435
629
|
}
|
|
436
630
|
|
|
437
631
|
public setCursorBlink(enabled: boolean): void {
|
|
438
|
-
if (enabled
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
632
|
+
if (enabled === this.cursorBlink) return;
|
|
633
|
+
this.cursorBlink = enabled;
|
|
634
|
+
this.stopCursorBlink();
|
|
635
|
+
if (enabled && this.focused) this.startCursorBlink();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Blink only while focused; unfocused, the cursor is a steady outline. */
|
|
639
|
+
public setFocused(focused: boolean): void {
|
|
640
|
+
if (focused === this.focused) return;
|
|
641
|
+
this.focused = focused;
|
|
642
|
+
this.stopCursorBlink();
|
|
643
|
+
if (focused && this.cursorBlink) this.startCursorBlink();
|
|
445
644
|
}
|
|
446
645
|
|
|
447
646
|
public getMetrics(): FontMetrics {
|
|
@@ -483,6 +682,24 @@ export abstract class TerminalRenderer {
|
|
|
483
682
|
}
|
|
484
683
|
}
|
|
485
684
|
|
|
685
|
+
function rgbWord([r, g, b]: [number, number, number]): number {
|
|
686
|
+
return (r << 16) | (g << 8) | b;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** An opaque CSS color as RGB; black when it cannot be read. */
|
|
690
|
+
export function cssRgb(css: string): [number, number, number] {
|
|
691
|
+
const hex = /^#([0-9a-f]{3,8})$/i.exec(css.trim());
|
|
692
|
+
if (hex) {
|
|
693
|
+
let h = hex[1];
|
|
694
|
+
if (h.length === 3 || h.length === 4) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
|
695
|
+
const v = Number.parseInt(h.slice(0, 6), 16);
|
|
696
|
+
return [(v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff];
|
|
697
|
+
}
|
|
698
|
+
const rgb = /rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i.exec(css);
|
|
699
|
+
if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
|
|
700
|
+
return [0, 0, 0];
|
|
701
|
+
}
|
|
702
|
+
|
|
486
703
|
function sameRange(a: LinkRange | null, b: LinkRange | null): boolean {
|
|
487
704
|
if (a === b) return true;
|
|
488
705
|
if (!a || !b) return false;
|
|
@@ -513,8 +730,8 @@ export class CanvasRenderer extends TerminalRenderer {
|
|
|
513
730
|
|
|
514
731
|
protected surfaceMismatch(cols: number, rows: number): boolean {
|
|
515
732
|
return (
|
|
516
|
-
this.canvas.width !== cols * this.metrics.width * this.devicePixelRatio ||
|
|
517
|
-
this.canvas.height !== rows * this.metrics.height * this.devicePixelRatio
|
|
733
|
+
this.canvas.width !== Math.round(cols * this.metrics.width * this.devicePixelRatio) ||
|
|
734
|
+
this.canvas.height !== Math.round(rows * this.metrics.height * this.devicePixelRatio)
|
|
518
735
|
);
|
|
519
736
|
}
|
|
520
737
|
|
|
@@ -523,8 +740,8 @@ export class CanvasRenderer extends TerminalRenderer {
|
|
|
523
740
|
const cssHeight = rows * this.metrics.height;
|
|
524
741
|
this.canvas.style.width = `${cssWidth}px`;
|
|
525
742
|
this.canvas.style.height = `${cssHeight}px`;
|
|
526
|
-
this.canvas.width = cssWidth * this.devicePixelRatio;
|
|
527
|
-
this.canvas.height = cssHeight * this.devicePixelRatio;
|
|
743
|
+
this.canvas.width = Math.round(cssWidth * this.devicePixelRatio);
|
|
744
|
+
this.canvas.height = Math.round(cssHeight * this.devicePixelRatio);
|
|
528
745
|
this.resetContextCache();
|
|
529
746
|
this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio);
|
|
530
747
|
this.ctx.textBaseline = 'alphabetic';
|
|
@@ -547,7 +764,7 @@ export class CanvasRenderer extends TerminalRenderer {
|
|
|
547
764
|
}
|
|
548
765
|
|
|
549
766
|
protected finishFrame(overlay: FrameOverlay): void {
|
|
550
|
-
if (overlay.showCursor) this.renderCursor(overlay.cursorX, overlay.cursorY);
|
|
767
|
+
if (overlay.showCursor) this.renderCursor(overlay.cursorX, overlay.cursorY, overlay.focused);
|
|
551
768
|
if (overlay.scrollbarOpacity > 0) this.renderScrollbar(overlay);
|
|
552
769
|
}
|
|
553
770
|
|
|
@@ -636,6 +853,9 @@ export class CanvasRenderer extends TerminalRenderer {
|
|
|
636
853
|
|
|
637
854
|
private backgroundColor(cell: GhosttyCell, x: number, y: number): string | null {
|
|
638
855
|
if (this.isInSelection(x, y)) return this.theme.selectionBackground;
|
|
856
|
+
const highlight = this.highlightAt(x, y);
|
|
857
|
+
if (highlight)
|
|
858
|
+
return highlight === 2 ? this.highlightColors.active : this.highlightColors.match;
|
|
639
859
|
const inverse = cell.flags & CellFlags.INVERSE;
|
|
640
860
|
const r = inverse ? cell.fg_r : cell.bg_r;
|
|
641
861
|
const g = inverse ? cell.fg_g : cell.bg_g;
|
|
@@ -646,8 +866,15 @@ export class CanvasRenderer extends TerminalRenderer {
|
|
|
646
866
|
|
|
647
867
|
private foregroundColor(cell: GhosttyCell, selected: boolean): string {
|
|
648
868
|
if (selected) return this.theme.selectionForeground;
|
|
649
|
-
|
|
650
|
-
|
|
869
|
+
const inverse = cell.flags & CellFlags.INVERSE;
|
|
870
|
+
const fg = inverse
|
|
871
|
+
? (cell.bg_r << 16) | (cell.bg_g << 8) | cell.bg_b
|
|
872
|
+
: (cell.fg_r << 16) | (cell.fg_g << 8) | cell.fg_b;
|
|
873
|
+
const bg = inverse
|
|
874
|
+
? (cell.fg_r << 16) | (cell.fg_g << 8) | cell.fg_b
|
|
875
|
+
: (cell.bg_r << 16) | (cell.bg_g << 8) | cell.bg_b;
|
|
876
|
+
const word = this.contrastedForeground(fg, bg === 0 ? null : bg);
|
|
877
|
+
return this.cssColor((word >> 16) & 0xff, (word >> 8) & 0xff, word & 0xff);
|
|
651
878
|
}
|
|
652
879
|
|
|
653
880
|
private cssColor(r: number, g: number, b: number): string {
|
|
@@ -679,10 +906,7 @@ export class CanvasRenderer extends TerminalRenderer {
|
|
|
679
906
|
private fontFor(style: number): string {
|
|
680
907
|
let font = this.fonts[style];
|
|
681
908
|
if (font === undefined) {
|
|
682
|
-
|
|
683
|
-
if (style & CellFlags.ITALIC) prefix += 'italic ';
|
|
684
|
-
if (style & CellFlags.BOLD) prefix += 'bold ';
|
|
685
|
-
font = `${prefix}${this.fontSize}px ${this.fontFamily}`;
|
|
909
|
+
font = this.fontString(style, this.fontSize);
|
|
686
910
|
this.fonts[style] = font;
|
|
687
911
|
}
|
|
688
912
|
return font;
|
|
@@ -768,11 +992,20 @@ export class CanvasRenderer extends TerminalRenderer {
|
|
|
768
992
|
ctx.stroke();
|
|
769
993
|
}
|
|
770
994
|
|
|
771
|
-
private renderCursor(x: number, y: number): void {
|
|
995
|
+
private renderCursor(x: number, y: number, focused: boolean): void {
|
|
772
996
|
const cursorX = x * this.metrics.width;
|
|
773
997
|
const cursorY = y * this.metrics.height;
|
|
774
998
|
this.setFill(this.theme.cursor);
|
|
775
|
-
if (
|
|
999
|
+
if (!focused) {
|
|
1000
|
+
this.ctx.strokeStyle = this.theme.cursor;
|
|
1001
|
+
this.ctx.lineWidth = 1;
|
|
1002
|
+
this.ctx.strokeRect(
|
|
1003
|
+
cursorX + 0.5,
|
|
1004
|
+
cursorY + 0.5,
|
|
1005
|
+
this.metrics.width - 1,
|
|
1006
|
+
this.metrics.height - 1
|
|
1007
|
+
);
|
|
1008
|
+
} else if (this.cursorStyle === 'block') {
|
|
776
1009
|
this.ctx.fillRect(cursorX, cursorY, this.metrics.width, this.metrics.height);
|
|
777
1010
|
const line = this.currentBuffer?.getLine(y);
|
|
778
1011
|
if (line?.[x]) {
|