@aurigax-ai/ghostty-web 0.1.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 +6 -1
- package/lib/addons/search.ts +190 -0
- package/lib/box-glyphs.ts +278 -0
- package/lib/ghostty.ts +465 -129
- package/lib/glyph-ink.ts +42 -0
- package/lib/index.ts +8 -1
- package/lib/input-handler.ts +55 -2
- package/lib/interfaces.ts +29 -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 +781 -686
- package/lib/selection-manager.ts +8 -3
- package/lib/terminal.ts +205 -85
- package/lib/types.ts +2 -1
- package/lib/webgl-instances.ts +69 -0
- package/lib/webgl-renderer.ts +943 -0
- package/package.json +2 -2
package/lib/renderer.ts
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Terminal renderers.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
9
|
-
* - Multiple cursor styles (block, underline, bar)
|
|
10
|
-
* - Dirty line optimization for 60 FPS
|
|
4
|
+
* `TerminalRenderer` decides what changed in a frame (dirty rows, cursor,
|
|
5
|
+
* selection, hovered links, scrolling) and owns the state every renderer
|
|
6
|
+
* shares; `CanvasRenderer` draws with Canvas 2D and `WebglRenderer`
|
|
7
|
+
* (webgl-renderer.ts) with WebGL 2. Both draw box drawing and block elements
|
|
8
|
+
* from geometry (box-glyphs.ts) so they fill their cells.
|
|
11
9
|
*/
|
|
12
10
|
|
|
13
|
-
import
|
|
11
|
+
import { drawBoxGlyph, isBoxGlyph } from './box-glyphs';
|
|
12
|
+
import type { FontWeight, ITheme } from './interfaces';
|
|
14
13
|
import type { SelectionManager } from './selection-manager';
|
|
15
14
|
import type { GhosttyCell } from './types';
|
|
16
15
|
import { CellFlags } from './types';
|
|
@@ -37,6 +36,17 @@ export interface IScrollbackProvider {
|
|
|
37
36
|
getScrollbackLength(): number;
|
|
38
37
|
}
|
|
39
38
|
|
|
39
|
+
export const LINK_COLOR = '#4A90E2';
|
|
40
|
+
export const TEXT_STYLE_FLAGS = CellFlags.BOLD | CellFlags.ITALIC | CellFlags.FAINT;
|
|
41
|
+
|
|
42
|
+
export function isBlank(cell: GhosttyCell): boolean {
|
|
43
|
+
return (cell.codepoint === 0 || cell.codepoint === 32) && cell.grapheme_len === 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isPlainAscii(cell: GhosttyCell): boolean {
|
|
47
|
+
return cell.width === 1 && cell.grapheme_len === 0 && cell.codepoint > 32 && cell.codepoint < 127;
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
// ============================================================================
|
|
41
51
|
// Type Definitions
|
|
42
52
|
// ============================================================================
|
|
@@ -48,6 +58,16 @@ export interface RendererOptions {
|
|
|
48
58
|
cursorBlink?: boolean; // Default: false
|
|
49
59
|
theme?: ITheme;
|
|
50
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;
|
|
51
71
|
}
|
|
52
72
|
|
|
53
73
|
export interface FontMetrics {
|
|
@@ -87,661 +107,431 @@ export const DEFAULT_THEME: Required<ITheme> = {
|
|
|
87
107
|
brightWhite: '#ffffff',
|
|
88
108
|
};
|
|
89
109
|
|
|
110
|
+
export type CursorStyle = 'block' | 'underline' | 'bar';
|
|
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
|
+
|
|
125
|
+
export interface LinkRange {
|
|
126
|
+
startX: number;
|
|
127
|
+
startY: number;
|
|
128
|
+
endX: number;
|
|
129
|
+
endY: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** What a renderer draws over the rows once they are up to date. */
|
|
133
|
+
export interface FrameOverlay {
|
|
134
|
+
cursorX: number;
|
|
135
|
+
cursorY: number;
|
|
136
|
+
/** Cursor shown: at the bottom of the scrollback, visible, and in the blink's on phase. */
|
|
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;
|
|
140
|
+
viewportY: number;
|
|
141
|
+
scrollbackLength: number;
|
|
142
|
+
cols: number;
|
|
143
|
+
rows: number;
|
|
144
|
+
/** Scrollbar opacity, 0 when there is no scrollback provider. */
|
|
145
|
+
scrollbarOpacity: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Measures the cell box of a font: width of 'M' and height from its ascent and descent. */
|
|
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 {
|
|
161
|
+
const canvas = document.createElement('canvas');
|
|
162
|
+
const ctx = canvas.getContext('2d')!;
|
|
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;
|
|
171
|
+
return { width, height, baseline };
|
|
172
|
+
}
|
|
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
|
+
|
|
90
219
|
// ============================================================================
|
|
91
|
-
//
|
|
220
|
+
// TerminalRenderer: what to draw each frame
|
|
92
221
|
// ============================================================================
|
|
93
222
|
|
|
94
|
-
export class
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
223
|
+
export abstract class TerminalRenderer {
|
|
224
|
+
protected readonly canvas: HTMLCanvasElement;
|
|
225
|
+
protected fontSize: number;
|
|
226
|
+
protected fontFamily: string;
|
|
227
|
+
protected cursorStyle: CursorStyle;
|
|
228
|
+
protected cursorBlink: boolean;
|
|
229
|
+
protected theme: Required<ITheme>;
|
|
230
|
+
protected devicePixelRatio: number;
|
|
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];
|
|
238
|
+
|
|
239
|
+
protected cursorVisible = true;
|
|
240
|
+
private lastCursorVisible = true;
|
|
241
|
+
protected focused = false;
|
|
242
|
+
private lastFocused = false;
|
|
107
243
|
private cursorBlinkInterval?: number;
|
|
108
244
|
|
|
109
245
|
/** Called when the renderer needs another frame (cursor blink), so an idle render loop wakes. */
|
|
110
246
|
public onNeedsFrame?: () => void;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
private lastViewportY: number = 0;
|
|
247
|
+
protected lastCursorPosition: { x: number; y: number } = { x: 0, y: 0 };
|
|
248
|
+
private lastViewportY = 0;
|
|
249
|
+
private fullRedrawPending = false;
|
|
115
250
|
|
|
116
|
-
|
|
117
|
-
|
|
251
|
+
/** The buffer of the frame being drawn, for grapheme lookups. */
|
|
252
|
+
protected currentBuffer: IRenderable | null = null;
|
|
118
253
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
private currentSelectionCoords: {
|
|
254
|
+
protected selectionManager?: SelectionManager;
|
|
255
|
+
/** Selection of the frame being drawn, viewport-relative. */
|
|
256
|
+
protected currentSelectionCoords: {
|
|
123
257
|
startCol: number;
|
|
124
258
|
startRow: number;
|
|
125
259
|
endCol: number;
|
|
126
260
|
endRow: number;
|
|
127
261
|
} | null = null;
|
|
128
262
|
|
|
129
|
-
|
|
130
|
-
private
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
private
|
|
135
|
-
|
|
136
|
-
private previousHoveredLinkRange:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
endY: number;
|
|
141
|
-
} | null = null;
|
|
263
|
+
private highlights = new Map<number, SearchHighlight[]>();
|
|
264
|
+
private readonly highlightRows = new Set<number>();
|
|
265
|
+
protected highlightColors: SearchHighlightColors = { match: '#5c6266', active: '#538bb5' };
|
|
266
|
+
|
|
267
|
+
protected hoveredHyperlinkId = 0;
|
|
268
|
+
private previousHoveredHyperlinkId = 0;
|
|
269
|
+
protected hoveredLinkRange: LinkRange | null = null;
|
|
270
|
+
private previousHoveredLinkRange: LinkRange | null = null;
|
|
271
|
+
|
|
272
|
+
/** Whether a moved or blinking cursor needs its row redrawn (false when the cursor is an overlay). */
|
|
273
|
+
protected abstract readonly cursorInRows: boolean;
|
|
142
274
|
|
|
143
275
|
constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) {
|
|
144
276
|
this.canvas = canvas;
|
|
145
|
-
const ctx = canvas.getContext('2d', { alpha: true });
|
|
146
|
-
if (!ctx) {
|
|
147
|
-
throw new Error('Failed to get 2D rendering context');
|
|
148
|
-
}
|
|
149
|
-
this.ctx = ctx;
|
|
150
|
-
|
|
151
|
-
// Apply options
|
|
152
277
|
this.fontSize = options.fontSize ?? 15;
|
|
153
278
|
this.fontFamily = options.fontFamily ?? 'monospace';
|
|
154
279
|
this.cursorStyle = options.cursorStyle ?? 'block';
|
|
155
280
|
this.cursorBlink = options.cursorBlink ?? false;
|
|
156
281
|
this.theme = { ...DEFAULT_THEME, ...options.theme };
|
|
157
282
|
this.devicePixelRatio = options.devicePixelRatio ?? window.devicePixelRatio ?? 1;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
this.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ==========================================================================
|
|
169
|
-
// Font Metrics Measurement
|
|
170
|
-
// ==========================================================================
|
|
171
|
-
|
|
172
|
-
private measureFont(): FontMetrics {
|
|
173
|
-
// Use an offscreen canvas for measurement
|
|
174
|
-
const canvas = document.createElement('canvas');
|
|
175
|
-
const ctx = canvas.getContext('2d')!;
|
|
176
|
-
|
|
177
|
-
// Set font (use actual pixel size for accurate measurement)
|
|
178
|
-
ctx.font = `${this.fontSize}px ${this.fontFamily}`;
|
|
179
|
-
|
|
180
|
-
// Measure width using 'M' (typically widest character)
|
|
181
|
-
const widthMetrics = ctx.measureText('M');
|
|
182
|
-
const width = Math.ceil(widthMetrics.width);
|
|
183
|
-
|
|
184
|
-
// Measure height using ascent + descent with padding for glyph overflow
|
|
185
|
-
const ascent = widthMetrics.actualBoundingBoxAscent || this.fontSize * 0.8;
|
|
186
|
-
const descent = widthMetrics.actualBoundingBoxDescent || this.fontSize * 0.2;
|
|
187
|
-
|
|
188
|
-
// Add 2px padding to height to account for glyphs that overflow (like 'f', 'd', 'g', 'p')
|
|
189
|
-
// and anti-aliasing pixels
|
|
190
|
-
const height = Math.ceil(ascent + descent) + 2;
|
|
191
|
-
const baseline = Math.ceil(ascent) + 1; // Offset baseline by half the padding
|
|
192
|
-
|
|
193
|
-
return { width, height, baseline };
|
|
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();
|
|
194
290
|
}
|
|
195
291
|
|
|
196
|
-
/**
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
return `rgb(${r}, ${g}, ${b})`;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// ==========================================================================
|
|
212
|
-
// Canvas Sizing
|
|
213
|
-
// ==========================================================================
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Resize canvas to fit terminal dimensions
|
|
217
|
-
*/
|
|
218
|
-
public resize(cols: number, rows: number): void {
|
|
219
|
-
const cssWidth = cols * this.metrics.width;
|
|
220
|
-
const cssHeight = rows * this.metrics.height;
|
|
221
|
-
|
|
222
|
-
// Set CSS size (what user sees)
|
|
223
|
-
this.canvas.style.width = `${cssWidth}px`;
|
|
224
|
-
this.canvas.style.height = `${cssHeight}px`;
|
|
225
|
-
|
|
226
|
-
// Set actual canvas size (scaled for DPI)
|
|
227
|
-
this.canvas.width = cssWidth * this.devicePixelRatio;
|
|
228
|
-
this.canvas.height = cssHeight * this.devicePixelRatio;
|
|
229
|
-
|
|
230
|
-
// Scale context to match DPI (setting canvas.width/height resets the context)
|
|
231
|
-
this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio);
|
|
292
|
+
/** Draws one viewport row. */
|
|
293
|
+
protected abstract drawLine(line: GhosttyCell[], y: number, cols: number): void;
|
|
294
|
+
/** Whether the drawing surface no longer matches cols × rows. */
|
|
295
|
+
protected abstract surfaceMismatch(cols: number, rows: number): boolean;
|
|
296
|
+
/** Draws the cursor and scrollbar and presents the frame. */
|
|
297
|
+
protected abstract finishFrame(overlay: FrameOverlay): void;
|
|
298
|
+
/** Resize the drawing surface to cols × rows cells. */
|
|
299
|
+
public abstract resize(cols: number, rows: number): void;
|
|
300
|
+
/** Clear the whole surface to the theme background. */
|
|
301
|
+
public abstract clear(): void;
|
|
302
|
+
/** Called after the font or its metrics changed. */
|
|
303
|
+
protected abstract fontChanged(): void;
|
|
232
304
|
|
|
233
|
-
// Set text rendering properties for crisp text
|
|
234
|
-
this.ctx.textBaseline = 'alphabetic';
|
|
235
|
-
this.ctx.textAlign = 'left';
|
|
236
|
-
|
|
237
|
-
// Fill background after resize
|
|
238
|
-
this.ctx.fillStyle = this.theme.background;
|
|
239
|
-
this.ctx.fillRect(0, 0, cssWidth, cssHeight);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// ==========================================================================
|
|
243
|
-
// Main Rendering
|
|
244
|
-
// ==========================================================================
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Render the terminal buffer to canvas
|
|
248
|
-
*/
|
|
249
305
|
public render(
|
|
250
306
|
buffer: IRenderable,
|
|
251
|
-
forceAll
|
|
252
|
-
viewportY
|
|
307
|
+
forceAll = false,
|
|
308
|
+
viewportY = 0,
|
|
253
309
|
scrollbackProvider?: IScrollbackProvider,
|
|
254
|
-
scrollbarOpacity
|
|
310
|
+
scrollbarOpacity = 1
|
|
255
311
|
): void {
|
|
256
|
-
// Store buffer reference for grapheme lookups in renderCell
|
|
257
312
|
this.currentBuffer = buffer;
|
|
258
|
-
|
|
259
|
-
// getCursor() calls update() internally to ensure fresh state.
|
|
260
|
-
// Multiple update() calls are safe - dirty state persists until clearDirty().
|
|
261
313
|
const cursor = buffer.getCursor();
|
|
262
314
|
const dims = buffer.getDimensions();
|
|
263
315
|
const scrollbackLength = scrollbackProvider ? scrollbackProvider.getScrollbackLength() : 0;
|
|
264
316
|
|
|
265
|
-
|
|
266
|
-
if (
|
|
317
|
+
if (buffer.needsFullRedraw?.()) forceAll = true;
|
|
318
|
+
if (this.fullRedrawPending) {
|
|
267
319
|
forceAll = true;
|
|
320
|
+
this.fullRedrawPending = false;
|
|
268
321
|
}
|
|
269
|
-
|
|
270
|
-
// Resize canvas if dimensions changed
|
|
271
|
-
const needsResize =
|
|
272
|
-
this.canvas.width !== dims.cols * this.metrics.width * this.devicePixelRatio ||
|
|
273
|
-
this.canvas.height !== dims.rows * this.metrics.height * this.devicePixelRatio;
|
|
274
|
-
|
|
275
|
-
if (needsResize) {
|
|
322
|
+
if (this.surfaceMismatch(dims.cols, dims.rows)) {
|
|
276
323
|
this.resize(dims.cols, dims.rows);
|
|
277
|
-
forceAll = true;
|
|
324
|
+
forceAll = true;
|
|
278
325
|
}
|
|
279
|
-
|
|
280
|
-
// Force re-render when viewport changes (scrolling)
|
|
281
326
|
if (viewportY !== this.lastViewportY) {
|
|
282
327
|
forceAll = true;
|
|
283
328
|
this.lastViewportY = viewportY;
|
|
284
329
|
}
|
|
285
330
|
|
|
286
|
-
// Check if cursor position changed or if blinking (need to redraw cursor line)
|
|
287
331
|
const cursorMoved =
|
|
288
332
|
cursor.x !== this.lastCursorPosition.x || cursor.y !== this.lastCursorPosition.y;
|
|
289
|
-
|
|
290
|
-
|
|
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)) {
|
|
291
338
|
if (!forceAll && !buffer.isRowDirty(cursor.y)) {
|
|
292
|
-
// Need to redraw cursor line
|
|
293
339
|
const line = buffer.getLine(cursor.y);
|
|
294
|
-
if (line)
|
|
295
|
-
this.renderLine(line, cursor.y, dims.cols);
|
|
296
|
-
}
|
|
340
|
+
if (line) this.drawLine(line, cursor.y, dims.cols);
|
|
297
341
|
}
|
|
298
342
|
if (cursorMoved && this.lastCursorPosition.y !== cursor.y) {
|
|
299
|
-
// Also redraw old cursor line if cursor moved to different line
|
|
300
343
|
if (!forceAll && !buffer.isRowDirty(this.lastCursorPosition.y)) {
|
|
301
344
|
const line = buffer.getLine(this.lastCursorPosition.y);
|
|
302
|
-
if (line)
|
|
303
|
-
this.renderLine(line, this.lastCursorPosition.y, dims.cols);
|
|
304
|
-
}
|
|
345
|
+
if (line) this.drawLine(line, this.lastCursorPosition.y, dims.cols);
|
|
305
346
|
}
|
|
306
347
|
}
|
|
307
348
|
}
|
|
308
349
|
|
|
309
|
-
// Check if we need to redraw selection-related lines
|
|
310
|
-
const hasSelection = this.selectionManager && this.selectionManager.hasSelection();
|
|
311
350
|
const selectionRows = new Set<number>();
|
|
312
|
-
|
|
313
|
-
// Cache selection coordinates for use during cell rendering
|
|
314
|
-
// This is used by isInSelection() to determine if a cell needs selection colors
|
|
351
|
+
const hasSelection = this.selectionManager?.hasSelection();
|
|
315
352
|
this.currentSelectionCoords = hasSelection ? this.selectionManager!.getSelectionCoords() : null;
|
|
316
|
-
|
|
317
|
-
// Mark current selection rows for redraw (includes programmatic selections)
|
|
318
353
|
if (this.currentSelectionCoords) {
|
|
319
354
|
const coords = this.currentSelectionCoords;
|
|
320
|
-
for (let row = coords.startRow; row <= coords.endRow; row++)
|
|
321
|
-
selectionRows.add(row);
|
|
322
|
-
}
|
|
355
|
+
for (let row = coords.startRow; row <= coords.endRow; row++) selectionRows.add(row);
|
|
323
356
|
}
|
|
324
|
-
|
|
325
|
-
|
|
357
|
+
for (const row of this.highlightRows) selectionRows.add(row);
|
|
358
|
+
this.highlightRows.clear();
|
|
326
359
|
if (this.selectionManager) {
|
|
327
360
|
const dirtyRows = this.selectionManager.getDirtySelectionRows();
|
|
328
361
|
if (dirtyRows.size > 0) {
|
|
329
|
-
for (const row of dirtyRows)
|
|
330
|
-
selectionRows.add(row);
|
|
331
|
-
}
|
|
332
|
-
// Clear the dirty rows tracking after marking for redraw
|
|
362
|
+
for (const row of dirtyRows) selectionRows.add(row);
|
|
333
363
|
this.selectionManager.clearDirtySelectionRows();
|
|
334
364
|
}
|
|
335
365
|
}
|
|
336
366
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
JSON.stringify(this.hoveredLinkRange) !== JSON.stringify(this.previousHoveredLinkRange);
|
|
342
|
-
|
|
343
|
-
if (hyperlinkChanged) {
|
|
344
|
-
// Find rows containing the old or new hovered hyperlink
|
|
345
|
-
// Must check the correct buffer based on viewportY (scrollback vs screen)
|
|
346
|
-
for (let y = 0; y < dims.rows; y++) {
|
|
347
|
-
let line: GhosttyCell[] | null = null;
|
|
348
|
-
|
|
349
|
-
// Same logic as rendering: fetch from scrollback or screen
|
|
350
|
-
if (viewportY > 0) {
|
|
351
|
-
if (y < viewportY && scrollbackProvider) {
|
|
352
|
-
// This row is from scrollback
|
|
353
|
-
// Floor viewportY for array access (handles fractional values during smooth scroll)
|
|
354
|
-
const scrollbackOffset = scrollbackLength - Math.floor(viewportY) + y;
|
|
355
|
-
line = scrollbackProvider.getScrollbackLine(scrollbackOffset);
|
|
356
|
-
} else {
|
|
357
|
-
// This row is from visible screen
|
|
358
|
-
const screenRow = y - Math.floor(viewportY);
|
|
359
|
-
line = buffer.getLine(screenRow);
|
|
360
|
-
}
|
|
361
|
-
} else {
|
|
362
|
-
// At bottom - fetch from visible screen
|
|
363
|
-
line = buffer.getLine(y);
|
|
367
|
+
const lineAt = (y: number): GhosttyCell[] | null => {
|
|
368
|
+
if (viewportY > 0) {
|
|
369
|
+
if (y < viewportY && scrollbackProvider) {
|
|
370
|
+
return scrollbackProvider.getScrollbackLine(scrollbackLength - Math.floor(viewportY) + y);
|
|
364
371
|
}
|
|
372
|
+
return buffer.getLine(y - Math.floor(viewportY));
|
|
373
|
+
}
|
|
374
|
+
return buffer.getLine(y);
|
|
375
|
+
};
|
|
365
376
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
377
|
+
const hyperlinkRows = new Set<number>();
|
|
378
|
+
if (this.hoveredHyperlinkId !== this.previousHoveredHyperlinkId) {
|
|
379
|
+
for (let y = 0; y < dims.rows; y++) {
|
|
380
|
+
const line = lineAt(y);
|
|
381
|
+
if (!line) continue;
|
|
382
|
+
for (const cell of line) {
|
|
383
|
+
if (
|
|
384
|
+
cell.hyperlink_id === this.hoveredHyperlinkId ||
|
|
385
|
+
cell.hyperlink_id === this.previousHoveredHyperlinkId
|
|
386
|
+
) {
|
|
387
|
+
hyperlinkRows.add(y);
|
|
388
|
+
break;
|
|
375
389
|
}
|
|
376
390
|
}
|
|
377
391
|
}
|
|
378
|
-
// Update previous state
|
|
379
392
|
this.previousHoveredHyperlinkId = this.hoveredHyperlinkId;
|
|
380
393
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if (this.previousHoveredLinkRange) {
|
|
386
|
-
for (
|
|
387
|
-
let y = this.previousHoveredLinkRange.startY;
|
|
388
|
-
y <= this.previousHoveredLinkRange.endY;
|
|
389
|
-
y++
|
|
390
|
-
) {
|
|
391
|
-
hyperlinkRows.add(y);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
// Add rows from new range
|
|
395
|
-
if (this.hoveredLinkRange) {
|
|
396
|
-
for (let y = this.hoveredLinkRange.startY; y <= this.hoveredLinkRange.endY; y++) {
|
|
397
|
-
hyperlinkRows.add(y);
|
|
398
|
-
}
|
|
394
|
+
if (!sameRange(this.hoveredLinkRange, this.previousHoveredLinkRange)) {
|
|
395
|
+
for (const range of [this.previousHoveredLinkRange, this.hoveredLinkRange]) {
|
|
396
|
+
if (!range) continue;
|
|
397
|
+
for (let y = range.startY; y <= range.endY; y++) hyperlinkRows.add(y);
|
|
399
398
|
}
|
|
400
399
|
this.previousHoveredLinkRange = this.hoveredLinkRange;
|
|
401
400
|
}
|
|
402
401
|
|
|
403
|
-
// Determine which rows need rendering.
|
|
404
|
-
// We also include adjacent rows (above and below) for each dirty row to handle
|
|
405
|
-
// glyph overflow - tall glyphs like Devanagari vowel signs can extend into
|
|
406
|
-
// adjacent rows' visual space.
|
|
407
402
|
const rowsToRender = new Set<number>();
|
|
408
403
|
for (let y = 0; y < dims.rows; y++) {
|
|
409
|
-
// When scrolled, always force render all lines since we're showing scrollback
|
|
410
404
|
const needsRender =
|
|
411
|
-
viewportY > 0
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
405
|
+
viewportY > 0 ||
|
|
406
|
+
forceAll ||
|
|
407
|
+
buffer.isRowDirty(y) ||
|
|
408
|
+
selectionRows.has(y) ||
|
|
409
|
+
hyperlinkRows.has(y);
|
|
415
410
|
if (needsRender) {
|
|
416
411
|
rowsToRender.add(y);
|
|
417
|
-
// Include adjacent rows to handle glyph overflow
|
|
418
412
|
if (y > 0) rowsToRender.add(y - 1);
|
|
419
413
|
if (y < dims.rows - 1) rowsToRender.add(y + 1);
|
|
420
414
|
}
|
|
421
415
|
}
|
|
422
|
-
|
|
423
|
-
// Render each line
|
|
424
416
|
for (let y = 0; y < dims.rows; y++) {
|
|
425
|
-
if (!rowsToRender.has(y))
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
// Fetch line from scrollback or visible screen
|
|
430
|
-
let line: GhosttyCell[] | null = null;
|
|
431
|
-
if (viewportY > 0) {
|
|
432
|
-
// Scrolled up - need to fetch from scrollback + visible screen
|
|
433
|
-
// When scrolled up N lines, we want to show:
|
|
434
|
-
// - Scrollback lines (from the end) + visible screen lines
|
|
435
|
-
|
|
436
|
-
// Check if this row should come from scrollback or visible screen
|
|
437
|
-
if (y < viewportY && scrollbackProvider) {
|
|
438
|
-
// This row is from scrollback (upper part of viewport)
|
|
439
|
-
// Get from end of scrollback buffer
|
|
440
|
-
// Floor viewportY for array access (handles fractional values during smooth scroll)
|
|
441
|
-
const scrollbackOffset = scrollbackLength - Math.floor(viewportY) + y;
|
|
442
|
-
line = scrollbackProvider.getScrollbackLine(scrollbackOffset);
|
|
443
|
-
} else {
|
|
444
|
-
// This row is from visible screen (lower part of viewport)
|
|
445
|
-
const screenRow = viewportY > 0 ? y - Math.floor(viewportY) : y;
|
|
446
|
-
line = buffer.getLine(screenRow);
|
|
447
|
-
}
|
|
448
|
-
} else {
|
|
449
|
-
// At bottom - fetch from visible screen
|
|
450
|
-
line = buffer.getLine(y);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
if (line) {
|
|
454
|
-
this.renderLine(line, y, dims.cols);
|
|
455
|
-
}
|
|
417
|
+
if (!rowsToRender.has(y)) continue;
|
|
418
|
+
const line = lineAt(y);
|
|
419
|
+
if (line) this.drawLine(line, y, dims.cols);
|
|
456
420
|
}
|
|
457
421
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
if (scrollbackProvider && scrollbarOpacity > 0) {
|
|
470
|
-
this.renderScrollbar(viewportY, scrollbackLength, dims.rows, scrollbarOpacity);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
// Update last cursor position
|
|
422
|
+
this.finishFrame({
|
|
423
|
+
cursorX: cursor.x,
|
|
424
|
+
cursorY: cursor.y,
|
|
425
|
+
showCursor: viewportY === 0 && cursor.visible && this.cursorVisible,
|
|
426
|
+
focused: this.focused,
|
|
427
|
+
viewportY,
|
|
428
|
+
scrollbackLength,
|
|
429
|
+
cols: dims.cols,
|
|
430
|
+
rows: dims.rows,
|
|
431
|
+
scrollbarOpacity: scrollbackProvider ? scrollbarOpacity : 0,
|
|
432
|
+
});
|
|
474
433
|
this.lastCursorPosition = { x: cursor.x, y: cursor.y };
|
|
475
|
-
|
|
476
|
-
// ALWAYS clear dirty flags after rendering, regardless of forceAll.
|
|
477
|
-
// This is critical - if we don't clear after a full redraw, the dirty
|
|
478
|
-
// state persists and the next frame might not detect new changes properly.
|
|
479
434
|
buffer.clearDirty();
|
|
480
435
|
}
|
|
481
436
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
* 2. Second pass: Draw all cell text and decorations
|
|
486
|
-
*
|
|
487
|
-
* This two-pass approach is necessary for proper rendering of complex scripts
|
|
488
|
-
* like Devanagari where diacritics (like vowel sign ि) can extend LEFT of the
|
|
489
|
-
* base character into the previous cell's visual area. If we draw backgrounds
|
|
490
|
-
* and text in a single pass (cell by cell), the background of cell N would
|
|
491
|
-
* cover any left-extending portions of graphemes from cell N-1.
|
|
492
|
-
*/
|
|
493
|
-
private renderLine(line: GhosttyCell[], y: number, cols: number): void {
|
|
494
|
-
const lineY = y * this.metrics.height;
|
|
495
|
-
const lineWidth = cols * this.metrics.width;
|
|
496
|
-
|
|
497
|
-
// Clear line background then fill with theme color.
|
|
498
|
-
// We clear just the cell area - glyph overflow is handled by also
|
|
499
|
-
// redrawing adjacent rows (see render() method).
|
|
500
|
-
// clearRect is needed because fillRect composites rather than replaces,
|
|
501
|
-
// so transparent/translucent backgrounds wouldn't clear previous content.
|
|
502
|
-
this.ctx.clearRect(0, lineY, lineWidth, this.metrics.height);
|
|
503
|
-
this.ctx.fillStyle = this.theme.background;
|
|
504
|
-
this.ctx.fillRect(0, lineY, lineWidth, this.metrics.height);
|
|
505
|
-
|
|
506
|
-
// PASS 1: Draw all cell backgrounds first
|
|
507
|
-
// This ensures all backgrounds are painted before any text, allowing text
|
|
508
|
-
// to "bleed" across cell boundaries without being covered by adjacent backgrounds
|
|
509
|
-
for (let x = 0; x < line.length; x++) {
|
|
510
|
-
const cell = line[x];
|
|
511
|
-
if (cell.width === 0) continue; // Skip spacer cells for wide characters
|
|
512
|
-
this.renderCellBackground(cell, x, y);
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// PASS 2: Draw all cell text and decorations
|
|
516
|
-
// Now text can safely extend beyond cell boundaries (for complex scripts)
|
|
517
|
-
for (let x = 0; x < line.length; x++) {
|
|
518
|
-
const cell = line[x];
|
|
519
|
-
if (cell.width === 0) continue; // Skip spacer cells for wide characters
|
|
520
|
-
this.renderCellText(cell, x, y);
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
/**
|
|
525
|
-
* Render a cell's background only (Pass 1 of two-pass rendering)
|
|
526
|
-
* Selection highlighting is integrated here to avoid z-order issues with
|
|
527
|
-
* complex glyphs (like Devanagari) that extend outside their cell bounds.
|
|
528
|
-
*/
|
|
529
|
-
private renderCellBackground(cell: GhosttyCell, x: number, y: number): void {
|
|
530
|
-
const cellX = x * this.metrics.width;
|
|
531
|
-
const cellY = y * this.metrics.height;
|
|
532
|
-
const cellWidth = this.metrics.width * cell.width;
|
|
533
|
-
|
|
534
|
-
// Check if this cell is selected
|
|
535
|
-
const isSelected = this.isInSelection(x, y);
|
|
536
|
-
|
|
537
|
-
if (isSelected) {
|
|
538
|
-
// Draw selection background (solid color, not overlay)
|
|
539
|
-
this.ctx.fillStyle = this.theme.selectionBackground;
|
|
540
|
-
this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height);
|
|
541
|
-
return; // Selection background replaces cell background
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
// Extract background color and handle inverse
|
|
545
|
-
let bg_r = cell.bg_r,
|
|
546
|
-
bg_g = cell.bg_g,
|
|
547
|
-
bg_b = cell.bg_b;
|
|
548
|
-
|
|
549
|
-
if (cell.flags & CellFlags.INVERSE) {
|
|
550
|
-
// When inverted, background becomes foreground
|
|
551
|
-
bg_r = cell.fg_r;
|
|
552
|
-
bg_g = cell.fg_g;
|
|
553
|
-
bg_b = cell.fg_b;
|
|
554
|
-
}
|
|
437
|
+
// ==========================================================================
|
|
438
|
+
// Shared helpers
|
|
439
|
+
// ==========================================================================
|
|
555
440
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
if (!isDefaultBg) {
|
|
560
|
-
this.ctx.fillStyle = this.rgbToCSS(bg_r, bg_g, bg_b);
|
|
561
|
-
this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height);
|
|
562
|
-
}
|
|
441
|
+
/** Makes the next frame redraw every row. */
|
|
442
|
+
protected requestFullRedraw(): void {
|
|
443
|
+
this.fullRedrawPending = true;
|
|
563
444
|
}
|
|
564
445
|
|
|
565
|
-
/**
|
|
566
|
-
|
|
567
|
-
* Selection foreground color is applied here to match the selection background.
|
|
568
|
-
*/
|
|
569
|
-
private renderCellText(cell: GhosttyCell, x: number, y: number, colorOverride?: string): void {
|
|
570
|
-
const cellX = x * this.metrics.width;
|
|
571
|
-
const cellY = y * this.metrics.height;
|
|
572
|
-
const cellWidth = this.metrics.width * cell.width;
|
|
573
|
-
|
|
574
|
-
// Skip rendering if invisible
|
|
575
|
-
if (cell.flags & CellFlags.INVISIBLE) {
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// Check if this cell is selected
|
|
580
|
-
const isSelected = this.isInSelection(x, y);
|
|
581
|
-
|
|
582
|
-
// Set text style
|
|
583
|
-
let fontStyle = '';
|
|
584
|
-
if (cell.flags & CellFlags.ITALIC) fontStyle += 'italic ';
|
|
585
|
-
if (cell.flags & CellFlags.BOLD) fontStyle += 'bold ';
|
|
586
|
-
this.ctx.font = `${fontStyle}${this.fontSize}px ${this.fontFamily}`;
|
|
587
|
-
|
|
588
|
-
// Set text color - use override, selection foreground, or normal color
|
|
589
|
-
if (colorOverride) {
|
|
590
|
-
this.ctx.fillStyle = colorOverride;
|
|
591
|
-
} else if (isSelected) {
|
|
592
|
-
this.ctx.fillStyle = this.theme.selectionForeground;
|
|
593
|
-
} else {
|
|
594
|
-
// Extract colors and handle inverse
|
|
595
|
-
let fg_r = cell.fg_r,
|
|
596
|
-
fg_g = cell.fg_g,
|
|
597
|
-
fg_b = cell.fg_b;
|
|
598
|
-
|
|
599
|
-
if (cell.flags & CellFlags.INVERSE) {
|
|
600
|
-
// When inverted, foreground becomes background
|
|
601
|
-
fg_r = cell.bg_r;
|
|
602
|
-
fg_g = cell.bg_g;
|
|
603
|
-
fg_b = cell.bg_b;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
this.ctx.fillStyle = this.rgbToCSS(fg_r, fg_g, fg_b);
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// Apply faint effect
|
|
610
|
-
if (cell.flags & CellFlags.FAINT) {
|
|
611
|
-
this.ctx.globalAlpha = 0.5;
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// Draw text
|
|
615
|
-
const textX = cellX;
|
|
616
|
-
const textY = cellY + this.metrics.baseline;
|
|
617
|
-
|
|
618
|
-
// Get the character to render - use grapheme lookup for complex scripts
|
|
619
|
-
let char: string;
|
|
446
|
+
/** The text of a cell: its grapheme cluster when it has one, else its codepoint. */
|
|
447
|
+
protected cellText(cell: GhosttyCell, x: number, y: number): string {
|
|
620
448
|
if (cell.grapheme_len > 0 && this.currentBuffer?.getGraphemeString) {
|
|
621
|
-
|
|
622
|
-
char = this.currentBuffer.getGraphemeString(y, x);
|
|
623
|
-
} else {
|
|
624
|
-
// Simple cell - single codepoint
|
|
625
|
-
char = String.fromCodePoint(cell.codepoint || 32); // Default to space if null
|
|
626
|
-
}
|
|
627
|
-
this.ctx.fillText(char, textX, textY);
|
|
628
|
-
|
|
629
|
-
// Reset alpha
|
|
630
|
-
if (cell.flags & CellFlags.FAINT) {
|
|
631
|
-
this.ctx.globalAlpha = 1.0;
|
|
449
|
+
return this.currentBuffer.getGraphemeString(y, x);
|
|
632
450
|
}
|
|
451
|
+
return String.fromCodePoint(cell.codepoint || 32);
|
|
452
|
+
}
|
|
633
453
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
454
|
+
protected isInSelection(x: number, y: number): boolean {
|
|
455
|
+
const sel = this.currentSelectionCoords;
|
|
456
|
+
if (!sel) return false;
|
|
457
|
+
const { startCol, startRow, endCol, endRow } = sel;
|
|
458
|
+
if (startRow === endRow) return y === startRow && x >= startCol && x <= endCol;
|
|
459
|
+
if (y === startRow) return x >= startCol;
|
|
460
|
+
if (y === endRow) return x <= endCol;
|
|
461
|
+
return y > startRow && y < endRow;
|
|
462
|
+
}
|
|
644
463
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
this.ctx.moveTo(cellX, strikeY);
|
|
652
|
-
this.ctx.lineTo(cellX + cellWidth, strikeY);
|
|
653
|
-
this.ctx.stroke();
|
|
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;
|
|
654
470
|
}
|
|
471
|
+
return 0;
|
|
472
|
+
}
|
|
655
473
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
this.ctx.stroke();
|
|
669
|
-
}
|
|
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);
|
|
670
486
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
const range = this.hoveredLinkRange;
|
|
675
|
-
// Check if this cell is within the hovered link range
|
|
676
|
-
const isInRange =
|
|
677
|
-
(y === range.startY && x >= range.startX && (y < range.endY || x <= range.endX)) ||
|
|
678
|
-
(y > range.startY && y < range.endY) ||
|
|
679
|
-
(y === range.endY && x <= range.endX && (y > range.startY || x >= range.startX));
|
|
680
|
-
|
|
681
|
-
if (isInRange) {
|
|
682
|
-
const underlineY = cellY + this.metrics.baseline + 2;
|
|
683
|
-
this.ctx.strokeStyle = '#4A90E2'; // Blue underline on hover
|
|
684
|
-
this.ctx.lineWidth = 1;
|
|
685
|
-
this.ctx.beginPath();
|
|
686
|
-
this.ctx.moveTo(cellX, underlineY);
|
|
687
|
-
this.ctx.lineTo(cellX + cellWidth, underlineY);
|
|
688
|
-
this.ctx.stroke();
|
|
689
|
-
}
|
|
487
|
+
if (colors) {
|
|
488
|
+
this.highlightColors = colors;
|
|
489
|
+
this.searchColorsChanged();
|
|
690
490
|
}
|
|
691
491
|
}
|
|
692
492
|
|
|
693
|
-
/**
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
493
|
+
/** Called when the search highlight colors change. */
|
|
494
|
+
protected searchColorsChanged(): void {}
|
|
495
|
+
|
|
496
|
+
/** Whether the cell has a hovered link under it (OSC 8 id or a detected URL range). */
|
|
497
|
+
protected linkHovered(cell: GhosttyCell, x: number, y: number): boolean {
|
|
498
|
+
if (cell.hyperlink_id > 0 && cell.hyperlink_id === this.hoveredHyperlinkId) return true;
|
|
499
|
+
const range = this.hoveredLinkRange;
|
|
500
|
+
if (!range) return false;
|
|
501
|
+
return (
|
|
502
|
+
(y === range.startY && x >= range.startX && (y < range.endY || x <= range.endX)) ||
|
|
503
|
+
(y > range.startY && y < range.endY) ||
|
|
504
|
+
(y === range.endY && x <= range.endX && (y > range.startY || x >= range.startX))
|
|
505
|
+
);
|
|
506
|
+
}
|
|
699
507
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
case 'underline':
|
|
721
|
-
// Underline at bottom of cell
|
|
722
|
-
const underlineHeight = Math.max(2, Math.floor(this.metrics.height * 0.15));
|
|
723
|
-
this.ctx.fillRect(
|
|
724
|
-
cursorX,
|
|
725
|
-
cursorY + this.metrics.height - underlineHeight,
|
|
726
|
-
this.metrics.width,
|
|
727
|
-
underlineHeight
|
|
728
|
-
);
|
|
729
|
-
break;
|
|
730
|
-
|
|
731
|
-
case 'bar':
|
|
732
|
-
// Vertical bar at left of cell
|
|
733
|
-
const barWidth = Math.max(2, Math.floor(this.metrics.width * 0.15));
|
|
734
|
-
this.ctx.fillRect(cursorX, cursorY, barWidth, this.metrics.height);
|
|
735
|
-
break;
|
|
736
|
-
}
|
|
508
|
+
/** Scrollbar geometry in CSS pixels, or null when there is nothing to draw. */
|
|
509
|
+
protected scrollbarThumb(overlay: FrameOverlay): {
|
|
510
|
+
x: number;
|
|
511
|
+
trackY: number;
|
|
512
|
+
trackHeight: number;
|
|
513
|
+
thumbY: number;
|
|
514
|
+
thumbHeight: number;
|
|
515
|
+
width: number;
|
|
516
|
+
} | null {
|
|
517
|
+
if (overlay.scrollbarOpacity <= 0 || overlay.scrollbackLength === 0) return null;
|
|
518
|
+
const height = overlay.rows * this.metrics.height;
|
|
519
|
+
const width = 8;
|
|
520
|
+
const x = overlay.cols * this.metrics.width - width - 4;
|
|
521
|
+
const padding = 4;
|
|
522
|
+
const trackHeight = height - padding * 2;
|
|
523
|
+
const total = overlay.scrollbackLength + overlay.rows;
|
|
524
|
+
const thumbHeight = Math.max(20, (overlay.rows / total) * trackHeight);
|
|
525
|
+
const position = overlay.viewportY / overlay.scrollbackLength;
|
|
526
|
+
const thumbY = padding + (trackHeight - thumbHeight) * (1 - position);
|
|
527
|
+
return { x, trackY: padding, trackHeight, thumbY, thumbHeight, width };
|
|
737
528
|
}
|
|
738
529
|
|
|
739
530
|
// ==========================================================================
|
|
740
|
-
// Cursor
|
|
531
|
+
// Cursor blinking
|
|
741
532
|
// ==========================================================================
|
|
742
533
|
|
|
743
534
|
private startCursorBlink(): void {
|
|
744
|
-
// xterm.js uses ~530ms blink interval
|
|
745
535
|
this.cursorBlinkInterval = window.setInterval(() => {
|
|
746
536
|
this.cursorVisible = !this.cursorVisible;
|
|
747
537
|
this.onNeedsFrame?.();
|
|
@@ -760,199 +550,504 @@ export class CanvasRenderer {
|
|
|
760
550
|
// Public API
|
|
761
551
|
// ==========================================================================
|
|
762
552
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
+
|
|
563
|
+
public remeasureFont(): void {
|
|
564
|
+
this.metrics = this.measure();
|
|
565
|
+
this.fontChanged();
|
|
566
|
+
}
|
|
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
|
+
|
|
766
580
|
public setTheme(theme: ITheme): void {
|
|
767
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}`;
|
|
768
592
|
}
|
|
769
593
|
|
|
770
594
|
/**
|
|
771
|
-
*
|
|
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.
|
|
772
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;
|
|
615
|
+
}
|
|
616
|
+
|
|
773
617
|
public setFontSize(size: number): void {
|
|
774
618
|
this.fontSize = size;
|
|
775
|
-
this.
|
|
619
|
+
this.remeasureFont();
|
|
776
620
|
}
|
|
777
621
|
|
|
778
|
-
/**
|
|
779
|
-
* Update font family
|
|
780
|
-
*/
|
|
781
622
|
public setFontFamily(family: string): void {
|
|
782
623
|
this.fontFamily = family;
|
|
783
|
-
this.
|
|
624
|
+
this.remeasureFont();
|
|
784
625
|
}
|
|
785
626
|
|
|
786
|
-
|
|
787
|
-
* Update cursor style
|
|
788
|
-
*/
|
|
789
|
-
public setCursorStyle(style: 'block' | 'underline' | 'bar'): void {
|
|
627
|
+
public setCursorStyle(style: CursorStyle): void {
|
|
790
628
|
this.cursorStyle = style;
|
|
791
629
|
}
|
|
792
630
|
|
|
793
|
-
/**
|
|
794
|
-
* Enable/disable cursor blinking
|
|
795
|
-
*/
|
|
796
631
|
public setCursorBlink(enabled: boolean): void {
|
|
797
|
-
if (enabled
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
this.cursorBlink = false;
|
|
802
|
-
this.stopCursorBlink();
|
|
803
|
-
}
|
|
632
|
+
if (enabled === this.cursorBlink) return;
|
|
633
|
+
this.cursorBlink = enabled;
|
|
634
|
+
this.stopCursorBlink();
|
|
635
|
+
if (enabled && this.focused) this.startCursorBlink();
|
|
804
636
|
}
|
|
805
637
|
|
|
806
|
-
/**
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
* Shows scroll position and allows click/drag interaction
|
|
813
|
-
* @param opacity Opacity level (0-1) for fade in/out effect
|
|
814
|
-
*/
|
|
815
|
-
private renderScrollbar(
|
|
816
|
-
viewportY: number,
|
|
817
|
-
scrollbackLength: number,
|
|
818
|
-
visibleRows: number,
|
|
819
|
-
opacity: number = 1
|
|
820
|
-
): void {
|
|
821
|
-
const ctx = this.ctx;
|
|
822
|
-
const canvasHeight = this.canvas.height / this.devicePixelRatio;
|
|
823
|
-
const canvasWidth = this.canvas.width / this.devicePixelRatio;
|
|
824
|
-
|
|
825
|
-
// Scrollbar dimensions
|
|
826
|
-
const scrollbarWidth = 8;
|
|
827
|
-
const scrollbarX = canvasWidth - scrollbarWidth - 4;
|
|
828
|
-
const scrollbarPadding = 4;
|
|
829
|
-
const scrollbarTrackHeight = canvasHeight - scrollbarPadding * 2;
|
|
830
|
-
|
|
831
|
-
// Always clear the scrollbar area first (fixes ghosting when fading out)
|
|
832
|
-
ctx.clearRect(scrollbarX - 2, 0, scrollbarWidth + 6, canvasHeight);
|
|
833
|
-
ctx.fillStyle = this.theme.background;
|
|
834
|
-
ctx.fillRect(scrollbarX - 2, 0, scrollbarWidth + 6, canvasHeight);
|
|
835
|
-
|
|
836
|
-
// Don't draw scrollbar if fully transparent or no scrollback
|
|
837
|
-
if (opacity <= 0 || scrollbackLength === 0) return;
|
|
838
|
-
|
|
839
|
-
// Calculate scrollbar thumb size and position
|
|
840
|
-
const totalLines = scrollbackLength + visibleRows;
|
|
841
|
-
const thumbHeight = Math.max(20, (visibleRows / totalLines) * scrollbarTrackHeight);
|
|
842
|
-
|
|
843
|
-
// Position: 0 = at bottom, scrollbackLength = at top
|
|
844
|
-
const scrollPosition = viewportY / scrollbackLength; // 0 to 1
|
|
845
|
-
const thumbY = scrollbarPadding + (scrollbarTrackHeight - thumbHeight) * (1 - scrollPosition);
|
|
846
|
-
|
|
847
|
-
// Draw scrollbar track (subtle background) with opacity
|
|
848
|
-
ctx.fillStyle = `rgba(128, 128, 128, ${0.1 * opacity})`;
|
|
849
|
-
ctx.fillRect(scrollbarX, scrollbarPadding, scrollbarWidth, scrollbarTrackHeight);
|
|
850
|
-
|
|
851
|
-
// Draw scrollbar thumb with opacity
|
|
852
|
-
const isScrolled = viewportY > 0;
|
|
853
|
-
const baseOpacity = isScrolled ? 0.5 : 0.3;
|
|
854
|
-
ctx.fillStyle = `rgba(128, 128, 128, ${baseOpacity * opacity})`;
|
|
855
|
-
ctx.fillRect(scrollbarX, thumbY, scrollbarWidth, thumbHeight);
|
|
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();
|
|
856
644
|
}
|
|
645
|
+
|
|
857
646
|
public getMetrics(): FontMetrics {
|
|
858
647
|
return { ...this.metrics };
|
|
859
648
|
}
|
|
860
649
|
|
|
861
|
-
/**
|
|
862
|
-
* Get canvas element (needed by SelectionManager)
|
|
863
|
-
*/
|
|
650
|
+
/** The canvas that takes pointer events and defines the terminal's box. */
|
|
864
651
|
public getCanvas(): HTMLCanvasElement {
|
|
865
652
|
return this.canvas;
|
|
866
653
|
}
|
|
867
654
|
|
|
868
|
-
/**
|
|
869
|
-
* Set selection manager (for rendering selection)
|
|
870
|
-
*/
|
|
871
655
|
public setSelectionManager(manager: SelectionManager): void {
|
|
872
656
|
this.selectionManager = manager;
|
|
873
657
|
}
|
|
874
658
|
|
|
875
|
-
/**
|
|
876
|
-
* Check if a cell at (x, y) is within the current selection.
|
|
877
|
-
* Uses cached selection coordinates for performance.
|
|
878
|
-
*/
|
|
879
|
-
private isInSelection(x: number, y: number): boolean {
|
|
880
|
-
const sel = this.currentSelectionCoords;
|
|
881
|
-
if (!sel) return false;
|
|
882
|
-
|
|
883
|
-
const { startCol, startRow, endCol, endRow } = sel;
|
|
884
|
-
|
|
885
|
-
// Single line selection
|
|
886
|
-
if (startRow === endRow) {
|
|
887
|
-
return y === startRow && x >= startCol && x <= endCol;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
// Multi-line selection
|
|
891
|
-
if (y === startRow) {
|
|
892
|
-
// First line: from startCol to end of line
|
|
893
|
-
return x >= startCol;
|
|
894
|
-
} else if (y === endRow) {
|
|
895
|
-
// Last line: from start of line to endCol
|
|
896
|
-
return x <= endCol;
|
|
897
|
-
} else if (y > startRow && y < endRow) {
|
|
898
|
-
// Middle lines: entire line is selected
|
|
899
|
-
return true;
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
return false;
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
/**
|
|
906
|
-
* Set the currently hovered hyperlink ID for rendering underlines
|
|
907
|
-
*/
|
|
908
659
|
public setHoveredHyperlinkId(hyperlinkId: number): void {
|
|
909
660
|
this.hoveredHyperlinkId = hyperlinkId;
|
|
910
661
|
}
|
|
911
662
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
startX: number;
|
|
919
|
-
startY: number;
|
|
920
|
-
endX: number;
|
|
921
|
-
endY: number;
|
|
922
|
-
} | null
|
|
923
|
-
): void {
|
|
663
|
+
public getHoveredHyperlinkId(): number {
|
|
664
|
+
return this.hoveredHyperlinkId;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** Set the hovered range of a detected URL, or null to clear it. */
|
|
668
|
+
public setHoveredLinkRange(range: LinkRange | null): void {
|
|
924
669
|
this.hoveredLinkRange = range;
|
|
925
670
|
}
|
|
926
671
|
|
|
927
|
-
/**
|
|
928
|
-
* Get character cell width (for coordinate conversion)
|
|
929
|
-
*/
|
|
930
672
|
public get charWidth(): number {
|
|
931
673
|
return this.metrics.width;
|
|
932
674
|
}
|
|
933
675
|
|
|
934
|
-
/**
|
|
935
|
-
* Get character cell height (for coordinate conversion)
|
|
936
|
-
*/
|
|
937
676
|
public get charHeight(): number {
|
|
938
677
|
return this.metrics.height;
|
|
939
678
|
}
|
|
940
679
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
680
|
+
public dispose(): void {
|
|
681
|
+
this.stopCursorBlink();
|
|
682
|
+
}
|
|
683
|
+
}
|
|
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
|
+
|
|
703
|
+
function sameRange(a: LinkRange | null, b: LinkRange | null): boolean {
|
|
704
|
+
if (a === b) return true;
|
|
705
|
+
if (!a || !b) return false;
|
|
706
|
+
return a.startX === b.startX && a.startY === b.startY && a.endX === b.endX && a.endY === b.endY;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ============================================================================
|
|
710
|
+
// CanvasRenderer
|
|
711
|
+
// ============================================================================
|
|
712
|
+
|
|
713
|
+
export class CanvasRenderer extends TerminalRenderer {
|
|
714
|
+
protected readonly cursorInRows = true;
|
|
715
|
+
private readonly ctx: CanvasRenderingContext2D;
|
|
716
|
+
private readonly batchText: boolean;
|
|
717
|
+
private readonly colorStrings = new Map<number, string>();
|
|
718
|
+
private fonts: string[] = [];
|
|
719
|
+
private readonly advances = new Map<string, number>();
|
|
720
|
+
private currentFill = '';
|
|
721
|
+
private currentFont = '';
|
|
722
|
+
|
|
723
|
+
constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) {
|
|
724
|
+
super(canvas, options);
|
|
725
|
+
const ctx = canvas.getContext('2d', { alpha: true });
|
|
726
|
+
if (!ctx) throw new Error('Failed to get 2D rendering context');
|
|
727
|
+
this.ctx = ctx;
|
|
728
|
+
this.batchText = typeof ctx.letterSpacing === 'string';
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
protected surfaceMismatch(cols: number, rows: number): boolean {
|
|
732
|
+
return (
|
|
733
|
+
this.canvas.width !== Math.round(cols * this.metrics.width * this.devicePixelRatio) ||
|
|
734
|
+
this.canvas.height !== Math.round(rows * this.metrics.height * this.devicePixelRatio)
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
public resize(cols: number, rows: number): void {
|
|
739
|
+
const cssWidth = cols * this.metrics.width;
|
|
740
|
+
const cssHeight = rows * this.metrics.height;
|
|
741
|
+
this.canvas.style.width = `${cssWidth}px`;
|
|
742
|
+
this.canvas.style.height = `${cssHeight}px`;
|
|
743
|
+
this.canvas.width = Math.round(cssWidth * this.devicePixelRatio);
|
|
744
|
+
this.canvas.height = Math.round(cssHeight * this.devicePixelRatio);
|
|
745
|
+
this.resetContextCache();
|
|
746
|
+
this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio);
|
|
747
|
+
this.ctx.textBaseline = 'alphabetic';
|
|
748
|
+
this.ctx.textAlign = 'left';
|
|
749
|
+
this.ctx.fillStyle = this.theme.background;
|
|
750
|
+
this.ctx.fillRect(0, 0, cssWidth, cssHeight);
|
|
751
|
+
}
|
|
752
|
+
|
|
944
753
|
public clear(): void {
|
|
945
|
-
// clearRect first because fillRect composites rather than replaces,
|
|
946
|
-
// so transparent/translucent backgrounds wouldn't clear previous content.
|
|
947
754
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
948
755
|
this.ctx.fillStyle = this.theme.background;
|
|
949
756
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
|
757
|
+
this.resetContextCache();
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
protected fontChanged(): void {
|
|
761
|
+
this.fonts = [];
|
|
762
|
+
this.advances.clear();
|
|
763
|
+
this.currentFont = '';
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
protected finishFrame(overlay: FrameOverlay): void {
|
|
767
|
+
if (overlay.showCursor) this.renderCursor(overlay.cursorX, overlay.cursorY, overlay.focused);
|
|
768
|
+
if (overlay.scrollbarOpacity > 0) this.renderScrollbar(overlay);
|
|
950
769
|
}
|
|
951
770
|
|
|
952
771
|
/**
|
|
953
|
-
*
|
|
772
|
+
* Draws a row in two passes, backgrounds then text, so glyphs that extend
|
|
773
|
+
* past their cell are not covered by a neighbour's background. Backgrounds
|
|
774
|
+
* of one color are one rect; runs of plain ASCII in one style are one
|
|
775
|
+
* fillText, spaced to the cell grid with letterSpacing.
|
|
954
776
|
*/
|
|
955
|
-
|
|
956
|
-
this.
|
|
777
|
+
protected drawLine(line: GhosttyCell[], y: number, cols: number): void {
|
|
778
|
+
const ctx = this.ctx;
|
|
779
|
+
const cellW = this.metrics.width;
|
|
780
|
+
const cellH = this.metrics.height;
|
|
781
|
+
const lineY = y * cellH;
|
|
782
|
+
|
|
783
|
+
ctx.clearRect(0, lineY, cols * cellW, cellH);
|
|
784
|
+
this.setFill(this.theme.background);
|
|
785
|
+
ctx.fillRect(0, lineY, cols * cellW, cellH);
|
|
786
|
+
|
|
787
|
+
let runStart = 0;
|
|
788
|
+
let runEnd = 0;
|
|
789
|
+
let runColor: string | null = null;
|
|
790
|
+
for (let x = 0; x < line.length; x++) {
|
|
791
|
+
const cell = line[x];
|
|
792
|
+
if (cell.width === 0) continue;
|
|
793
|
+
const color = this.backgroundColor(cell, x, y);
|
|
794
|
+
if (color !== runColor) {
|
|
795
|
+
if (runColor) {
|
|
796
|
+
this.setFill(runColor);
|
|
797
|
+
ctx.fillRect(runStart * cellW, lineY, (runEnd - runStart) * cellW, cellH);
|
|
798
|
+
}
|
|
799
|
+
runColor = color;
|
|
800
|
+
runStart = x;
|
|
801
|
+
}
|
|
802
|
+
runEnd = x + cell.width;
|
|
803
|
+
}
|
|
804
|
+
if (runColor) {
|
|
805
|
+
this.setFill(runColor);
|
|
806
|
+
ctx.fillRect(runStart * cellW, lineY, (runEnd - runStart) * cellW, cellH);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
let x = 0;
|
|
810
|
+
while (x < line.length) {
|
|
811
|
+
const cell = line[x];
|
|
812
|
+
if (cell.width === 0 || cell.flags & CellFlags.INVISIBLE) {
|
|
813
|
+
x++;
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
const color = this.foregroundColor(cell, this.isInSelection(x, y));
|
|
817
|
+
if (isBlank(cell)) {
|
|
818
|
+
this.renderDecorations(cell, x, y, color);
|
|
819
|
+
x++;
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
if (!this.batchText || !isPlainAscii(cell)) {
|
|
823
|
+
this.renderCellText(cell, x, y, color);
|
|
824
|
+
x++;
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
const style = cell.flags & TEXT_STYLE_FLAGS;
|
|
828
|
+
let text = String.fromCharCode(cell.codepoint);
|
|
829
|
+
let end = x + 1;
|
|
830
|
+
let lastGlyph = x;
|
|
831
|
+
this.renderDecorations(cell, x, y, color);
|
|
832
|
+
while (end < line.length) {
|
|
833
|
+
const next = line[end];
|
|
834
|
+
if (next.width !== 1 || next.flags & CellFlags.INVISIBLE) break;
|
|
835
|
+
const nextColor = this.foregroundColor(next, this.isInSelection(end, y));
|
|
836
|
+
if (isBlank(next)) {
|
|
837
|
+
this.renderDecorations(next, end, y, nextColor);
|
|
838
|
+
text += ' ';
|
|
839
|
+
end++;
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
if (!isPlainAscii(next)) break;
|
|
843
|
+
if ((next.flags & TEXT_STYLE_FLAGS) !== style || nextColor !== color) break;
|
|
844
|
+
this.renderDecorations(next, end, y, nextColor);
|
|
845
|
+
text += String.fromCharCode(next.codepoint);
|
|
846
|
+
lastGlyph = end;
|
|
847
|
+
end++;
|
|
848
|
+
}
|
|
849
|
+
this.drawText(text.slice(0, lastGlyph - x + 1), x, y, style, color);
|
|
850
|
+
x = end;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
private backgroundColor(cell: GhosttyCell, x: number, y: number): string | null {
|
|
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;
|
|
859
|
+
const inverse = cell.flags & CellFlags.INVERSE;
|
|
860
|
+
const r = inverse ? cell.fg_r : cell.bg_r;
|
|
861
|
+
const g = inverse ? cell.fg_g : cell.bg_g;
|
|
862
|
+
const b = inverse ? cell.fg_b : cell.bg_b;
|
|
863
|
+
if (r === 0 && g === 0 && b === 0) return null;
|
|
864
|
+
return this.cssColor(r, g, b);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private foregroundColor(cell: GhosttyCell, selected: boolean): string {
|
|
868
|
+
if (selected) return this.theme.selectionForeground;
|
|
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);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private cssColor(r: number, g: number, b: number): string {
|
|
881
|
+
const key = (r << 16) | (g << 8) | b;
|
|
882
|
+
let color = this.colorStrings.get(key);
|
|
883
|
+
if (color === undefined) {
|
|
884
|
+
color = `rgb(${r}, ${g}, ${b})`;
|
|
885
|
+
this.colorStrings.set(key, color);
|
|
886
|
+
}
|
|
887
|
+
return color;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private setFill(color: string): void {
|
|
891
|
+
if (this.currentFill !== color) {
|
|
892
|
+
this.ctx.fillStyle = color;
|
|
893
|
+
this.currentFill = color;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
private setFont(style: number): void {
|
|
898
|
+
const font = this.fontFor(style);
|
|
899
|
+
if (this.currentFont !== font) {
|
|
900
|
+
this.ctx.font = font;
|
|
901
|
+
this.currentFont = font;
|
|
902
|
+
if (this.batchText) this.ctx.letterSpacing = `${this.metrics.width - this.advanceOf(font)}px`;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
private fontFor(style: number): string {
|
|
907
|
+
let font = this.fonts[style];
|
|
908
|
+
if (font === undefined) {
|
|
909
|
+
font = this.fontString(style, this.fontSize);
|
|
910
|
+
this.fonts[style] = font;
|
|
911
|
+
}
|
|
912
|
+
return font;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
private advanceOf(font: string): number {
|
|
916
|
+
let advance = this.advances.get(font);
|
|
917
|
+
if (advance === undefined) {
|
|
918
|
+
const spacing = this.ctx.letterSpacing;
|
|
919
|
+
this.ctx.letterSpacing = '0px';
|
|
920
|
+
advance = this.ctx.measureText('M').width;
|
|
921
|
+
this.ctx.letterSpacing = spacing;
|
|
922
|
+
this.advances.set(font, advance);
|
|
923
|
+
}
|
|
924
|
+
return advance;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
private resetContextCache(): void {
|
|
928
|
+
this.currentFill = '';
|
|
929
|
+
this.currentFont = '';
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
private drawText(text: string, x: number, y: number, style: number, color: string): void {
|
|
933
|
+
this.setFont(style);
|
|
934
|
+
this.setFill(color);
|
|
935
|
+
const faint = style & CellFlags.FAINT;
|
|
936
|
+
if (faint) this.ctx.globalAlpha = 0.5;
|
|
937
|
+
this.ctx.fillText(
|
|
938
|
+
text,
|
|
939
|
+
x * this.metrics.width,
|
|
940
|
+
y * this.metrics.height + this.metrics.baseline
|
|
941
|
+
);
|
|
942
|
+
if (faint) this.ctx.globalAlpha = 1.0;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** One cell's glyph and decorations, for cells a run cannot hold and the glyph under a block cursor. */
|
|
946
|
+
private renderCellText(cell: GhosttyCell, x: number, y: number, color: string): void {
|
|
947
|
+
if (cell.flags & CellFlags.INVISIBLE) return;
|
|
948
|
+
if (cell.grapheme_len === 0 && isBoxGlyph(cell.codepoint)) {
|
|
949
|
+
this.setFill(color);
|
|
950
|
+
const faint = cell.flags & CellFlags.FAINT;
|
|
951
|
+
if (faint) this.ctx.globalAlpha = 0.5;
|
|
952
|
+
drawBoxGlyph(
|
|
953
|
+
this.ctx,
|
|
954
|
+
cell.codepoint,
|
|
955
|
+
x * this.metrics.width,
|
|
956
|
+
y * this.metrics.height,
|
|
957
|
+
this.metrics.width * cell.width,
|
|
958
|
+
this.metrics.height
|
|
959
|
+
);
|
|
960
|
+
if (faint) this.ctx.globalAlpha = 1.0;
|
|
961
|
+
this.resetContextCache();
|
|
962
|
+
} else {
|
|
963
|
+
const spacing = this.batchText ? this.ctx.letterSpacing : '';
|
|
964
|
+
if (this.batchText) this.ctx.letterSpacing = '0px';
|
|
965
|
+
this.drawText(this.cellText(cell, x, y), x, y, cell.flags & TEXT_STYLE_FLAGS, color);
|
|
966
|
+
if (this.batchText) this.ctx.letterSpacing = spacing;
|
|
967
|
+
}
|
|
968
|
+
this.renderDecorations(cell, x, y, color);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
private renderDecorations(cell: GhosttyCell, x: number, y: number, color: string): void {
|
|
972
|
+
const underline = cell.flags & CellFlags.UNDERLINE;
|
|
973
|
+
const strike = cell.flags & CellFlags.STRIKETHROUGH;
|
|
974
|
+
const link = (cell.hyperlink_id > 0 || this.hoveredLinkRange) && this.linkHovered(cell, x, y);
|
|
975
|
+
if (!underline && !strike && !link) return;
|
|
976
|
+
const cellX = x * this.metrics.width;
|
|
977
|
+
const cellY = y * this.metrics.height;
|
|
978
|
+
const cellWidth = this.metrics.width * cell.width;
|
|
979
|
+
const underlineY = cellY + this.metrics.baseline + 2;
|
|
980
|
+
if (underline) this.strokeLine(cellX, underlineY, cellWidth, color);
|
|
981
|
+
if (strike) this.strokeLine(cellX, cellY + this.metrics.height / 2, cellWidth, color);
|
|
982
|
+
if (link) this.strokeLine(cellX, underlineY, cellWidth, LINK_COLOR);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
private strokeLine(x: number, y: number, width: number, color: string): void {
|
|
986
|
+
const ctx = this.ctx;
|
|
987
|
+
ctx.strokeStyle = color;
|
|
988
|
+
ctx.lineWidth = 1;
|
|
989
|
+
ctx.beginPath();
|
|
990
|
+
ctx.moveTo(x, y);
|
|
991
|
+
ctx.lineTo(x + width, y);
|
|
992
|
+
ctx.stroke();
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
private renderCursor(x: number, y: number, focused: boolean): void {
|
|
996
|
+
const cursorX = x * this.metrics.width;
|
|
997
|
+
const cursorY = y * this.metrics.height;
|
|
998
|
+
this.setFill(this.theme.cursor);
|
|
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') {
|
|
1009
|
+
this.ctx.fillRect(cursorX, cursorY, this.metrics.width, this.metrics.height);
|
|
1010
|
+
const line = this.currentBuffer?.getLine(y);
|
|
1011
|
+
if (line?.[x]) {
|
|
1012
|
+
this.ctx.save();
|
|
1013
|
+
this.ctx.beginPath();
|
|
1014
|
+
this.ctx.rect(cursorX, cursorY, this.metrics.width, this.metrics.height);
|
|
1015
|
+
this.ctx.clip();
|
|
1016
|
+
this.renderCellText(line[x], x, y, this.theme.cursorAccent);
|
|
1017
|
+
this.ctx.restore();
|
|
1018
|
+
this.resetContextCache();
|
|
1019
|
+
}
|
|
1020
|
+
} else if (this.cursorStyle === 'underline') {
|
|
1021
|
+
const height = Math.max(2, Math.floor(this.metrics.height * 0.15));
|
|
1022
|
+
this.ctx.fillRect(
|
|
1023
|
+
cursorX,
|
|
1024
|
+
cursorY + this.metrics.height - height,
|
|
1025
|
+
this.metrics.width,
|
|
1026
|
+
height
|
|
1027
|
+
);
|
|
1028
|
+
} else {
|
|
1029
|
+
const width = Math.max(2, Math.floor(this.metrics.width * 0.15));
|
|
1030
|
+
this.ctx.fillRect(cursorX, cursorY, width, this.metrics.height);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
private renderScrollbar(overlay: FrameOverlay): void {
|
|
1035
|
+
const ctx = this.ctx;
|
|
1036
|
+
const canvasHeight = this.canvas.height / this.devicePixelRatio;
|
|
1037
|
+
const canvasWidth = this.canvas.width / this.devicePixelRatio;
|
|
1038
|
+
const scrollbarX = canvasWidth - 8 - 4;
|
|
1039
|
+
ctx.clearRect(scrollbarX - 2, 0, 8 + 6, canvasHeight);
|
|
1040
|
+
ctx.fillStyle = this.theme.background;
|
|
1041
|
+
ctx.fillRect(scrollbarX - 2, 0, 8 + 6, canvasHeight);
|
|
1042
|
+
this.resetContextCache();
|
|
1043
|
+
const thumb = this.scrollbarThumb(overlay);
|
|
1044
|
+
if (!thumb) return;
|
|
1045
|
+
const opacity = overlay.scrollbarOpacity;
|
|
1046
|
+
ctx.fillStyle = `rgba(128, 128, 128, ${0.1 * opacity})`;
|
|
1047
|
+
ctx.fillRect(scrollbarX, thumb.trackY, thumb.width, thumb.trackHeight);
|
|
1048
|
+
const baseOpacity = overlay.viewportY > 0 ? 0.5 : 0.3;
|
|
1049
|
+
ctx.fillStyle = `rgba(128, 128, 128, ${baseOpacity * opacity})`;
|
|
1050
|
+
ctx.fillRect(scrollbarX, thumb.thumbY, thumb.width, thumb.thumbHeight);
|
|
1051
|
+
this.resetContextCache();
|
|
957
1052
|
}
|
|
958
1053
|
}
|