@aurigax-ai/ghostty-web 0.2.0 → 0.3.1

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 CHANGED
@@ -16,7 +16,8 @@ kept with its history. It does not track upstream. What changed:
16
16
  its host (OSC 7), notifications, clipboard writes and unknown sequences, and keep markers as
17
17
  tracked grid references.
18
18
  - **WebGL renderer.** `renderer: 'webgl'` (the default) draws with WebGL 2 from a glyph atlas,
19
- rewriting only the rows that changed; without WebGL 2, or when its context is lost, the terminal
19
+ rewriting only the rows that changed and drawing only the cells and glyph pixels that show
20
+ something; without WebGL 2, or when its context is lost, the terminal
20
21
  draws with the canvas renderer instead (`renderer: 'canvas'` asks for it). Both draw box drawing
21
22
  and block elements from geometry, so they fill their cells.
22
23
  - **pnpm and Vitest** instead of Bun; no demo server, benchmark or release automation.
@@ -0,0 +1,190 @@
1
+ /**
2
+ * SearchAddon: find in the terminal's screen and scrollback, shaped like
3
+ * xterm.js's @xterm/addon-search (findNext, findPrevious, clearDecorations,
4
+ * onDidChangeResults). Matching is libghostty-vt's: case-insensitive for ASCII
5
+ * letters. "Next" moves toward older output, as in Ghostty, and the result
6
+ * index counts from the newest match.
7
+ */
8
+
9
+ import { EventEmitter } from '../event-emitter';
10
+ import type { SearchMatch, TerminalSearch } from '../ghostty';
11
+ import type { IDisposable, IEvent, ITerminalAddon } from '../interfaces';
12
+ import type { SearchHighlight } from '../renderer';
13
+ import type { Terminal } from '../terminal';
14
+
15
+ export interface ISearchDecorationOptions {
16
+ matchBackground?: string;
17
+ activeMatchBackground?: string;
18
+ }
19
+
20
+ export interface ISearchOptions {
21
+ /** Typing into the query: keep searching from the newest match. */
22
+ incremental?: boolean;
23
+ decorations?: ISearchDecorationOptions;
24
+ }
25
+
26
+ export interface ISearchResultChangeEvent {
27
+ /** Index of the selected match, newest first, or -1 when none is selected. */
28
+ resultIndex: number;
29
+ resultCount: number;
30
+ }
31
+
32
+ const DEFAULT_MATCH = '#5c6266';
33
+ const DEFAULT_ACTIVE = '#538bb5';
34
+ /** Matches beyond this many (newest first) are counted but not highlighted. */
35
+ const HIGHLIGHT_LIMIT = 20_000;
36
+
37
+ export class SearchAddon implements ITerminalAddon {
38
+ private terminal?: Terminal;
39
+ private search: TerminalSearch | null = null;
40
+ private searchedEngine: unknown = null;
41
+ private needle = '';
42
+ private decorations: ISearchDecorationOptions = {};
43
+ private frame: number | null = null;
44
+ private subscriptions: IDisposable[] = [];
45
+ private readonly resultsEmitter = new EventEmitter<ISearchResultChangeEvent>();
46
+
47
+ readonly onDidChangeResults: IEvent<ISearchResultChangeEvent> = this.resultsEmitter.event;
48
+
49
+ activate(terminal: Terminal): void {
50
+ this.terminal = terminal;
51
+ this.subscriptions = [
52
+ terminal.onWriteParsed(() => this.scheduleRefresh()),
53
+ terminal.onResize(() => this.scheduleRefresh()),
54
+ terminal.onScroll(() => this.paint()),
55
+ ];
56
+ }
57
+
58
+ findNext(term: string, options?: ISearchOptions): boolean {
59
+ return this.find(term, options, true);
60
+ }
61
+
62
+ findPrevious(term: string, options?: ISearchOptions): boolean {
63
+ return this.find(term, options, false);
64
+ }
65
+
66
+ /** Stops searching and removes every highlight. */
67
+ clearDecorations(): void {
68
+ this.cancelRefresh();
69
+ this.needle = '';
70
+ this.search?.setNeedle('');
71
+ this.terminal?.setSearchHighlights(null);
72
+ }
73
+
74
+ private engineSearch(): TerminalSearch | null {
75
+ const engine = this.terminal?.wasmTerm;
76
+ if (!engine) return null;
77
+ if (this.search && this.searchedEngine === engine) return this.search;
78
+ this.search?.free();
79
+ this.search = engine.createSearch();
80
+ this.searchedEngine = engine;
81
+ this.needle = '';
82
+ return this.search;
83
+ }
84
+
85
+ private find(term: string, options: ISearchOptions | undefined, older: boolean): boolean {
86
+ const search = this.engineSearch();
87
+ if (!search) return false;
88
+ if (options?.decorations) this.decorations = options.decorations;
89
+ if (!term) {
90
+ this.clearDecorations();
91
+ this.emitResults();
92
+ return false;
93
+ }
94
+ const changed = term !== this.needle;
95
+ if (changed) {
96
+ this.needle = term;
97
+ search.setNeedle(term);
98
+ }
99
+ search.run();
100
+ const found = search.select(changed ? true : older);
101
+ if (found) this.reveal(search.selected());
102
+ this.paint();
103
+ this.emitResults();
104
+ return found;
105
+ }
106
+
107
+ /** Scrolls so the match is in view, centered, unless it already is. */
108
+ private reveal(match: SearchMatch | null): void {
109
+ const terminal = this.terminal;
110
+ if (!terminal || !match) return;
111
+ const scrollback = terminal.getScrollbackLength();
112
+ const top = scrollback - Math.floor(terminal.getViewportY());
113
+ if (match.startY >= top && match.endY < top + terminal.rows) return;
114
+ terminal.scrollToLine(match.startY - Math.floor(terminal.rows / 2));
115
+ }
116
+
117
+ private scheduleRefresh(): void {
118
+ if (!this.needle || this.frame !== null) return;
119
+ this.frame = requestAnimationFrame(() => {
120
+ this.frame = null;
121
+ if (!this.needle || !this.search) return;
122
+ this.search.run();
123
+ this.paint();
124
+ this.emitResults();
125
+ });
126
+ }
127
+
128
+ private cancelRefresh(): void {
129
+ if (this.frame !== null) cancelAnimationFrame(this.frame);
130
+ this.frame = null;
131
+ }
132
+
133
+ /** Highlights the matches on the rows now in view. */
134
+ private paint(): void {
135
+ const terminal = this.terminal;
136
+ const search = this.search;
137
+ if (!terminal || !search || !this.needle) return;
138
+ const cols = terminal.cols;
139
+ const rows = terminal.rows;
140
+ const top = terminal.getScrollbackLength() - Math.floor(terminal.getViewportY());
141
+ const selected = search.selected();
142
+ const highlights: SearchHighlight[] = [];
143
+ const matches = search.matches();
144
+ for (let i = 0; i < matches.length && i < HIGHLIGHT_LIMIT; i++) {
145
+ const match = matches[i];
146
+ if (match.endY < top || match.startY >= top + rows) continue;
147
+ const active =
148
+ selected !== null &&
149
+ selected.startX === match.startX &&
150
+ selected.startY === match.startY &&
151
+ selected.endX === match.endX &&
152
+ selected.endY === match.endY;
153
+ for (let y = Math.max(match.startY, top); y <= Math.min(match.endY, top + rows - 1); y++) {
154
+ highlights.push({
155
+ row: y - top,
156
+ start: y === match.startY ? match.startX : 0,
157
+ end: y === match.endY ? match.endX : cols - 1,
158
+ active,
159
+ });
160
+ }
161
+ }
162
+ terminal.setSearchHighlights(highlights, {
163
+ match: this.decorations.matchBackground ?? DEFAULT_MATCH,
164
+ active: this.decorations.activeMatchBackground ?? DEFAULT_ACTIVE,
165
+ });
166
+ }
167
+
168
+ private emitResults(): void {
169
+ const search = this.search;
170
+ if (!search || !this.needle) {
171
+ this.resultsEmitter.fire({ resultIndex: -1, resultCount: 0 });
172
+ return;
173
+ }
174
+ this.resultsEmitter.fire({
175
+ resultIndex: search.selectedIndex() ?? -1,
176
+ resultCount: search.total(),
177
+ });
178
+ }
179
+
180
+ dispose(): void {
181
+ this.cancelRefresh();
182
+ for (const sub of this.subscriptions) sub.dispose();
183
+ this.subscriptions = [];
184
+ this.terminal?.setSearchHighlights(null);
185
+ this.search?.free();
186
+ this.search = null;
187
+ this.resultsEmitter.dispose();
188
+ this.terminal = undefined;
189
+ }
190
+ }
package/lib/buffer.ts CHANGED
@@ -180,9 +180,7 @@ export class Buffer implements IBuffer {
180
180
  // So scrollbackOffset = y directly!
181
181
  const scrollbackOffset = y;
182
182
  cells = wasmTerm.getScrollbackLine(scrollbackOffset);
183
- // TODO: We'd need WASM API to check if scrollback line is wrapped
184
- // For now, assume not wrapped
185
- isWrapped = false;
183
+ isWrapped = wasmTerm.isScrollbackRowWrapped(scrollbackOffset);
186
184
  } else {
187
185
  // Accessing visible screen
188
186
  lineNumber = this.bufferType === 'normal' ? y - scrollbackLength : y;
package/lib/ghostty.ts CHANGED
@@ -6,7 +6,14 @@
6
6
  * grid references and key encoder through `Abi`.
7
7
  */
8
8
 
9
- import { Abi, GHOSTTY_SUCCESS, GhosttyCallError, type VtExports } from './abi';
9
+ import {
10
+ Abi,
11
+ GHOSTTY_NO_VALUE,
12
+ GHOSTTY_OUT_OF_SPACE,
13
+ GHOSTTY_SUCCESS,
14
+ GhosttyCallError,
15
+ type VtExports,
16
+ } from './abi';
10
17
  import { EventEmitter } from './event-emitter';
11
18
  import type { IEvent } from './interfaces';
12
19
  import {
@@ -372,6 +379,176 @@ function allocScratch(abi: Abi, layout: CellLayout): ExtractScratch {
372
379
  * Scrollback, graphemes, hyperlinks and wrap flags are read through grid
373
380
  * references on demand.
374
381
  */
382
+ /** A match in screen coordinates: columns, and rows counted from the top of the scrollback. */
383
+ export interface SearchMatch {
384
+ startX: number;
385
+ startY: number;
386
+ endX: number;
387
+ endY: number;
388
+ }
389
+
390
+ /**
391
+ * A search of one terminal's screen and scrollback with libghostty-vt's
392
+ * search: case-insensitive for ASCII letters, kept in sync with the terminal
393
+ * by `run()` after writes. It never scrolls; the caller shows the selected
394
+ * match. "Next" moves toward older output (up), as in Ghostty.
395
+ */
396
+ export class TerminalSearch {
397
+ private handle: number;
398
+
399
+ constructor(
400
+ private readonly abi: Abi,
401
+ private readonly terminal: number
402
+ ) {
403
+ this.handle = abi.newHandle('ghostty_search_new', (slot) =>
404
+ abi.call('ghostty_search_new', 0, slot, terminal)
405
+ );
406
+ abi.with(4, (ptr) => {
407
+ abi.view().setInt32(ptr, abi.enumValue('GhosttySearchScroll', 'NONE'), true);
408
+ abi.check('ghostty_search_set', this.handle, this.option('SELECT_SCROLL'), ptr);
409
+ });
410
+ }
411
+
412
+ private option(name: string): number {
413
+ return this.abi.enumValue('GhosttySearchOption', name);
414
+ }
415
+
416
+ private data(name: string): number {
417
+ return this.abi.enumValue('GhosttySearchData', name);
418
+ }
419
+
420
+ /** Starts a search for `text`, or stops searching when it is empty. */
421
+ setNeedle(text: string): void {
422
+ const abi = this.abi;
423
+ if (!text) {
424
+ abi.check('ghostty_search_set', this.handle, this.option('NEEDLE'), 0);
425
+ return;
426
+ }
427
+ const bytes = new TextEncoder().encode(text);
428
+ abi.withBytes(bytes, (ptr, len) =>
429
+ abi.with(abi.sizeOf('GhosttyString'), (str) => {
430
+ const view = abi.view();
431
+ view.setUint32(str + abi.offset('GhosttyString', 'ptr'), ptr, true);
432
+ view.setUint32(str + abi.offset('GhosttyString', 'len'), len, true);
433
+ abi.check('ghostty_search_set', this.handle, this.option('NEEDLE'), str);
434
+ })
435
+ );
436
+ }
437
+
438
+ /** Catches up with everything written to the terminal since the last run. */
439
+ run(): void {
440
+ this.abi.check('ghostty_search_run', this.handle);
441
+ }
442
+
443
+ /** Selects the next older (`older`) or newer match, wrapping; false when there are none. */
444
+ select(older: boolean): boolean {
445
+ const result = this.abi.call(
446
+ 'ghostty_search_set',
447
+ this.handle,
448
+ this.option(older ? 'SELECT_NEXT' : 'SELECT_PREV'),
449
+ 0
450
+ );
451
+ if (result === GHOSTTY_NO_VALUE) return false;
452
+ if (result !== GHOSTTY_SUCCESS) throw new GhosttyCallError('ghostty_search_set', result);
453
+ return true;
454
+ }
455
+
456
+ total(): number {
457
+ return this.readU32('TOTAL_MATCHES') ?? 0;
458
+ }
459
+
460
+ /** Index of the selected match, newest first (0 is the newest), or null. */
461
+ selectedIndex(): number | null {
462
+ return this.readU32('SELECTED_INDEX');
463
+ }
464
+
465
+ private readU32(name: string): number | null {
466
+ const abi = this.abi;
467
+ return abi.with(8, (ptr) => {
468
+ const result = abi.call('ghostty_search_get', this.handle, this.data(name), ptr);
469
+ if (result === GHOSTTY_NO_VALUE) return null;
470
+ if (result !== GHOSTTY_SUCCESS) throw new GhosttyCallError('ghostty_search_get', result);
471
+ return abi.view().getUint32(ptr, true);
472
+ });
473
+ }
474
+
475
+ selected(): SearchMatch | null {
476
+ const abi = this.abi;
477
+ return abi.withSized('GhosttySelection', (sel) => {
478
+ const result = abi.call('ghostty_search_get', this.handle, this.data('SELECTED_MATCH'), sel);
479
+ if (result === GHOSTTY_NO_VALUE) return null;
480
+ if (result !== GHOSTTY_SUCCESS) throw new GhosttyCallError('ghostty_search_get', result);
481
+ return this.toMatch(sel);
482
+ });
483
+ }
484
+
485
+ /** Every match, newest first, read right after `run()` and before the next write. */
486
+ matches(): SearchMatch[] {
487
+ const abi = this.abi;
488
+ const selSize = abi.sizeOf('GhosttySelection');
489
+ const bufSize = abi.sizeOf('GhosttySelectionBuffer');
490
+ const at = (buf: number, field: string) => buf + abi.offset('GhosttySelectionBuffer', field);
491
+ return abi.with(bufSize, (buf) => {
492
+ const query = abi.call('ghostty_search_get', this.handle, this.data('MATCHES'), buf);
493
+ if (query === GHOSTTY_SUCCESS) return [];
494
+ if (query !== GHOSTTY_OUT_OF_SPACE) {
495
+ if (query === GHOSTTY_NO_VALUE) return [];
496
+ throw new GhosttyCallError('ghostty_search_get', query);
497
+ }
498
+ const count = abi.view().getUint32(at(buf, 'len'), true);
499
+ return abi.with(count * selSize, (items) => {
500
+ for (let i = 0; i < count; i++) {
501
+ abi.view().setUint32(items + i * selSize, selSize, true);
502
+ }
503
+ const view = abi.view();
504
+ view.setUint32(at(buf, 'ptr'), items, true);
505
+ view.setUint32(at(buf, 'cap'), count, true);
506
+ abi.check('ghostty_search_get', this.handle, this.data('MATCHES'), buf);
507
+ const len = abi.view().getUint32(at(buf, 'len'), true);
508
+ const out: SearchMatch[] = [];
509
+ for (let i = 0; i < len; i++) {
510
+ const match = this.toMatch(items + i * selSize);
511
+ if (match) out.push(match);
512
+ }
513
+ return out;
514
+ });
515
+ });
516
+ }
517
+
518
+ private toMatch(sel: number): SearchMatch | null {
519
+ const abi = this.abi;
520
+ const start = this.screenPoint(sel + abi.offset('GhosttySelection', 'start'));
521
+ const end = this.screenPoint(sel + abi.offset('GhosttySelection', 'end'));
522
+ if (!start || !end) return null;
523
+ return { startX: start.x, startY: start.y, endX: end.x, endY: end.y };
524
+ }
525
+
526
+ private screenPoint(ref: number): { x: number; y: number } | null {
527
+ const abi = this.abi;
528
+ return abi.with(abi.sizeOf('GhosttyPointCoordinate'), (out) => {
529
+ const result = abi.call(
530
+ 'ghostty_terminal_point_from_grid_ref',
531
+ this.terminal,
532
+ ref,
533
+ abi.enumValue('GhosttyPointTag', 'SCREEN'),
534
+ out
535
+ );
536
+ if (result !== GHOSTTY_SUCCESS) return null;
537
+ const view = abi.view();
538
+ return {
539
+ x: view.getUint16(out + abi.offset('GhosttyPointCoordinate', 'x'), true),
540
+ y: view.getUint32(out + abi.offset('GhosttyPointCoordinate', 'y'), true),
541
+ };
542
+ });
543
+ }
544
+
545
+ free(): void {
546
+ if (!this.handle) return;
547
+ this.abi.call('ghostty_search_free', this.handle);
548
+ this.handle = 0;
549
+ }
550
+ }
551
+
375
552
  export class GhosttyTerminal {
376
553
  private handle: number;
377
554
  private renderState: number;
@@ -449,7 +626,7 @@ export class GhosttyTerminal {
449
626
  this.setOption('WRITE_PTY', writePty);
450
627
  this.installHooks();
451
628
  this.setModeDefault(GRAPHEME_CLUSTER_MODE, true);
452
- if (config) this.applyConfig(config);
629
+ if (config) this.configure(config);
453
630
  this.initCellPool();
454
631
  }
455
632
 
@@ -460,6 +637,11 @@ export class GhosttyTerminal {
460
637
  return this._rows;
461
638
  }
462
639
 
640
+ /** A new search of this terminal; free it before or after the terminal. */
641
+ createSearch(): TerminalSearch {
642
+ return new TerminalSearch(this.abi, this.handle);
643
+ }
644
+
463
645
  /** The raw libghostty-vt terminal handle, for APIs this class does not wrap yet. */
464
646
  get rawHandle(): number {
465
647
  return this.handle;
@@ -748,9 +930,19 @@ export class GhosttyTerminal {
748
930
  }
749
931
 
750
932
  isRowWrapped(row: number): boolean {
933
+ return this.rowContinues(POINT_ACTIVE, row);
934
+ }
935
+
936
+ /** Whether a scrollback line continues the one before it; offset 0 is the oldest line. */
937
+ isScrollbackRowWrapped(offset: number): boolean {
938
+ if (offset < 0 || offset >= this.getScrollbackLength()) return false;
939
+ return this.rowContinues(POINT_HISTORY, offset);
940
+ }
941
+
942
+ private rowContinues(point: PointSpace, row: number): boolean {
751
943
  const abi = this.abi;
752
944
  return (
753
- this.gridRef(POINT_ACTIVE, 0, row, (ref) =>
945
+ this.gridRef(point, 0, row, (ref) =>
754
946
  abi.with(8, (rowPtr) => {
755
947
  if (abi.call('ghostty_grid_ref_row', ref, rowPtr) !== GHOSTTY_SUCCESS) return false;
756
948
  const raw = abi.view().getBigUint64(rowPtr, true);
@@ -811,16 +1003,21 @@ export class GhosttyTerminal {
811
1003
  // Private helpers
812
1004
  // ==========================================================================
813
1005
 
814
- private applyConfig(config: GhosttyTerminalConfig): void {
1006
+ /**
1007
+ * Applies scrollback and color settings. Colors take effect on the whole
1008
+ * screen at the next update; programs can still change them (OSC 4/10/11).
1009
+ */
1010
+ configure(config: GhosttyTerminalConfig): void {
815
1011
  const abi = this.abi;
816
1012
  if (config.scrollbackLimit !== undefined) {
1013
+ this.setOption('SCROLLBACK_MAX_BYTES', 0);
817
1014
  abi.with(4, (ptr) => {
818
1015
  abi.view().setUint32(ptr, config.scrollbackLimit!, true);
819
1016
  this.setOption('SCROLLBACK_MAX_LINES', ptr);
820
1017
  });
821
1018
  }
822
1019
  const setRgb = (option: string, color: number | undefined) => {
823
- if (!color) return;
1020
+ if (color === undefined) return;
824
1021
  abi.with(4, (ptr) => {
825
1022
  const view = abi.view();
826
1023
  view.setUint8(ptr, (color >> 16) & 0xff);
@@ -832,7 +1029,7 @@ export class GhosttyTerminal {
832
1029
  setRgb('COLOR_FOREGROUND', config.fgColor);
833
1030
  setRgb('COLOR_BACKGROUND', config.bgColor);
834
1031
  setRgb('COLOR_CURSOR', config.cursorColor);
835
- if (config.palette?.some((c) => c)) {
1032
+ if (config.palette?.some((c) => c !== undefined)) {
836
1033
  const size = 256 * 3;
837
1034
  abi.with(size, (ptr) => {
838
1035
  abi.check(
@@ -843,7 +1040,7 @@ export class GhosttyTerminal {
843
1040
  );
844
1041
  const view = abi.view();
845
1042
  config.palette!.slice(0, 16).forEach((color, i) => {
846
- if (!color) return;
1043
+ if (color === undefined) return;
847
1044
  view.setUint8(ptr + i * 3, (color >> 16) & 0xff);
848
1045
  view.setUint8(ptr + i * 3 + 1, (color >> 8) & 0xff);
849
1046
  view.setUint8(ptr + i * 3 + 2, color & 0xff);
@@ -851,6 +1048,8 @@ export class GhosttyTerminal {
851
1048
  this.setOption('COLOR_PALETTE', ptr);
852
1049
  });
853
1050
  }
1051
+ this.changed = true;
1052
+ this.dirty = DirtyState.FULL;
854
1053
  }
855
1054
 
856
1055
  private installHooks(): void {
@@ -1028,6 +1227,36 @@ export class GhosttyTerminal {
1028
1227
  palette,
1029
1228
  };
1030
1229
  });
1230
+ this.readTerminalColors();
1231
+ }
1232
+
1233
+ /**
1234
+ * The render state does not pick up default colors set with
1235
+ * ghostty_terminal_set, so the effective colors (a program's OSC override,
1236
+ * else the default) are read from the terminal itself.
1237
+ */
1238
+ private readTerminalColors(): void {
1239
+ const abi = this.abi;
1240
+ const data = (name: string) => abi.enumValue('GhosttyTerminalData', name);
1241
+ const rgb = (name: string): RGB | null =>
1242
+ abi.with(4, (ptr) =>
1243
+ abi.call('ghostty_terminal_get', this.handle, data(name), ptr) === GHOSTTY_SUCCESS
1244
+ ? rgbAt(abi.view(), ptr)
1245
+ : null
1246
+ );
1247
+ this.colors.background = rgb('COLOR_BACKGROUND') ?? this.colors.background;
1248
+ this.colors.foreground = rgb('COLOR_FOREGROUND') ?? this.colors.foreground;
1249
+ this.colors.cursor = rgb('COLOR_CURSOR') ?? this.colors.cursor;
1250
+ abi.with(256 * 3, (ptr) => {
1251
+ if (
1252
+ abi.call('ghostty_terminal_get', this.handle, data('COLOR_PALETTE'), ptr) !==
1253
+ GHOSTTY_SUCCESS
1254
+ ) {
1255
+ return;
1256
+ }
1257
+ const view = abi.view();
1258
+ for (let i = 0; i < 256; i++) this.colors.palette[i] = rgbAt(view, ptr + i * 3);
1259
+ });
1031
1260
  }
1032
1261
 
1033
1262
  private extractRows(all: boolean): void {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Finds the pixels a rasterized glyph actually covers, so the renderer draws
3
+ * a quad over the ink instead of over the whole padded atlas slot.
4
+ */
5
+
6
+ const COLORED_SPREAD = 24;
7
+
8
+ export interface GlyphInk {
9
+ left: number;
10
+ top: number;
11
+ width: number;
12
+ height: number;
13
+ colored: boolean;
14
+ }
15
+
16
+ /** Bounds of the non-transparent pixels in RGBA `data` (`w` × `h`), or null when none. */
17
+ export function glyphInk(data: Uint8ClampedArray, w: number, h: number): GlyphInk | null {
18
+ let left = w;
19
+ let right = -1;
20
+ let top = h;
21
+ let bottom = -1;
22
+ let colored = false;
23
+ for (let y = 0; y < h; y++) {
24
+ const row = y * w * 4;
25
+ for (let x = 0; x < w; x++) {
26
+ const i = row + x * 4;
27
+ if (data[i + 3] === 0) continue;
28
+ if (x < left) left = x;
29
+ if (x > right) right = x;
30
+ if (y < top) top = y;
31
+ bottom = y;
32
+ if (!colored) {
33
+ const r = data[i];
34
+ const g = data[i + 1];
35
+ const b = data[i + 2];
36
+ colored = Math.max(r, g, b) - Math.min(r, g, b) > COLORED_SPREAD;
37
+ }
38
+ }
39
+ }
40
+ if (right < 0) return null;
41
+ return { left, top, width: right - left + 1, height: bottom - top + 1, colored };
42
+ }
package/lib/index.ts CHANGED
@@ -102,6 +102,12 @@ export type { SelectionCoordinates } from './selection-manager';
102
102
 
103
103
  // Addons
104
104
  export { FitAddon } from './addons/fit';
105
+ export { SearchAddon } from './addons/search';
106
+ export type {
107
+ ISearchDecorationOptions,
108
+ ISearchOptions,
109
+ ISearchResultChangeEvent,
110
+ } from './addons/search';
105
111
  export type { ITerminalDimensions } from './addons/fit';
106
112
 
107
113
  // Link providers
@@ -195,6 +195,13 @@ export class InputHandler {
195
195
  private wheelListener: ((e: WheelEvent) => void) | null = null;
196
196
  private isComposing = false;
197
197
  private isDisposed = false;
198
+ /**
199
+ * On macOS, whether Option acts as Meta (ESC prefix) instead of composing a
200
+ * character (Option+A types 'å'). Elsewhere Alt always acts as Meta.
201
+ */
202
+ macOptionIsMeta = false;
203
+ private readonly isMac =
204
+ typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform ?? '');
198
205
  private mouseButtonsPressed = 0; // Track which buttons are pressed for motion reporting
199
206
  private lastKeyDownData: string | null = null;
200
207
  private lastKeyDownTime = 0;
@@ -233,6 +240,7 @@ export class InputHandler {
233
240
  mouseConfig?: MouseTrackingConfig
234
241
  ) {
235
242
  this.encoder = ghostty.createKeyEncoder();
243
+ this.encoder.setOption(KeyEncoderOption.ALT_ESC_PREFIX, true);
236
244
  this.container = container;
237
245
  this.inputElement = inputElement;
238
246
  this.onDataCallback = onData;
@@ -345,6 +353,18 @@ export class InputHandler {
345
353
  return mods;
346
354
  }
347
355
 
356
+ /** macOS Option without Ctrl or Cmd composes a character unless Option is Meta. */
357
+ private optionComposes(event: KeyboardEvent): boolean {
358
+ return (
359
+ this.isMac &&
360
+ !this.macOptionIsMeta &&
361
+ event.altKey &&
362
+ !event.ctrlKey &&
363
+ !event.metaKey &&
364
+ event.key.length === 1
365
+ );
366
+ }
367
+
348
368
  /**
349
369
  * Check if this is a printable character with no special modifiers
350
370
  * @param event - KeyboardEvent
@@ -389,8 +409,9 @@ export class InputHandler {
389
409
  }
390
410
  }
391
411
 
392
- // Allow Ctrl+V and Cmd+V to trigger paste event (don't preventDefault)
393
- if ((event.ctrlKey || event.metaKey) && event.code === 'KeyV') {
412
+ // Cmd+V pastes through the browser's paste event; Ctrl+V reaches the
413
+ // program as ^V, as in xterm.js.
414
+ if (event.metaKey && event.code === 'KeyV') {
394
415
  // Let the browser's native paste event fire
395
416
  return;
396
417
  }
@@ -407,6 +428,13 @@ export class InputHandler {
407
428
  return;
408
429
  }
409
430
 
431
+ if (this.optionComposes(event)) {
432
+ event.preventDefault();
433
+ this.onDataCallback(event.key);
434
+ this.recordKeyDownData(event.key);
435
+ return;
436
+ }
437
+
410
438
  // For printable characters without modifiers, send the character directly
411
439
  // This handles: a-z, A-Z (with shift), 0-9, punctuation, etc.
412
440
  if (this.isPrintableCharacter(event)) {
@@ -532,7 +560,9 @@ export class InputHandler {
532
560
  const utf8 =
533
561
  event.key.length === 1 && event.key.charCodeAt(0) < 128
534
562
  ? event.key.toLowerCase() // Use lowercase for consistency
535
- : undefined;
563
+ : event.altKey
564
+ ? baseCharacter(event.code)
565
+ : undefined;
536
566
 
537
567
  const encoded = this.encoder.encode({
538
568
  action,
@@ -1114,3 +1144,15 @@ export class InputHandler {
1114
1144
  return !this.isDisposed;
1115
1145
  }
1116
1146
  }
1147
+
1148
+ /**
1149
+ * The character a letter or digit key types without modifiers, from its
1150
+ * physical code: Alt as Meta sends ESC and this even when the layout makes
1151
+ * Alt+key type something else (macOS Option+A types 'å').
1152
+ */
1153
+ function baseCharacter(code: string): string | undefined {
1154
+ const letter = /^Key([A-Z])$/.exec(code);
1155
+ if (letter) return letter[1].toLowerCase();
1156
+ const digit = /^Digit([0-9])$/.exec(code);
1157
+ return digit ? digit[1] : undefined;
1158
+ }