@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 +2 -1
- package/lib/addons/search.ts +190 -0
- package/lib/buffer.ts +1 -3
- package/lib/ghostty.ts +236 -7
- package/lib/glyph-ink.ts +42 -0
- package/lib/index.ts +6 -0
- package/lib/input-handler.ts +45 -3
- package/lib/interfaces.ts +23 -0
- package/lib/providers/osc8-link-provider.ts +4 -10
- package/lib/providers/url-regex-provider.ts +10 -13
- package/lib/providers/web-link.ts +35 -0
- package/lib/renderer.ts +266 -33
- package/lib/selection-manager.ts +3 -16
- package/lib/terminal.ts +154 -73
- package/lib/types.ts +2 -1
- package/lib/webgl-instances.ts +69 -0
- package/lib/webgl-renderer.ts +176 -103
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 {
|
package/lib/glyph-ink.ts
ADDED
|
@@ -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
|
package/lib/input-handler.ts
CHANGED
|
@@ -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
|
-
//
|
|
393
|
-
|
|
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
|
-
:
|
|
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
|
+
}
|