@aurigax-ai/ghostty-web 0.1.0 → 0.2.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 +5 -1
- package/lib/box-glyphs.ts +278 -0
- package/lib/ghostty.ts +244 -126
- package/lib/index.ts +2 -1
- package/lib/input-handler.ts +13 -1
- package/lib/interfaces.ts +6 -0
- package/lib/renderer.ts +550 -688
- package/lib/selection-manager.ts +8 -3
- package/lib/terminal.ts +58 -30
- package/lib/webgl-renderer.ts +870 -0
- package/package.json +2 -2
package/lib/renderer.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
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
|
|
|
11
|
+
import { drawBoxGlyph, isBoxGlyph } from './box-glyphs';
|
|
13
12
|
import type { ITheme } from './interfaces';
|
|
14
13
|
import type { SelectionManager } from './selection-manager';
|
|
15
14
|
import type { GhosttyCell } 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
|
// ============================================================================
|
|
@@ -87,661 +97,302 @@ export const DEFAULT_THEME: Required<ITheme> = {
|
|
|
87
97
|
brightWhite: '#ffffff',
|
|
88
98
|
};
|
|
89
99
|
|
|
100
|
+
export type CursorStyle = 'block' | 'underline' | 'bar';
|
|
101
|
+
|
|
102
|
+
export interface LinkRange {
|
|
103
|
+
startX: number;
|
|
104
|
+
startY: number;
|
|
105
|
+
endX: number;
|
|
106
|
+
endY: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** What a renderer draws over the rows once they are up to date. */
|
|
110
|
+
export interface FrameOverlay {
|
|
111
|
+
cursorX: number;
|
|
112
|
+
cursorY: number;
|
|
113
|
+
/** Cursor shown: at the bottom of the scrollback, visible, and in the blink's on phase. */
|
|
114
|
+
showCursor: boolean;
|
|
115
|
+
viewportY: number;
|
|
116
|
+
scrollbackLength: number;
|
|
117
|
+
cols: number;
|
|
118
|
+
rows: number;
|
|
119
|
+
/** Scrollbar opacity, 0 when there is no scrollback provider. */
|
|
120
|
+
scrollbarOpacity: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Measures the cell box of a font: width of 'M' and height from its ascent and descent. */
|
|
124
|
+
export function measureCell(fontSize: number, fontFamily: string): FontMetrics {
|
|
125
|
+
const canvas = document.createElement('canvas');
|
|
126
|
+
const ctx = canvas.getContext('2d')!;
|
|
127
|
+
ctx.font = `${fontSize}px ${fontFamily}`;
|
|
128
|
+
const widthMetrics = ctx.measureText('M');
|
|
129
|
+
const width = Math.ceil(widthMetrics.width);
|
|
130
|
+
const ascent = widthMetrics.actualBoundingBoxAscent || fontSize * 0.8;
|
|
131
|
+
const descent = widthMetrics.actualBoundingBoxDescent || fontSize * 0.2;
|
|
132
|
+
const height = Math.ceil(ascent + descent) + 2;
|
|
133
|
+
const baseline = Math.ceil(ascent) + 1;
|
|
134
|
+
return { width, height, baseline };
|
|
135
|
+
}
|
|
136
|
+
|
|
90
137
|
// ============================================================================
|
|
91
|
-
//
|
|
138
|
+
// TerminalRenderer: what to draw each frame
|
|
92
139
|
// ============================================================================
|
|
93
140
|
|
|
94
|
-
export class
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
// Cursor blinking state
|
|
106
|
-
private cursorVisible: boolean = true;
|
|
141
|
+
export abstract class TerminalRenderer {
|
|
142
|
+
protected readonly canvas: HTMLCanvasElement;
|
|
143
|
+
protected fontSize: number;
|
|
144
|
+
protected fontFamily: string;
|
|
145
|
+
protected cursorStyle: CursorStyle;
|
|
146
|
+
protected cursorBlink: boolean;
|
|
147
|
+
protected theme: Required<ITheme>;
|
|
148
|
+
protected devicePixelRatio: number;
|
|
149
|
+
protected metrics: FontMetrics;
|
|
150
|
+
|
|
151
|
+
protected cursorVisible = true;
|
|
107
152
|
private cursorBlinkInterval?: number;
|
|
108
153
|
|
|
109
154
|
/** Called when the renderer needs another frame (cursor blink), so an idle render loop wakes. */
|
|
110
155
|
public onNeedsFrame?: () => void;
|
|
111
|
-
|
|
156
|
+
protected lastCursorPosition: { x: number; y: number } = { x: 0, y: 0 };
|
|
157
|
+
private lastViewportY = 0;
|
|
158
|
+
private fullRedrawPending = false;
|
|
112
159
|
|
|
113
|
-
|
|
114
|
-
|
|
160
|
+
/** The buffer of the frame being drawn, for grapheme lookups. */
|
|
161
|
+
protected currentBuffer: IRenderable | null = null;
|
|
115
162
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
// Selection manager (for rendering selection)
|
|
120
|
-
private selectionManager?: SelectionManager;
|
|
121
|
-
// Cached selection coordinates for current render pass (viewport-relative)
|
|
122
|
-
private currentSelectionCoords: {
|
|
163
|
+
protected selectionManager?: SelectionManager;
|
|
164
|
+
/** Selection of the frame being drawn, viewport-relative. */
|
|
165
|
+
protected currentSelectionCoords: {
|
|
123
166
|
startCol: number;
|
|
124
167
|
startRow: number;
|
|
125
168
|
endCol: number;
|
|
126
169
|
endRow: number;
|
|
127
170
|
} | null = null;
|
|
128
171
|
|
|
129
|
-
|
|
130
|
-
private
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
private previousHoveredLinkRange: {
|
|
137
|
-
startX: number;
|
|
138
|
-
startY: number;
|
|
139
|
-
endX: number;
|
|
140
|
-
endY: number;
|
|
141
|
-
} | null = null;
|
|
172
|
+
protected hoveredHyperlinkId = 0;
|
|
173
|
+
private previousHoveredHyperlinkId = 0;
|
|
174
|
+
protected hoveredLinkRange: LinkRange | null = null;
|
|
175
|
+
private previousHoveredLinkRange: LinkRange | null = null;
|
|
176
|
+
|
|
177
|
+
/** Whether a moved or blinking cursor needs its row redrawn (false when the cursor is an overlay). */
|
|
178
|
+
protected abstract readonly cursorInRows: boolean;
|
|
142
179
|
|
|
143
180
|
constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) {
|
|
144
181
|
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
182
|
this.fontSize = options.fontSize ?? 15;
|
|
153
183
|
this.fontFamily = options.fontFamily ?? 'monospace';
|
|
154
184
|
this.cursorStyle = options.cursorStyle ?? 'block';
|
|
155
185
|
this.cursorBlink = options.cursorBlink ?? false;
|
|
156
186
|
this.theme = { ...DEFAULT_THEME, ...options.theme };
|
|
157
187
|
this.devicePixelRatio = options.devicePixelRatio ?? window.devicePixelRatio ?? 1;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
this.metrics = this.measureFont();
|
|
161
|
-
|
|
162
|
-
// Setup cursor blinking if enabled
|
|
163
|
-
if (this.cursorBlink) {
|
|
164
|
-
this.startCursorBlink();
|
|
165
|
-
}
|
|
188
|
+
this.metrics = measureCell(this.fontSize, this.fontFamily);
|
|
189
|
+
if (this.cursorBlink) this.startCursorBlink();
|
|
166
190
|
}
|
|
167
191
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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;
|
|
192
|
+
/** Draws one viewport row. */
|
|
193
|
+
protected abstract drawLine(line: GhosttyCell[], y: number, cols: number): void;
|
|
194
|
+
/** Whether the drawing surface no longer matches cols × rows. */
|
|
195
|
+
protected abstract surfaceMismatch(cols: number, rows: number): boolean;
|
|
196
|
+
/** Draws the cursor and scrollbar and presents the frame. */
|
|
197
|
+
protected abstract finishFrame(overlay: FrameOverlay): void;
|
|
198
|
+
/** Resize the drawing surface to cols × rows cells. */
|
|
199
|
+
public abstract resize(cols: number, rows: number): void;
|
|
200
|
+
/** Clear the whole surface to the theme background. */
|
|
201
|
+
public abstract clear(): void;
|
|
202
|
+
/** Called after the font or its metrics changed. */
|
|
203
|
+
protected abstract fontChanged(): void;
|
|
187
204
|
|
|
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 };
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Remeasure font metrics (call after font loads or changes)
|
|
198
|
-
*/
|
|
199
|
-
public remeasureFont(): void {
|
|
200
|
-
this.metrics = this.measureFont();
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// ==========================================================================
|
|
204
|
-
// Color Conversion
|
|
205
|
-
// ==========================================================================
|
|
206
|
-
|
|
207
|
-
private rgbToCSS(r: number, g: number, b: number): string {
|
|
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);
|
|
232
|
-
|
|
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
205
|
public render(
|
|
250
206
|
buffer: IRenderable,
|
|
251
|
-
forceAll
|
|
252
|
-
viewportY
|
|
207
|
+
forceAll = false,
|
|
208
|
+
viewportY = 0,
|
|
253
209
|
scrollbackProvider?: IScrollbackProvider,
|
|
254
|
-
scrollbarOpacity
|
|
210
|
+
scrollbarOpacity = 1
|
|
255
211
|
): void {
|
|
256
|
-
// Store buffer reference for grapheme lookups in renderCell
|
|
257
212
|
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
213
|
const cursor = buffer.getCursor();
|
|
262
214
|
const dims = buffer.getDimensions();
|
|
263
215
|
const scrollbackLength = scrollbackProvider ? scrollbackProvider.getScrollbackLength() : 0;
|
|
264
216
|
|
|
265
|
-
|
|
266
|
-
if (
|
|
217
|
+
if (buffer.needsFullRedraw?.()) forceAll = true;
|
|
218
|
+
if (this.fullRedrawPending) {
|
|
267
219
|
forceAll = true;
|
|
220
|
+
this.fullRedrawPending = false;
|
|
268
221
|
}
|
|
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) {
|
|
222
|
+
if (this.surfaceMismatch(dims.cols, dims.rows)) {
|
|
276
223
|
this.resize(dims.cols, dims.rows);
|
|
277
|
-
forceAll = true;
|
|
224
|
+
forceAll = true;
|
|
278
225
|
}
|
|
279
|
-
|
|
280
|
-
// Force re-render when viewport changes (scrolling)
|
|
281
226
|
if (viewportY !== this.lastViewportY) {
|
|
282
227
|
forceAll = true;
|
|
283
228
|
this.lastViewportY = viewportY;
|
|
284
229
|
}
|
|
285
230
|
|
|
286
|
-
// Check if cursor position changed or if blinking (need to redraw cursor line)
|
|
287
231
|
const cursorMoved =
|
|
288
232
|
cursor.x !== this.lastCursorPosition.x || cursor.y !== this.lastCursorPosition.y;
|
|
289
|
-
if (cursorMoved || this.cursorBlink) {
|
|
290
|
-
// Mark cursor lines as needing redraw
|
|
233
|
+
if (this.cursorInRows && (cursorMoved || this.cursorBlink)) {
|
|
291
234
|
if (!forceAll && !buffer.isRowDirty(cursor.y)) {
|
|
292
|
-
// Need to redraw cursor line
|
|
293
235
|
const line = buffer.getLine(cursor.y);
|
|
294
|
-
if (line)
|
|
295
|
-
this.renderLine(line, cursor.y, dims.cols);
|
|
296
|
-
}
|
|
236
|
+
if (line) this.drawLine(line, cursor.y, dims.cols);
|
|
297
237
|
}
|
|
298
238
|
if (cursorMoved && this.lastCursorPosition.y !== cursor.y) {
|
|
299
|
-
// Also redraw old cursor line if cursor moved to different line
|
|
300
239
|
if (!forceAll && !buffer.isRowDirty(this.lastCursorPosition.y)) {
|
|
301
240
|
const line = buffer.getLine(this.lastCursorPosition.y);
|
|
302
|
-
if (line)
|
|
303
|
-
this.renderLine(line, this.lastCursorPosition.y, dims.cols);
|
|
304
|
-
}
|
|
241
|
+
if (line) this.drawLine(line, this.lastCursorPosition.y, dims.cols);
|
|
305
242
|
}
|
|
306
243
|
}
|
|
307
244
|
}
|
|
308
245
|
|
|
309
|
-
// Check if we need to redraw selection-related lines
|
|
310
|
-
const hasSelection = this.selectionManager && this.selectionManager.hasSelection();
|
|
311
246
|
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
|
|
247
|
+
const hasSelection = this.selectionManager?.hasSelection();
|
|
315
248
|
this.currentSelectionCoords = hasSelection ? this.selectionManager!.getSelectionCoords() : null;
|
|
316
|
-
|
|
317
|
-
// Mark current selection rows for redraw (includes programmatic selections)
|
|
318
249
|
if (this.currentSelectionCoords) {
|
|
319
250
|
const coords = this.currentSelectionCoords;
|
|
320
|
-
for (let row = coords.startRow; row <= coords.endRow; row++)
|
|
321
|
-
selectionRows.add(row);
|
|
322
|
-
}
|
|
251
|
+
for (let row = coords.startRow; row <= coords.endRow; row++) selectionRows.add(row);
|
|
323
252
|
}
|
|
324
|
-
|
|
325
|
-
// Always mark dirty selection rows for redraw (to clear old overlay)
|
|
326
253
|
if (this.selectionManager) {
|
|
327
254
|
const dirtyRows = this.selectionManager.getDirtySelectionRows();
|
|
328
255
|
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
|
|
256
|
+
for (const row of dirtyRows) selectionRows.add(row);
|
|
333
257
|
this.selectionManager.clearDirtySelectionRows();
|
|
334
258
|
}
|
|
335
259
|
}
|
|
336
260
|
|
|
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);
|
|
261
|
+
const lineAt = (y: number): GhosttyCell[] | null => {
|
|
262
|
+
if (viewportY > 0) {
|
|
263
|
+
if (y < viewportY && scrollbackProvider) {
|
|
264
|
+
return scrollbackProvider.getScrollbackLine(scrollbackLength - Math.floor(viewportY) + y);
|
|
364
265
|
}
|
|
266
|
+
return buffer.getLine(y - Math.floor(viewportY));
|
|
267
|
+
}
|
|
268
|
+
return buffer.getLine(y);
|
|
269
|
+
};
|
|
365
270
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
271
|
+
const hyperlinkRows = new Set<number>();
|
|
272
|
+
if (this.hoveredHyperlinkId !== this.previousHoveredHyperlinkId) {
|
|
273
|
+
for (let y = 0; y < dims.rows; y++) {
|
|
274
|
+
const line = lineAt(y);
|
|
275
|
+
if (!line) continue;
|
|
276
|
+
for (const cell of line) {
|
|
277
|
+
if (
|
|
278
|
+
cell.hyperlink_id === this.hoveredHyperlinkId ||
|
|
279
|
+
cell.hyperlink_id === this.previousHoveredHyperlinkId
|
|
280
|
+
) {
|
|
281
|
+
hyperlinkRows.add(y);
|
|
282
|
+
break;
|
|
375
283
|
}
|
|
376
284
|
}
|
|
377
285
|
}
|
|
378
|
-
// Update previous state
|
|
379
286
|
this.previousHoveredHyperlinkId = this.hoveredHyperlinkId;
|
|
380
287
|
}
|
|
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
|
-
}
|
|
288
|
+
if (!sameRange(this.hoveredLinkRange, this.previousHoveredLinkRange)) {
|
|
289
|
+
for (const range of [this.previousHoveredLinkRange, this.hoveredLinkRange]) {
|
|
290
|
+
if (!range) continue;
|
|
291
|
+
for (let y = range.startY; y <= range.endY; y++) hyperlinkRows.add(y);
|
|
399
292
|
}
|
|
400
293
|
this.previousHoveredLinkRange = this.hoveredLinkRange;
|
|
401
294
|
}
|
|
402
295
|
|
|
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
296
|
const rowsToRender = new Set<number>();
|
|
408
297
|
for (let y = 0; y < dims.rows; y++) {
|
|
409
|
-
// When scrolled, always force render all lines since we're showing scrollback
|
|
410
298
|
const needsRender =
|
|
411
|
-
viewportY > 0
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
299
|
+
viewportY > 0 ||
|
|
300
|
+
forceAll ||
|
|
301
|
+
buffer.isRowDirty(y) ||
|
|
302
|
+
selectionRows.has(y) ||
|
|
303
|
+
hyperlinkRows.has(y);
|
|
415
304
|
if (needsRender) {
|
|
416
305
|
rowsToRender.add(y);
|
|
417
|
-
// Include adjacent rows to handle glyph overflow
|
|
418
306
|
if (y > 0) rowsToRender.add(y - 1);
|
|
419
307
|
if (y < dims.rows - 1) rowsToRender.add(y + 1);
|
|
420
308
|
}
|
|
421
309
|
}
|
|
422
|
-
|
|
423
|
-
// Render each line
|
|
424
310
|
for (let y = 0; y < dims.rows; y++) {
|
|
425
|
-
if (!rowsToRender.has(y))
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// Selection highlighting is now integrated into renderCellBackground/renderCellText
|
|
459
|
-
// No separate overlay pass needed - this fixes z-order issues with complex glyphs
|
|
460
|
-
|
|
461
|
-
// Link underlines are drawn during cell rendering (see renderCell)
|
|
462
|
-
|
|
463
|
-
// Render cursor (only if we're at the bottom, not scrolled)
|
|
464
|
-
if (viewportY === 0 && cursor.visible && this.cursorVisible) {
|
|
465
|
-
this.renderCursor(cursor.x, cursor.y);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// Render scrollbar if scrolled or scrollback exists (with opacity for fade effect)
|
|
469
|
-
if (scrollbackProvider && scrollbarOpacity > 0) {
|
|
470
|
-
this.renderScrollbar(viewportY, scrollbackLength, dims.rows, scrollbarOpacity);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
// Update last cursor position
|
|
311
|
+
if (!rowsToRender.has(y)) continue;
|
|
312
|
+
const line = lineAt(y);
|
|
313
|
+
if (line) this.drawLine(line, y, dims.cols);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
this.finishFrame({
|
|
317
|
+
cursorX: cursor.x,
|
|
318
|
+
cursorY: cursor.y,
|
|
319
|
+
showCursor: viewportY === 0 && cursor.visible && this.cursorVisible,
|
|
320
|
+
viewportY,
|
|
321
|
+
scrollbackLength,
|
|
322
|
+
cols: dims.cols,
|
|
323
|
+
rows: dims.rows,
|
|
324
|
+
scrollbarOpacity: scrollbackProvider ? scrollbarOpacity : 0,
|
|
325
|
+
});
|
|
474
326
|
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
327
|
buffer.clearDirty();
|
|
480
328
|
}
|
|
481
329
|
|
|
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
|
-
}
|
|
330
|
+
// ==========================================================================
|
|
331
|
+
// Shared helpers
|
|
332
|
+
// ==========================================================================
|
|
555
333
|
|
|
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
|
-
}
|
|
334
|
+
/** Makes the next frame redraw every row. */
|
|
335
|
+
protected requestFullRedraw(): void {
|
|
336
|
+
this.fullRedrawPending = true;
|
|
563
337
|
}
|
|
564
338
|
|
|
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;
|
|
339
|
+
/** The text of a cell: its grapheme cluster when it has one, else its codepoint. */
|
|
340
|
+
protected cellText(cell: GhosttyCell, x: number, y: number): string {
|
|
620
341
|
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;
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// Draw underline
|
|
635
|
-
if (cell.flags & CellFlags.UNDERLINE) {
|
|
636
|
-
const underlineY = cellY + this.metrics.baseline + 2;
|
|
637
|
-
this.ctx.strokeStyle = this.ctx.fillStyle;
|
|
638
|
-
this.ctx.lineWidth = 1;
|
|
639
|
-
this.ctx.beginPath();
|
|
640
|
-
this.ctx.moveTo(cellX, underlineY);
|
|
641
|
-
this.ctx.lineTo(cellX + cellWidth, underlineY);
|
|
642
|
-
this.ctx.stroke();
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// Draw strikethrough
|
|
646
|
-
if (cell.flags & CellFlags.STRIKETHROUGH) {
|
|
647
|
-
const strikeY = cellY + this.metrics.height / 2;
|
|
648
|
-
this.ctx.strokeStyle = this.ctx.fillStyle;
|
|
649
|
-
this.ctx.lineWidth = 1;
|
|
650
|
-
this.ctx.beginPath();
|
|
651
|
-
this.ctx.moveTo(cellX, strikeY);
|
|
652
|
-
this.ctx.lineTo(cellX + cellWidth, strikeY);
|
|
653
|
-
this.ctx.stroke();
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// Draw hyperlink underline (for OSC8 hyperlinks)
|
|
657
|
-
if (cell.hyperlink_id > 0) {
|
|
658
|
-
const isHovered = cell.hyperlink_id === this.hoveredHyperlinkId;
|
|
659
|
-
|
|
660
|
-
// Only show underline when hovered (cleaner look)
|
|
661
|
-
if (isHovered) {
|
|
662
|
-
const underlineY = cellY + this.metrics.baseline + 2;
|
|
663
|
-
this.ctx.strokeStyle = '#4A90E2'; // Blue underline on hover
|
|
664
|
-
this.ctx.lineWidth = 1;
|
|
665
|
-
this.ctx.beginPath();
|
|
666
|
-
this.ctx.moveTo(cellX, underlineY);
|
|
667
|
-
this.ctx.lineTo(cellX + cellWidth, underlineY);
|
|
668
|
-
this.ctx.stroke();
|
|
669
|
-
}
|
|
342
|
+
return this.currentBuffer.getGraphemeString(y, x);
|
|
670
343
|
}
|
|
344
|
+
return String.fromCodePoint(cell.codepoint || 32);
|
|
345
|
+
}
|
|
671
346
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
}
|
|
690
|
-
}
|
|
347
|
+
protected isInSelection(x: number, y: number): boolean {
|
|
348
|
+
const sel = this.currentSelectionCoords;
|
|
349
|
+
if (!sel) return false;
|
|
350
|
+
const { startCol, startRow, endCol, endRow } = sel;
|
|
351
|
+
if (startRow === endRow) return y === startRow && x >= startCol && x <= endCol;
|
|
352
|
+
if (y === startRow) return x >= startCol;
|
|
353
|
+
if (y === endRow) return x <= endCol;
|
|
354
|
+
return y > startRow && y < endRow;
|
|
691
355
|
}
|
|
692
356
|
|
|
693
|
-
/**
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
357
|
+
/** Whether the cell has a hovered link under it (OSC 8 id or a detected URL range). */
|
|
358
|
+
protected linkHovered(cell: GhosttyCell, x: number, y: number): boolean {
|
|
359
|
+
if (cell.hyperlink_id > 0 && cell.hyperlink_id === this.hoveredHyperlinkId) return true;
|
|
360
|
+
const range = this.hoveredLinkRange;
|
|
361
|
+
if (!range) return false;
|
|
362
|
+
return (
|
|
363
|
+
(y === range.startY && x >= range.startX && (y < range.endY || x <= range.endX)) ||
|
|
364
|
+
(y > range.startY && y < range.endY) ||
|
|
365
|
+
(y === range.endY && x <= range.endX && (y > range.startY || x >= range.startX))
|
|
366
|
+
);
|
|
367
|
+
}
|
|
699
368
|
|
|
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
|
-
}
|
|
369
|
+
/** Scrollbar geometry in CSS pixels, or null when there is nothing to draw. */
|
|
370
|
+
protected scrollbarThumb(overlay: FrameOverlay): {
|
|
371
|
+
x: number;
|
|
372
|
+
trackY: number;
|
|
373
|
+
trackHeight: number;
|
|
374
|
+
thumbY: number;
|
|
375
|
+
thumbHeight: number;
|
|
376
|
+
width: number;
|
|
377
|
+
} | null {
|
|
378
|
+
if (overlay.scrollbarOpacity <= 0 || overlay.scrollbackLength === 0) return null;
|
|
379
|
+
const height = overlay.rows * this.metrics.height;
|
|
380
|
+
const width = 8;
|
|
381
|
+
const x = overlay.cols * this.metrics.width - width - 4;
|
|
382
|
+
const padding = 4;
|
|
383
|
+
const trackHeight = height - padding * 2;
|
|
384
|
+
const total = overlay.scrollbackLength + overlay.rows;
|
|
385
|
+
const thumbHeight = Math.max(20, (overlay.rows / total) * trackHeight);
|
|
386
|
+
const position = overlay.viewportY / overlay.scrollbackLength;
|
|
387
|
+
const thumbY = padding + (trackHeight - thumbHeight) * (1 - position);
|
|
388
|
+
return { x, trackY: padding, trackHeight, thumbY, thumbHeight, width };
|
|
737
389
|
}
|
|
738
390
|
|
|
739
391
|
// ==========================================================================
|
|
740
|
-
// Cursor
|
|
392
|
+
// Cursor blinking
|
|
741
393
|
// ==========================================================================
|
|
742
394
|
|
|
743
395
|
private startCursorBlink(): void {
|
|
744
|
-
// xterm.js uses ~530ms blink interval
|
|
745
396
|
this.cursorBlinkInterval = window.setInterval(() => {
|
|
746
397
|
this.cursorVisible = !this.cursorVisible;
|
|
747
398
|
this.onNeedsFrame?.();
|
|
@@ -760,39 +411,29 @@ export class CanvasRenderer {
|
|
|
760
411
|
// Public API
|
|
761
412
|
// ==========================================================================
|
|
762
413
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
414
|
+
public remeasureFont(): void {
|
|
415
|
+
this.metrics = measureCell(this.fontSize, this.fontFamily);
|
|
416
|
+
this.fontChanged();
|
|
417
|
+
}
|
|
418
|
+
|
|
766
419
|
public setTheme(theme: ITheme): void {
|
|
767
420
|
this.theme = { ...DEFAULT_THEME, ...theme };
|
|
768
421
|
}
|
|
769
422
|
|
|
770
|
-
/**
|
|
771
|
-
* Update font size
|
|
772
|
-
*/
|
|
773
423
|
public setFontSize(size: number): void {
|
|
774
424
|
this.fontSize = size;
|
|
775
|
-
this.
|
|
425
|
+
this.remeasureFont();
|
|
776
426
|
}
|
|
777
427
|
|
|
778
|
-
/**
|
|
779
|
-
* Update font family
|
|
780
|
-
*/
|
|
781
428
|
public setFontFamily(family: string): void {
|
|
782
429
|
this.fontFamily = family;
|
|
783
|
-
this.
|
|
430
|
+
this.remeasureFont();
|
|
784
431
|
}
|
|
785
432
|
|
|
786
|
-
|
|
787
|
-
* Update cursor style
|
|
788
|
-
*/
|
|
789
|
-
public setCursorStyle(style: 'block' | 'underline' | 'bar'): void {
|
|
433
|
+
public setCursorStyle(style: CursorStyle): void {
|
|
790
434
|
this.cursorStyle = style;
|
|
791
435
|
}
|
|
792
436
|
|
|
793
|
-
/**
|
|
794
|
-
* Enable/disable cursor blinking
|
|
795
|
-
*/
|
|
796
437
|
public setCursorBlink(enabled: boolean): void {
|
|
797
438
|
if (enabled && !this.cursorBlink) {
|
|
798
439
|
this.cursorBlink = true;
|
|
@@ -803,156 +444,377 @@ export class CanvasRenderer {
|
|
|
803
444
|
}
|
|
804
445
|
}
|
|
805
446
|
|
|
806
|
-
/**
|
|
807
|
-
* Get current font metrics
|
|
808
|
-
*/
|
|
809
|
-
|
|
810
|
-
/**
|
|
811
|
-
* Render scrollbar (Phase 2)
|
|
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);
|
|
856
|
-
}
|
|
857
447
|
public getMetrics(): FontMetrics {
|
|
858
448
|
return { ...this.metrics };
|
|
859
449
|
}
|
|
860
450
|
|
|
861
|
-
/**
|
|
862
|
-
* Get canvas element (needed by SelectionManager)
|
|
863
|
-
*/
|
|
451
|
+
/** The canvas that takes pointer events and defines the terminal's box. */
|
|
864
452
|
public getCanvas(): HTMLCanvasElement {
|
|
865
453
|
return this.canvas;
|
|
866
454
|
}
|
|
867
455
|
|
|
868
|
-
/**
|
|
869
|
-
* Set selection manager (for rendering selection)
|
|
870
|
-
*/
|
|
871
456
|
public setSelectionManager(manager: SelectionManager): void {
|
|
872
457
|
this.selectionManager = manager;
|
|
873
458
|
}
|
|
874
459
|
|
|
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
460
|
public setHoveredHyperlinkId(hyperlinkId: number): void {
|
|
909
461
|
this.hoveredHyperlinkId = hyperlinkId;
|
|
910
462
|
}
|
|
911
463
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
startX: number;
|
|
919
|
-
startY: number;
|
|
920
|
-
endX: number;
|
|
921
|
-
endY: number;
|
|
922
|
-
} | null
|
|
923
|
-
): void {
|
|
464
|
+
public getHoveredHyperlinkId(): number {
|
|
465
|
+
return this.hoveredHyperlinkId;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Set the hovered range of a detected URL, or null to clear it. */
|
|
469
|
+
public setHoveredLinkRange(range: LinkRange | null): void {
|
|
924
470
|
this.hoveredLinkRange = range;
|
|
925
471
|
}
|
|
926
472
|
|
|
927
|
-
/**
|
|
928
|
-
* Get character cell width (for coordinate conversion)
|
|
929
|
-
*/
|
|
930
473
|
public get charWidth(): number {
|
|
931
474
|
return this.metrics.width;
|
|
932
475
|
}
|
|
933
476
|
|
|
934
|
-
/**
|
|
935
|
-
* Get character cell height (for coordinate conversion)
|
|
936
|
-
*/
|
|
937
477
|
public get charHeight(): number {
|
|
938
478
|
return this.metrics.height;
|
|
939
479
|
}
|
|
940
480
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
481
|
+
public dispose(): void {
|
|
482
|
+
this.stopCursorBlink();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function sameRange(a: LinkRange | null, b: LinkRange | null): boolean {
|
|
487
|
+
if (a === b) return true;
|
|
488
|
+
if (!a || !b) return false;
|
|
489
|
+
return a.startX === b.startX && a.startY === b.startY && a.endX === b.endX && a.endY === b.endY;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ============================================================================
|
|
493
|
+
// CanvasRenderer
|
|
494
|
+
// ============================================================================
|
|
495
|
+
|
|
496
|
+
export class CanvasRenderer extends TerminalRenderer {
|
|
497
|
+
protected readonly cursorInRows = true;
|
|
498
|
+
private readonly ctx: CanvasRenderingContext2D;
|
|
499
|
+
private readonly batchText: boolean;
|
|
500
|
+
private readonly colorStrings = new Map<number, string>();
|
|
501
|
+
private fonts: string[] = [];
|
|
502
|
+
private readonly advances = new Map<string, number>();
|
|
503
|
+
private currentFill = '';
|
|
504
|
+
private currentFont = '';
|
|
505
|
+
|
|
506
|
+
constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) {
|
|
507
|
+
super(canvas, options);
|
|
508
|
+
const ctx = canvas.getContext('2d', { alpha: true });
|
|
509
|
+
if (!ctx) throw new Error('Failed to get 2D rendering context');
|
|
510
|
+
this.ctx = ctx;
|
|
511
|
+
this.batchText = typeof ctx.letterSpacing === 'string';
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
protected surfaceMismatch(cols: number, rows: number): boolean {
|
|
515
|
+
return (
|
|
516
|
+
this.canvas.width !== cols * this.metrics.width * this.devicePixelRatio ||
|
|
517
|
+
this.canvas.height !== rows * this.metrics.height * this.devicePixelRatio
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
public resize(cols: number, rows: number): void {
|
|
522
|
+
const cssWidth = cols * this.metrics.width;
|
|
523
|
+
const cssHeight = rows * this.metrics.height;
|
|
524
|
+
this.canvas.style.width = `${cssWidth}px`;
|
|
525
|
+
this.canvas.style.height = `${cssHeight}px`;
|
|
526
|
+
this.canvas.width = cssWidth * this.devicePixelRatio;
|
|
527
|
+
this.canvas.height = cssHeight * this.devicePixelRatio;
|
|
528
|
+
this.resetContextCache();
|
|
529
|
+
this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio);
|
|
530
|
+
this.ctx.textBaseline = 'alphabetic';
|
|
531
|
+
this.ctx.textAlign = 'left';
|
|
532
|
+
this.ctx.fillStyle = this.theme.background;
|
|
533
|
+
this.ctx.fillRect(0, 0, cssWidth, cssHeight);
|
|
534
|
+
}
|
|
535
|
+
|
|
944
536
|
public clear(): void {
|
|
945
|
-
// clearRect first because fillRect composites rather than replaces,
|
|
946
|
-
// so transparent/translucent backgrounds wouldn't clear previous content.
|
|
947
537
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
948
538
|
this.ctx.fillStyle = this.theme.background;
|
|
949
539
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
|
540
|
+
this.resetContextCache();
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
protected fontChanged(): void {
|
|
544
|
+
this.fonts = [];
|
|
545
|
+
this.advances.clear();
|
|
546
|
+
this.currentFont = '';
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
protected finishFrame(overlay: FrameOverlay): void {
|
|
550
|
+
if (overlay.showCursor) this.renderCursor(overlay.cursorX, overlay.cursorY);
|
|
551
|
+
if (overlay.scrollbarOpacity > 0) this.renderScrollbar(overlay);
|
|
950
552
|
}
|
|
951
553
|
|
|
952
554
|
/**
|
|
953
|
-
*
|
|
555
|
+
* Draws a row in two passes, backgrounds then text, so glyphs that extend
|
|
556
|
+
* past their cell are not covered by a neighbour's background. Backgrounds
|
|
557
|
+
* of one color are one rect; runs of plain ASCII in one style are one
|
|
558
|
+
* fillText, spaced to the cell grid with letterSpacing.
|
|
954
559
|
*/
|
|
955
|
-
|
|
956
|
-
this.
|
|
560
|
+
protected drawLine(line: GhosttyCell[], y: number, cols: number): void {
|
|
561
|
+
const ctx = this.ctx;
|
|
562
|
+
const cellW = this.metrics.width;
|
|
563
|
+
const cellH = this.metrics.height;
|
|
564
|
+
const lineY = y * cellH;
|
|
565
|
+
|
|
566
|
+
ctx.clearRect(0, lineY, cols * cellW, cellH);
|
|
567
|
+
this.setFill(this.theme.background);
|
|
568
|
+
ctx.fillRect(0, lineY, cols * cellW, cellH);
|
|
569
|
+
|
|
570
|
+
let runStart = 0;
|
|
571
|
+
let runEnd = 0;
|
|
572
|
+
let runColor: string | null = null;
|
|
573
|
+
for (let x = 0; x < line.length; x++) {
|
|
574
|
+
const cell = line[x];
|
|
575
|
+
if (cell.width === 0) continue;
|
|
576
|
+
const color = this.backgroundColor(cell, x, y);
|
|
577
|
+
if (color !== runColor) {
|
|
578
|
+
if (runColor) {
|
|
579
|
+
this.setFill(runColor);
|
|
580
|
+
ctx.fillRect(runStart * cellW, lineY, (runEnd - runStart) * cellW, cellH);
|
|
581
|
+
}
|
|
582
|
+
runColor = color;
|
|
583
|
+
runStart = x;
|
|
584
|
+
}
|
|
585
|
+
runEnd = x + cell.width;
|
|
586
|
+
}
|
|
587
|
+
if (runColor) {
|
|
588
|
+
this.setFill(runColor);
|
|
589
|
+
ctx.fillRect(runStart * cellW, lineY, (runEnd - runStart) * cellW, cellH);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
let x = 0;
|
|
593
|
+
while (x < line.length) {
|
|
594
|
+
const cell = line[x];
|
|
595
|
+
if (cell.width === 0 || cell.flags & CellFlags.INVISIBLE) {
|
|
596
|
+
x++;
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
const color = this.foregroundColor(cell, this.isInSelection(x, y));
|
|
600
|
+
if (isBlank(cell)) {
|
|
601
|
+
this.renderDecorations(cell, x, y, color);
|
|
602
|
+
x++;
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
if (!this.batchText || !isPlainAscii(cell)) {
|
|
606
|
+
this.renderCellText(cell, x, y, color);
|
|
607
|
+
x++;
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const style = cell.flags & TEXT_STYLE_FLAGS;
|
|
611
|
+
let text = String.fromCharCode(cell.codepoint);
|
|
612
|
+
let end = x + 1;
|
|
613
|
+
let lastGlyph = x;
|
|
614
|
+
this.renderDecorations(cell, x, y, color);
|
|
615
|
+
while (end < line.length) {
|
|
616
|
+
const next = line[end];
|
|
617
|
+
if (next.width !== 1 || next.flags & CellFlags.INVISIBLE) break;
|
|
618
|
+
const nextColor = this.foregroundColor(next, this.isInSelection(end, y));
|
|
619
|
+
if (isBlank(next)) {
|
|
620
|
+
this.renderDecorations(next, end, y, nextColor);
|
|
621
|
+
text += ' ';
|
|
622
|
+
end++;
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
if (!isPlainAscii(next)) break;
|
|
626
|
+
if ((next.flags & TEXT_STYLE_FLAGS) !== style || nextColor !== color) break;
|
|
627
|
+
this.renderDecorations(next, end, y, nextColor);
|
|
628
|
+
text += String.fromCharCode(next.codepoint);
|
|
629
|
+
lastGlyph = end;
|
|
630
|
+
end++;
|
|
631
|
+
}
|
|
632
|
+
this.drawText(text.slice(0, lastGlyph - x + 1), x, y, style, color);
|
|
633
|
+
x = end;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private backgroundColor(cell: GhosttyCell, x: number, y: number): string | null {
|
|
638
|
+
if (this.isInSelection(x, y)) return this.theme.selectionBackground;
|
|
639
|
+
const inverse = cell.flags & CellFlags.INVERSE;
|
|
640
|
+
const r = inverse ? cell.fg_r : cell.bg_r;
|
|
641
|
+
const g = inverse ? cell.fg_g : cell.bg_g;
|
|
642
|
+
const b = inverse ? cell.fg_b : cell.bg_b;
|
|
643
|
+
if (r === 0 && g === 0 && b === 0) return null;
|
|
644
|
+
return this.cssColor(r, g, b);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private foregroundColor(cell: GhosttyCell, selected: boolean): string {
|
|
648
|
+
if (selected) return this.theme.selectionForeground;
|
|
649
|
+
if (cell.flags & CellFlags.INVERSE) return this.cssColor(cell.bg_r, cell.bg_g, cell.bg_b);
|
|
650
|
+
return this.cssColor(cell.fg_r, cell.fg_g, cell.fg_b);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private cssColor(r: number, g: number, b: number): string {
|
|
654
|
+
const key = (r << 16) | (g << 8) | b;
|
|
655
|
+
let color = this.colorStrings.get(key);
|
|
656
|
+
if (color === undefined) {
|
|
657
|
+
color = `rgb(${r}, ${g}, ${b})`;
|
|
658
|
+
this.colorStrings.set(key, color);
|
|
659
|
+
}
|
|
660
|
+
return color;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private setFill(color: string): void {
|
|
664
|
+
if (this.currentFill !== color) {
|
|
665
|
+
this.ctx.fillStyle = color;
|
|
666
|
+
this.currentFill = color;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
private setFont(style: number): void {
|
|
671
|
+
const font = this.fontFor(style);
|
|
672
|
+
if (this.currentFont !== font) {
|
|
673
|
+
this.ctx.font = font;
|
|
674
|
+
this.currentFont = font;
|
|
675
|
+
if (this.batchText) this.ctx.letterSpacing = `${this.metrics.width - this.advanceOf(font)}px`;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
private fontFor(style: number): string {
|
|
680
|
+
let font = this.fonts[style];
|
|
681
|
+
if (font === undefined) {
|
|
682
|
+
let prefix = '';
|
|
683
|
+
if (style & CellFlags.ITALIC) prefix += 'italic ';
|
|
684
|
+
if (style & CellFlags.BOLD) prefix += 'bold ';
|
|
685
|
+
font = `${prefix}${this.fontSize}px ${this.fontFamily}`;
|
|
686
|
+
this.fonts[style] = font;
|
|
687
|
+
}
|
|
688
|
+
return font;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private advanceOf(font: string): number {
|
|
692
|
+
let advance = this.advances.get(font);
|
|
693
|
+
if (advance === undefined) {
|
|
694
|
+
const spacing = this.ctx.letterSpacing;
|
|
695
|
+
this.ctx.letterSpacing = '0px';
|
|
696
|
+
advance = this.ctx.measureText('M').width;
|
|
697
|
+
this.ctx.letterSpacing = spacing;
|
|
698
|
+
this.advances.set(font, advance);
|
|
699
|
+
}
|
|
700
|
+
return advance;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private resetContextCache(): void {
|
|
704
|
+
this.currentFill = '';
|
|
705
|
+
this.currentFont = '';
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private drawText(text: string, x: number, y: number, style: number, color: string): void {
|
|
709
|
+
this.setFont(style);
|
|
710
|
+
this.setFill(color);
|
|
711
|
+
const faint = style & CellFlags.FAINT;
|
|
712
|
+
if (faint) this.ctx.globalAlpha = 0.5;
|
|
713
|
+
this.ctx.fillText(
|
|
714
|
+
text,
|
|
715
|
+
x * this.metrics.width,
|
|
716
|
+
y * this.metrics.height + this.metrics.baseline
|
|
717
|
+
);
|
|
718
|
+
if (faint) this.ctx.globalAlpha = 1.0;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** One cell's glyph and decorations, for cells a run cannot hold and the glyph under a block cursor. */
|
|
722
|
+
private renderCellText(cell: GhosttyCell, x: number, y: number, color: string): void {
|
|
723
|
+
if (cell.flags & CellFlags.INVISIBLE) return;
|
|
724
|
+
if (cell.grapheme_len === 0 && isBoxGlyph(cell.codepoint)) {
|
|
725
|
+
this.setFill(color);
|
|
726
|
+
const faint = cell.flags & CellFlags.FAINT;
|
|
727
|
+
if (faint) this.ctx.globalAlpha = 0.5;
|
|
728
|
+
drawBoxGlyph(
|
|
729
|
+
this.ctx,
|
|
730
|
+
cell.codepoint,
|
|
731
|
+
x * this.metrics.width,
|
|
732
|
+
y * this.metrics.height,
|
|
733
|
+
this.metrics.width * cell.width,
|
|
734
|
+
this.metrics.height
|
|
735
|
+
);
|
|
736
|
+
if (faint) this.ctx.globalAlpha = 1.0;
|
|
737
|
+
this.resetContextCache();
|
|
738
|
+
} else {
|
|
739
|
+
const spacing = this.batchText ? this.ctx.letterSpacing : '';
|
|
740
|
+
if (this.batchText) this.ctx.letterSpacing = '0px';
|
|
741
|
+
this.drawText(this.cellText(cell, x, y), x, y, cell.flags & TEXT_STYLE_FLAGS, color);
|
|
742
|
+
if (this.batchText) this.ctx.letterSpacing = spacing;
|
|
743
|
+
}
|
|
744
|
+
this.renderDecorations(cell, x, y, color);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private renderDecorations(cell: GhosttyCell, x: number, y: number, color: string): void {
|
|
748
|
+
const underline = cell.flags & CellFlags.UNDERLINE;
|
|
749
|
+
const strike = cell.flags & CellFlags.STRIKETHROUGH;
|
|
750
|
+
const link = (cell.hyperlink_id > 0 || this.hoveredLinkRange) && this.linkHovered(cell, x, y);
|
|
751
|
+
if (!underline && !strike && !link) return;
|
|
752
|
+
const cellX = x * this.metrics.width;
|
|
753
|
+
const cellY = y * this.metrics.height;
|
|
754
|
+
const cellWidth = this.metrics.width * cell.width;
|
|
755
|
+
const underlineY = cellY + this.metrics.baseline + 2;
|
|
756
|
+
if (underline) this.strokeLine(cellX, underlineY, cellWidth, color);
|
|
757
|
+
if (strike) this.strokeLine(cellX, cellY + this.metrics.height / 2, cellWidth, color);
|
|
758
|
+
if (link) this.strokeLine(cellX, underlineY, cellWidth, LINK_COLOR);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
private strokeLine(x: number, y: number, width: number, color: string): void {
|
|
762
|
+
const ctx = this.ctx;
|
|
763
|
+
ctx.strokeStyle = color;
|
|
764
|
+
ctx.lineWidth = 1;
|
|
765
|
+
ctx.beginPath();
|
|
766
|
+
ctx.moveTo(x, y);
|
|
767
|
+
ctx.lineTo(x + width, y);
|
|
768
|
+
ctx.stroke();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private renderCursor(x: number, y: number): void {
|
|
772
|
+
const cursorX = x * this.metrics.width;
|
|
773
|
+
const cursorY = y * this.metrics.height;
|
|
774
|
+
this.setFill(this.theme.cursor);
|
|
775
|
+
if (this.cursorStyle === 'block') {
|
|
776
|
+
this.ctx.fillRect(cursorX, cursorY, this.metrics.width, this.metrics.height);
|
|
777
|
+
const line = this.currentBuffer?.getLine(y);
|
|
778
|
+
if (line?.[x]) {
|
|
779
|
+
this.ctx.save();
|
|
780
|
+
this.ctx.beginPath();
|
|
781
|
+
this.ctx.rect(cursorX, cursorY, this.metrics.width, this.metrics.height);
|
|
782
|
+
this.ctx.clip();
|
|
783
|
+
this.renderCellText(line[x], x, y, this.theme.cursorAccent);
|
|
784
|
+
this.ctx.restore();
|
|
785
|
+
this.resetContextCache();
|
|
786
|
+
}
|
|
787
|
+
} else if (this.cursorStyle === 'underline') {
|
|
788
|
+
const height = Math.max(2, Math.floor(this.metrics.height * 0.15));
|
|
789
|
+
this.ctx.fillRect(
|
|
790
|
+
cursorX,
|
|
791
|
+
cursorY + this.metrics.height - height,
|
|
792
|
+
this.metrics.width,
|
|
793
|
+
height
|
|
794
|
+
);
|
|
795
|
+
} else {
|
|
796
|
+
const width = Math.max(2, Math.floor(this.metrics.width * 0.15));
|
|
797
|
+
this.ctx.fillRect(cursorX, cursorY, width, this.metrics.height);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private renderScrollbar(overlay: FrameOverlay): void {
|
|
802
|
+
const ctx = this.ctx;
|
|
803
|
+
const canvasHeight = this.canvas.height / this.devicePixelRatio;
|
|
804
|
+
const canvasWidth = this.canvas.width / this.devicePixelRatio;
|
|
805
|
+
const scrollbarX = canvasWidth - 8 - 4;
|
|
806
|
+
ctx.clearRect(scrollbarX - 2, 0, 8 + 6, canvasHeight);
|
|
807
|
+
ctx.fillStyle = this.theme.background;
|
|
808
|
+
ctx.fillRect(scrollbarX - 2, 0, 8 + 6, canvasHeight);
|
|
809
|
+
this.resetContextCache();
|
|
810
|
+
const thumb = this.scrollbarThumb(overlay);
|
|
811
|
+
if (!thumb) return;
|
|
812
|
+
const opacity = overlay.scrollbarOpacity;
|
|
813
|
+
ctx.fillStyle = `rgba(128, 128, 128, ${0.1 * opacity})`;
|
|
814
|
+
ctx.fillRect(scrollbarX, thumb.trackY, thumb.width, thumb.trackHeight);
|
|
815
|
+
const baseOpacity = overlay.viewportY > 0 ? 0.5 : 0.3;
|
|
816
|
+
ctx.fillStyle = `rgba(128, 128, 128, ${baseOpacity * opacity})`;
|
|
817
|
+
ctx.fillRect(scrollbarX, thumb.thumbY, thumb.width, thumb.thumbHeight);
|
|
818
|
+
this.resetContextCache();
|
|
957
819
|
}
|
|
958
820
|
}
|