@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.
- package/README.md +2 -1
- package/lib/addons/search.ts +190 -0
- package/lib/ghostty.ts +224 -6
- package/lib/glyph-ink.ts +42 -0
- package/lib/index.ts +6 -0
- package/lib/input-handler.ts +42 -1
- 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/terminal.ts +150 -58
- 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/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;
|
|
@@ -811,7 +993,11 @@ export class GhosttyTerminal {
|
|
|
811
993
|
// Private helpers
|
|
812
994
|
// ==========================================================================
|
|
813
995
|
|
|
814
|
-
|
|
996
|
+
/**
|
|
997
|
+
* Applies scrollback and color settings. Colors take effect on the whole
|
|
998
|
+
* screen at the next update; programs can still change them (OSC 4/10/11).
|
|
999
|
+
*/
|
|
1000
|
+
configure(config: GhosttyTerminalConfig): void {
|
|
815
1001
|
const abi = this.abi;
|
|
816
1002
|
if (config.scrollbackLimit !== undefined) {
|
|
817
1003
|
abi.with(4, (ptr) => {
|
|
@@ -820,7 +1006,7 @@ export class GhosttyTerminal {
|
|
|
820
1006
|
});
|
|
821
1007
|
}
|
|
822
1008
|
const setRgb = (option: string, color: number | undefined) => {
|
|
823
|
-
if (
|
|
1009
|
+
if (color === undefined) return;
|
|
824
1010
|
abi.with(4, (ptr) => {
|
|
825
1011
|
const view = abi.view();
|
|
826
1012
|
view.setUint8(ptr, (color >> 16) & 0xff);
|
|
@@ -832,7 +1018,7 @@ export class GhosttyTerminal {
|
|
|
832
1018
|
setRgb('COLOR_FOREGROUND', config.fgColor);
|
|
833
1019
|
setRgb('COLOR_BACKGROUND', config.bgColor);
|
|
834
1020
|
setRgb('COLOR_CURSOR', config.cursorColor);
|
|
835
|
-
if (config.palette?.some((c) => c)) {
|
|
1021
|
+
if (config.palette?.some((c) => c !== undefined)) {
|
|
836
1022
|
const size = 256 * 3;
|
|
837
1023
|
abi.with(size, (ptr) => {
|
|
838
1024
|
abi.check(
|
|
@@ -843,7 +1029,7 @@ export class GhosttyTerminal {
|
|
|
843
1029
|
);
|
|
844
1030
|
const view = abi.view();
|
|
845
1031
|
config.palette!.slice(0, 16).forEach((color, i) => {
|
|
846
|
-
if (
|
|
1032
|
+
if (color === undefined) return;
|
|
847
1033
|
view.setUint8(ptr + i * 3, (color >> 16) & 0xff);
|
|
848
1034
|
view.setUint8(ptr + i * 3 + 1, (color >> 8) & 0xff);
|
|
849
1035
|
view.setUint8(ptr + i * 3 + 2, color & 0xff);
|
|
@@ -851,6 +1037,8 @@ export class GhosttyTerminal {
|
|
|
851
1037
|
this.setOption('COLOR_PALETTE', ptr);
|
|
852
1038
|
});
|
|
853
1039
|
}
|
|
1040
|
+
this.changed = true;
|
|
1041
|
+
this.dirty = DirtyState.FULL;
|
|
854
1042
|
}
|
|
855
1043
|
|
|
856
1044
|
private installHooks(): void {
|
|
@@ -1028,6 +1216,36 @@ export class GhosttyTerminal {
|
|
|
1028
1216
|
palette,
|
|
1029
1217
|
};
|
|
1030
1218
|
});
|
|
1219
|
+
this.readTerminalColors();
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* The render state does not pick up default colors set with
|
|
1224
|
+
* ghostty_terminal_set, so the effective colors (a program's OSC override,
|
|
1225
|
+
* else the default) are read from the terminal itself.
|
|
1226
|
+
*/
|
|
1227
|
+
private readTerminalColors(): void {
|
|
1228
|
+
const abi = this.abi;
|
|
1229
|
+
const data = (name: string) => abi.enumValue('GhosttyTerminalData', name);
|
|
1230
|
+
const rgb = (name: string): RGB | null =>
|
|
1231
|
+
abi.with(4, (ptr) =>
|
|
1232
|
+
abi.call('ghostty_terminal_get', this.handle, data(name), ptr) === GHOSTTY_SUCCESS
|
|
1233
|
+
? rgbAt(abi.view(), ptr)
|
|
1234
|
+
: null
|
|
1235
|
+
);
|
|
1236
|
+
this.colors.background = rgb('COLOR_BACKGROUND') ?? this.colors.background;
|
|
1237
|
+
this.colors.foreground = rgb('COLOR_FOREGROUND') ?? this.colors.foreground;
|
|
1238
|
+
this.colors.cursor = rgb('COLOR_CURSOR') ?? this.colors.cursor;
|
|
1239
|
+
abi.with(256 * 3, (ptr) => {
|
|
1240
|
+
if (
|
|
1241
|
+
abi.call('ghostty_terminal_get', this.handle, data('COLOR_PALETTE'), ptr) !==
|
|
1242
|
+
GHOSTTY_SUCCESS
|
|
1243
|
+
) {
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
const view = abi.view();
|
|
1247
|
+
for (let i = 0; i < 256; i++) this.colors.palette[i] = rgbAt(view, ptr + i * 3);
|
|
1248
|
+
});
|
|
1031
1249
|
}
|
|
1032
1250
|
|
|
1033
1251
|
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
|
|
@@ -407,6 +427,13 @@ export class InputHandler {
|
|
|
407
427
|
return;
|
|
408
428
|
}
|
|
409
429
|
|
|
430
|
+
if (this.optionComposes(event)) {
|
|
431
|
+
event.preventDefault();
|
|
432
|
+
this.onDataCallback(event.key);
|
|
433
|
+
this.recordKeyDownData(event.key);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
410
437
|
// For printable characters without modifiers, send the character directly
|
|
411
438
|
// This handles: a-z, A-Z (with shift), 0-9, punctuation, etc.
|
|
412
439
|
if (this.isPrintableCharacter(event)) {
|
|
@@ -532,7 +559,9 @@ export class InputHandler {
|
|
|
532
559
|
const utf8 =
|
|
533
560
|
event.key.length === 1 && event.key.charCodeAt(0) < 128
|
|
534
561
|
? event.key.toLowerCase() // Use lowercase for consistency
|
|
535
|
-
:
|
|
562
|
+
: event.altKey
|
|
563
|
+
? baseCharacter(event.code)
|
|
564
|
+
: undefined;
|
|
536
565
|
|
|
537
566
|
const encoded = this.encoder.encode({
|
|
538
567
|
action,
|
|
@@ -1114,3 +1143,15 @@ export class InputHandler {
|
|
|
1114
1143
|
return !this.isDisposed;
|
|
1115
1144
|
}
|
|
1116
1145
|
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* The character a letter or digit key types without modifiers, from its
|
|
1149
|
+
* physical code: Alt as Meta sends ESC and this even when the layout makes
|
|
1150
|
+
* Alt+key type something else (macOS Option+A types 'å').
|
|
1151
|
+
*/
|
|
1152
|
+
function baseCharacter(code: string): string | undefined {
|
|
1153
|
+
const letter = /^Key([A-Z])$/.exec(code);
|
|
1154
|
+
if (letter) return letter[1].toLowerCase();
|
|
1155
|
+
const digit = /^Digit([0-9])$/.exec(code);
|
|
1156
|
+
return digit ? digit[1] : undefined;
|
|
1157
|
+
}
|
package/lib/interfaces.ts
CHANGED
|
@@ -14,6 +14,14 @@ export interface ITerminalOptions {
|
|
|
14
14
|
fontSize?: number; // Default: 15
|
|
15
15
|
fontFamily?: string; // Default: 'monospace'
|
|
16
16
|
allowTransparency?: boolean;
|
|
17
|
+
fontWeight?: FontWeight; // Default: 'normal'
|
|
18
|
+
fontWeightBold?: FontWeight; // Default: 'bold'
|
|
19
|
+
lineHeight?: number; // Multiplies the cell height. Default: 1
|
|
20
|
+
scrollSensitivity?: number; // Multiplies wheel scrolling. Default: 1
|
|
21
|
+
/** Foreground colors are adjusted to at least this contrast ratio with their background (1 to 21). Default: 1 */
|
|
22
|
+
minimumContrastRatio?: number;
|
|
23
|
+
/** On macOS, Option acts as Meta instead of composing characters. Default: false */
|
|
24
|
+
macOptionIsMeta?: boolean;
|
|
17
25
|
/**
|
|
18
26
|
* How the terminal draws, read when it opens. 'webgl' draws on the GPU and
|
|
19
27
|
* falls back to 'canvas' when WebGL 2 is unavailable or its context is lost.
|
|
@@ -21,6 +29,12 @@ export interface ITerminalOptions {
|
|
|
21
29
|
*/
|
|
22
30
|
renderer?: 'webgl' | 'canvas';
|
|
23
31
|
|
|
32
|
+
/**
|
|
33
|
+
* What clicking and hovering a web link does (plain URLs and OSC 8
|
|
34
|
+
* hyperlinks). Without one, Ctrl/Cmd+click opens the link in a new window.
|
|
35
|
+
*/
|
|
36
|
+
linkHandler?: ILinkHandler | null;
|
|
37
|
+
|
|
24
38
|
// Phase 1 additions
|
|
25
39
|
convertEol?: boolean; // Convert \n to \r\n (default: false)
|
|
26
40
|
disableStdin?: boolean; // Disable keyboard input (default: false)
|
|
@@ -33,6 +47,15 @@ export interface ITerminalOptions {
|
|
|
33
47
|
ghostty?: Ghostty;
|
|
34
48
|
}
|
|
35
49
|
|
|
50
|
+
export type FontWeight = 'normal' | 'bold' | number;
|
|
51
|
+
|
|
52
|
+
/** Handles web links the way xterm.js's `linkHandler` option does. Ranges are 0-based buffer cells. */
|
|
53
|
+
export interface ILinkHandler {
|
|
54
|
+
activate(event: MouseEvent, text: string, range: IBufferRange): void;
|
|
55
|
+
hover?(event: MouseEvent, text: string, range: IBufferRange): void;
|
|
56
|
+
leave?(event: MouseEvent, text: string, range: IBufferRange): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
export interface ITheme {
|
|
37
60
|
foreground?: string;
|
|
38
61
|
background?: string;
|