@aurigax-ai/ghostty-web 0.2.0 → 0.3.0

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