@aurigax-ai/ghostty-web 0.3.0 → 0.3.2
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 +3 -1
- package/lib/abi.ts +71 -2
- package/lib/buffer.ts +1 -3
- package/lib/ghostty.ts +152 -96
- package/lib/input-handler.ts +3 -2
- package/lib/renderer.ts +52 -17
- package/lib/selection-manager.ts +3 -16
- package/lib/terminal.ts +78 -42
- package/lib/webgl-renderer.ts +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,7 +66,9 @@ in the output stream:
|
|
|
66
66
|
| `modes.mouseTrackingMode` | `none`, `x10`, `vt200`, `drag` or `any` |
|
|
67
67
|
|
|
68
68
|
The render loop runs only while something changes (output, input, scrolling, selection, cursor
|
|
69
|
-
blink) and sleeps otherwise.
|
|
69
|
+
blink) and sleeps otherwise. `setPaused(true)` stops drawing a terminal that is off screen while it
|
|
70
|
+
keeps parsing output; `setPaused(false)` redraws it whole. `setPaused(true, { releaseRenderer: true })`
|
|
71
|
+
also gives up its WebGL context until then, since browsers keep only a few.
|
|
70
72
|
|
|
71
73
|
## Use
|
|
72
74
|
|
package/lib/abi.ts
CHANGED
|
@@ -32,6 +32,9 @@ interface TypeLayout {
|
|
|
32
32
|
values?: Record<string, number>;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
const encoder = new TextEncoder();
|
|
36
|
+
const decoder = new TextDecoder();
|
|
37
|
+
|
|
35
38
|
const I32 = 0x7f;
|
|
36
39
|
const I64 = 0x7e;
|
|
37
40
|
|
|
@@ -95,13 +98,16 @@ export class GhosttyCallError extends Error {
|
|
|
95
98
|
export class Abi {
|
|
96
99
|
private readonly types: Record<string, TypeLayout>;
|
|
97
100
|
private readonly freeSlots: number[] = [];
|
|
101
|
+
private scratchPtr = 0;
|
|
102
|
+
private scratchLen = 0;
|
|
103
|
+
private readonly input = { ptr: 0, len: 0, busy: false };
|
|
98
104
|
|
|
99
105
|
constructor(readonly exports: VtExports) {
|
|
100
106
|
const ptr = this.fn('ghostty_type_json')() as number;
|
|
101
107
|
const bytes = new Uint8Array(exports.memory.buffer);
|
|
102
108
|
let end = ptr;
|
|
103
109
|
while (bytes[end] !== 0) end++;
|
|
104
|
-
this.types = JSON.parse(
|
|
110
|
+
this.types = JSON.parse(decoder.decode(bytes.subarray(ptr, end))).types;
|
|
105
111
|
}
|
|
106
112
|
|
|
107
113
|
fn(name: string): Fn {
|
|
@@ -188,6 +194,69 @@ export class Abi {
|
|
|
188
194
|
});
|
|
189
195
|
}
|
|
190
196
|
|
|
197
|
+
/**
|
|
198
|
+
* A zeroed buffer of at least `len` bytes, the same one on every call, for a
|
|
199
|
+
* read that is done with it before anything else calls scratch().
|
|
200
|
+
*/
|
|
201
|
+
scratch(len: number): number {
|
|
202
|
+
if (len > this.scratchLen) {
|
|
203
|
+
if (this.scratchPtr) this.free(this.scratchPtr, this.scratchLen);
|
|
204
|
+
this.scratchPtr = 0;
|
|
205
|
+
this.scratchLen = 0;
|
|
206
|
+
const size = Math.max(len, 64);
|
|
207
|
+
this.scratchPtr = this.alloc(size);
|
|
208
|
+
this.scratchLen = size;
|
|
209
|
+
} else {
|
|
210
|
+
this.bytes().fill(0, this.scratchPtr, this.scratchPtr + len);
|
|
211
|
+
}
|
|
212
|
+
return this.scratchPtr;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** scratch() holding a sized struct (its leading `size` field set), as GHOSTTY_INIT_SIZED does. */
|
|
216
|
+
scratchSized(type: string): number {
|
|
217
|
+
const size = this.sizeOf(type);
|
|
218
|
+
const ptr = this.scratch(size);
|
|
219
|
+
this.view().setUint32(ptr, size, true);
|
|
220
|
+
return ptr;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Runs `use` on text (as UTF-8) or bytes copied into a grow-only buffer kept
|
|
225
|
+
* between calls. A call from inside `use` (a terminal written to from one of
|
|
226
|
+
* the callbacks a write runs) gets a buffer of its own.
|
|
227
|
+
*/
|
|
228
|
+
withInput<T>(data: string | Uint8Array, use: (ptr: number, len: number) => T): T {
|
|
229
|
+
if (this.input.busy) {
|
|
230
|
+
return this.withBytes(typeof data === 'string' ? encoder.encode(data) : data, use);
|
|
231
|
+
}
|
|
232
|
+
// UTF-8 takes at most 3 bytes per UTF-16 code unit.
|
|
233
|
+
const max = typeof data === 'string' ? data.length * 3 : data.length;
|
|
234
|
+
if (max === 0) return use(0, 0);
|
|
235
|
+
const input = this.input;
|
|
236
|
+
if (max > input.len) {
|
|
237
|
+
if (input.ptr) this.free(input.ptr, input.len);
|
|
238
|
+
input.ptr = 0;
|
|
239
|
+
input.len = 0;
|
|
240
|
+
const len = Math.max(max, 4096);
|
|
241
|
+
input.ptr = this.call('ghostty_wasm_alloc', len);
|
|
242
|
+
if (input.ptr === 0) throw new GhosttyCallError('ghostty_wasm_alloc', -1);
|
|
243
|
+
input.len = len;
|
|
244
|
+
}
|
|
245
|
+
const bytes = this.bytes();
|
|
246
|
+
let len = data.length;
|
|
247
|
+
if (typeof data === 'string') {
|
|
248
|
+
len = encoder.encodeInto(data, bytes.subarray(input.ptr, input.ptr + input.len)).written;
|
|
249
|
+
} else {
|
|
250
|
+
bytes.set(data, input.ptr);
|
|
251
|
+
}
|
|
252
|
+
input.busy = true;
|
|
253
|
+
try {
|
|
254
|
+
return use(input.ptr, len);
|
|
255
|
+
} finally {
|
|
256
|
+
input.busy = false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
191
260
|
/** Runs a constructor that writes an opaque handle into an out slot and returns the handle. */
|
|
192
261
|
newHandle(name: string, construct: (slot: number) => number): number {
|
|
193
262
|
const slot = this.call('ghostty_wasm_alloc_opaque');
|
|
@@ -203,7 +272,7 @@ export class Abi {
|
|
|
203
272
|
|
|
204
273
|
string(ptr: number, len: number): string {
|
|
205
274
|
if (len === 0) return '';
|
|
206
|
-
return
|
|
275
|
+
return decoder.decode(this.bytes().subarray(ptr, ptr + len));
|
|
207
276
|
}
|
|
208
277
|
|
|
209
278
|
/** Registers a JavaScript callback in the function table and returns its index (the C function pointer). */
|
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
|
@@ -229,7 +229,9 @@ export interface TrackedRow {
|
|
|
229
229
|
|
|
230
230
|
const POINT_ACTIVE = 'ACTIVE';
|
|
231
231
|
const POINT_HISTORY = 'HISTORY';
|
|
232
|
+
const POINT_SCREEN = 'SCREEN';
|
|
232
233
|
type PointSpace = typeof POINT_ACTIVE | typeof POINT_HISTORY;
|
|
234
|
+
type PointTag = PointSpace | typeof POINT_SCREEN;
|
|
233
235
|
|
|
234
236
|
const GRAPHEME_CAP = 16;
|
|
235
237
|
const GRAPHEME_CLUSTER_MODE = 2027;
|
|
@@ -560,6 +562,8 @@ export class GhosttyTerminal {
|
|
|
560
562
|
private readonly layout: CellLayout;
|
|
561
563
|
private readonly scratch: ExtractScratch;
|
|
562
564
|
private cellPool: GhosttyCell[] = [];
|
|
565
|
+
/** Each row of cellPool as an array of the same cell objects. */
|
|
566
|
+
private rowViews: GhosttyCell[][] = [];
|
|
563
567
|
private rowDirty: boolean[] = [];
|
|
564
568
|
private dirty: DirtyState = DirtyState.FULL;
|
|
565
569
|
private changed = true;
|
|
@@ -569,8 +573,13 @@ export class GhosttyTerminal {
|
|
|
569
573
|
cursor: null,
|
|
570
574
|
palette: [],
|
|
571
575
|
};
|
|
576
|
+
/** libghostty-vt changes the palette only with a full redraw, or through configure(). */
|
|
577
|
+
private paletteStale = true;
|
|
578
|
+
private cursor: RenderStateCursor | null = null;
|
|
572
579
|
private responses: string[] = [];
|
|
573
580
|
private callbacks: number[] = [];
|
|
581
|
+
/** A tracked row at the top of each screen, keyed by whether it is the alternate one. */
|
|
582
|
+
private screenTops = new Map<boolean, TrackedRow>();
|
|
574
583
|
|
|
575
584
|
private readonly bellEmitter = new EventEmitter<void>();
|
|
576
585
|
private readonly titleEmitter = new EventEmitter<string>();
|
|
@@ -658,8 +667,7 @@ export class GhosttyTerminal {
|
|
|
658
667
|
// ==========================================================================
|
|
659
668
|
|
|
660
669
|
write(data: string | Uint8Array): void {
|
|
661
|
-
|
|
662
|
-
this.abi.withBytes(bytes, (ptr, len) =>
|
|
670
|
+
this.abi.withInput(data, (ptr, len) =>
|
|
663
671
|
this.abi.call('ghostty_terminal_vt_write', this.handle, ptr, len)
|
|
664
672
|
);
|
|
665
673
|
this.changed = true;
|
|
@@ -681,6 +689,8 @@ export class GhosttyTerminal {
|
|
|
681
689
|
abi.call('ghostty_render_state_row_iterator_free', this.rowIterator);
|
|
682
690
|
abi.call('ghostty_render_state_free', this.renderState);
|
|
683
691
|
abi.free(this.scratch.base, this.scratch.size);
|
|
692
|
+
for (const top of this.screenTops.values()) top.free();
|
|
693
|
+
this.screenTops.clear();
|
|
684
694
|
abi.call('ghostty_terminal_free', this.handle);
|
|
685
695
|
for (const index of this.callbacks) abi.removeCallback(index);
|
|
686
696
|
this.callbacks = [];
|
|
@@ -713,43 +723,50 @@ export class GhosttyTerminal {
|
|
|
713
723
|
abi.check('ghostty_render_state_update', this.renderState, this.handle);
|
|
714
724
|
const dirty = this.readRenderU32('DIRTY') as DirtyState;
|
|
715
725
|
if (dirty > this.dirty || this.dirty === DirtyState.NONE) this.dirty = dirty;
|
|
716
|
-
this.
|
|
726
|
+
this.cursor = null;
|
|
727
|
+
this.readColors(this.dirty === DirtyState.FULL || this.paletteStale);
|
|
728
|
+
this.paletteStale = false;
|
|
717
729
|
this.extractRows(this.dirty === DirtyState.FULL);
|
|
718
730
|
return this.dirty;
|
|
719
731
|
}
|
|
720
732
|
|
|
733
|
+
/** The cursor as of the last update; the same object until the render state changes. */
|
|
721
734
|
getCursor(): RenderStateCursor {
|
|
722
735
|
this.update();
|
|
736
|
+
this.cursor ??= this.readCursor();
|
|
737
|
+
return this.cursor;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private readCursor(): RenderStateCursor {
|
|
723
741
|
const abi = this.abi;
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
});
|
|
742
|
+
const ptr = abi.scratchSized('GhosttyRenderStateCursor');
|
|
743
|
+
abi.check(
|
|
744
|
+
'ghostty_render_state_get',
|
|
745
|
+
this.renderState,
|
|
746
|
+
abi.enumValue('GhosttyRenderStateData', 'CURSOR'),
|
|
747
|
+
ptr
|
|
748
|
+
);
|
|
749
|
+
const view = abi.view();
|
|
750
|
+
const at = (f: string) => ptr + abi.offset('GhosttyRenderStateCursor', f);
|
|
751
|
+
const inViewport = view.getUint8(at('viewport_has_value')) !== 0;
|
|
752
|
+
const x = inViewport ? view.getUint16(at('viewport_x'), true) : -1;
|
|
753
|
+
const y = inViewport ? view.getUint16(at('viewport_y'), true) : -1;
|
|
754
|
+
const style = view.getInt32(at('visual_style'), true);
|
|
755
|
+
const styles = 'GhosttyRenderStateCursorVisualStyle';
|
|
756
|
+
return {
|
|
757
|
+
x,
|
|
758
|
+
y,
|
|
759
|
+
viewportX: x,
|
|
760
|
+
viewportY: y,
|
|
761
|
+
visible: inViewport && view.getUint8(at('visible')) !== 0,
|
|
762
|
+
blinking: view.getUint8(at('blinking')) !== 0,
|
|
763
|
+
style:
|
|
764
|
+
style === abi.enumValue(styles, 'BAR')
|
|
765
|
+
? 'bar'
|
|
766
|
+
: style === abi.enumValue(styles, 'UNDERLINE')
|
|
767
|
+
? 'underline'
|
|
768
|
+
: 'block',
|
|
769
|
+
};
|
|
753
770
|
}
|
|
754
771
|
|
|
755
772
|
/**
|
|
@@ -792,6 +809,17 @@ export class GhosttyTerminal {
|
|
|
792
809
|
return this.cellPool.slice(start, start + this._cols).map((cell) => ({ ...cell }));
|
|
793
810
|
}
|
|
794
811
|
|
|
812
|
+
/**
|
|
813
|
+
* A row's cells as the renderer reads them: the terminal's own cell objects,
|
|
814
|
+
* updated in place by later updates, so read them before the next one and
|
|
815
|
+
* never modify them. getLine() returns a copy to keep.
|
|
816
|
+
*/
|
|
817
|
+
getLineView(y: number): readonly GhosttyCell[] | null {
|
|
818
|
+
if (y < 0 || y >= this._rows) return null;
|
|
819
|
+
this.update();
|
|
820
|
+
return this.rowViews[y];
|
|
821
|
+
}
|
|
822
|
+
|
|
795
823
|
isDirty(): boolean {
|
|
796
824
|
return this.update() !== DirtyState.NONE;
|
|
797
825
|
}
|
|
@@ -833,13 +861,41 @@ export class GhosttyTerminal {
|
|
|
833
861
|
|
|
834
862
|
/** Tracks the start of a row of the active area. */
|
|
835
863
|
trackRow(activeY: number): TrackedRow | null {
|
|
864
|
+
return this.trackLineStart(POINT_ACTIVE, activeY);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Whether the terminal may have discarded rows since the last call, which is
|
|
869
|
+
* the only way a tracked row loses its line: libghostty-vt prunes scrollback
|
|
870
|
+
* from its first page and resets a screen whole, and either marks every
|
|
871
|
+
* tracked row on that page stale. A tracked row at the top of each screen
|
|
872
|
+
* goes stale with them. A screen is watched from the first call made while it
|
|
873
|
+
* is active.
|
|
874
|
+
*/
|
|
875
|
+
rowsDiscarded(): boolean {
|
|
876
|
+
let discarded = false;
|
|
877
|
+
for (const [alternate, top] of this.screenTops) {
|
|
878
|
+
if (top.line() === 0) continue;
|
|
879
|
+
discarded = true;
|
|
880
|
+
top.free();
|
|
881
|
+
this.screenTops.delete(alternate);
|
|
882
|
+
}
|
|
883
|
+
const alternate = this.isAlternateScreen();
|
|
884
|
+
if (!this.screenTops.has(alternate)) {
|
|
885
|
+
const top = this.trackLineStart(POINT_SCREEN, 0);
|
|
886
|
+
if (top) this.screenTops.set(alternate, top);
|
|
887
|
+
}
|
|
888
|
+
return discarded;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
private trackLineStart(tag: PointTag, y: number): TrackedRow | null {
|
|
836
892
|
const abi = this.abi;
|
|
837
893
|
const ref = abi.with(abi.sizeOf('GhosttyPoint'), (point) => {
|
|
838
894
|
const view = abi.view();
|
|
839
|
-
view.setInt32(point, abi.enumValue('GhosttyPointTag',
|
|
895
|
+
view.setInt32(point, abi.enumValue('GhosttyPointTag', tag), true);
|
|
840
896
|
const coord = point + abi.offset('GhosttyPoint', 'value');
|
|
841
897
|
view.setUint16(coord + abi.offset('GhosttyPointCoordinate', 'x'), 0, true);
|
|
842
|
-
view.setUint32(coord + abi.offset('GhosttyPointCoordinate', 'y'),
|
|
898
|
+
view.setUint32(coord + abi.offset('GhosttyPointCoordinate', 'y'), y, true);
|
|
843
899
|
const slot = abi.call('ghostty_wasm_alloc_opaque');
|
|
844
900
|
try {
|
|
845
901
|
const result = abi.call('ghostty_terminal_grid_ref_track', this.handle, point, slot);
|
|
@@ -853,16 +909,11 @@ export class GhosttyTerminal {
|
|
|
853
909
|
return {
|
|
854
910
|
line: () => {
|
|
855
911
|
if (freed) return null;
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
out
|
|
862
|
-
);
|
|
863
|
-
if (result !== GHOSTTY_SUCCESS) return null;
|
|
864
|
-
return abi.view().getUint32(out + abi.offset('GhosttyPointCoordinate', 'y'), true);
|
|
865
|
-
});
|
|
912
|
+
const out = abi.scratch(abi.sizeOf('GhosttyPointCoordinate'));
|
|
913
|
+
const tag = abi.enumValue('GhosttyPointTag', POINT_SCREEN);
|
|
914
|
+
if (abi.call('ghostty_tracked_grid_ref_point', ref, tag, out) !== GHOSTTY_SUCCESS)
|
|
915
|
+
return null;
|
|
916
|
+
return abi.view().getUint32(out + abi.offset('GhosttyPointCoordinate', 'y'), true);
|
|
866
917
|
},
|
|
867
918
|
free: () => {
|
|
868
919
|
if (freed) return;
|
|
@@ -930,9 +981,19 @@ export class GhosttyTerminal {
|
|
|
930
981
|
}
|
|
931
982
|
|
|
932
983
|
isRowWrapped(row: number): boolean {
|
|
984
|
+
return this.rowContinues(POINT_ACTIVE, row);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** Whether a scrollback line continues the one before it; offset 0 is the oldest line. */
|
|
988
|
+
isScrollbackRowWrapped(offset: number): boolean {
|
|
989
|
+
if (offset < 0 || offset >= this.getScrollbackLength()) return false;
|
|
990
|
+
return this.rowContinues(POINT_HISTORY, offset);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
private rowContinues(point: PointSpace, row: number): boolean {
|
|
933
994
|
const abi = this.abi;
|
|
934
995
|
return (
|
|
935
|
-
this.gridRef(
|
|
996
|
+
this.gridRef(point, 0, row, (ref) =>
|
|
936
997
|
abi.with(8, (rowPtr) => {
|
|
937
998
|
if (abi.call('ghostty_grid_ref_row', ref, rowPtr) !== GHOSTTY_SUCCESS) return false;
|
|
938
999
|
const raw = abi.view().getBigUint64(rowPtr, true);
|
|
@@ -999,7 +1060,10 @@ export class GhosttyTerminal {
|
|
|
999
1060
|
*/
|
|
1000
1061
|
configure(config: GhosttyTerminalConfig): void {
|
|
1001
1062
|
const abi = this.abi;
|
|
1063
|
+
this.paletteStale = true;
|
|
1064
|
+
this.changed = true;
|
|
1002
1065
|
if (config.scrollbackLimit !== undefined) {
|
|
1066
|
+
this.setOption('SCROLLBACK_MAX_BYTES', 0);
|
|
1003
1067
|
abi.with(4, (ptr) => {
|
|
1004
1068
|
abi.view().setUint32(ptr, config.scrollbackLimit!, true);
|
|
1005
1069
|
this.setOption('SCROLLBACK_MAX_LINES', ptr);
|
|
@@ -1159,46 +1223,35 @@ export class GhosttyTerminal {
|
|
|
1159
1223
|
|
|
1160
1224
|
private readTerminalU32(data: string): number {
|
|
1161
1225
|
const abi = this.abi;
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
this.handle,
|
|
1166
|
-
abi.enumValue('GhosttyTerminalData', data),
|
|
1167
|
-
out
|
|
1168
|
-
);
|
|
1169
|
-
return abi.view().getUint32(out, true);
|
|
1170
|
-
});
|
|
1226
|
+
const out = abi.scratch(8);
|
|
1227
|
+
abi.check('ghostty_terminal_get', this.handle, abi.enumValue('GhosttyTerminalData', data), out);
|
|
1228
|
+
return abi.view().getUint32(out, true);
|
|
1171
1229
|
}
|
|
1172
1230
|
|
|
1173
1231
|
private readTerminalU16(data: string): number {
|
|
1174
1232
|
const abi = this.abi;
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
this.handle,
|
|
1179
|
-
abi.enumValue('GhosttyTerminalData', data),
|
|
1180
|
-
out
|
|
1181
|
-
);
|
|
1182
|
-
return abi.view().getUint16(out, true);
|
|
1183
|
-
});
|
|
1233
|
+
const out = abi.scratch(8);
|
|
1234
|
+
abi.check('ghostty_terminal_get', this.handle, abi.enumValue('GhosttyTerminalData', data), out);
|
|
1235
|
+
return abi.view().getUint16(out, true);
|
|
1184
1236
|
}
|
|
1185
1237
|
|
|
1186
1238
|
private readRenderU32(data: string): number {
|
|
1187
1239
|
const abi = this.abi;
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
});
|
|
1240
|
+
const out = abi.scratch(8);
|
|
1241
|
+
abi.check(
|
|
1242
|
+
'ghostty_render_state_get',
|
|
1243
|
+
this.renderState,
|
|
1244
|
+
abi.enumValue('GhosttyRenderStateData', data),
|
|
1245
|
+
out
|
|
1246
|
+
);
|
|
1247
|
+
return abi.view().getUint32(out, true);
|
|
1197
1248
|
}
|
|
1198
1249
|
|
|
1199
|
-
|
|
1250
|
+
/** Reads the colors cells are drawn with; the 256-color palette only when `palette`. */
|
|
1251
|
+
private readColors(palette: boolean): void {
|
|
1200
1252
|
const abi = this.abi;
|
|
1201
|
-
|
|
1253
|
+
if (palette) {
|
|
1254
|
+
const ptr = abi.scratchSized('GhosttyRenderStateColors');
|
|
1202
1255
|
abi.check(
|
|
1203
1256
|
'ghostty_render_state_get',
|
|
1204
1257
|
this.renderState,
|
|
@@ -1207,16 +1260,16 @@ export class GhosttyTerminal {
|
|
|
1207
1260
|
);
|
|
1208
1261
|
const view = abi.view();
|
|
1209
1262
|
const at = (f: string) => ptr + abi.offset('GhosttyRenderStateColors', f);
|
|
1210
|
-
const
|
|
1211
|
-
for (let i = 0; i < 256; i++)
|
|
1263
|
+
const colors: RGB[] = [];
|
|
1264
|
+
for (let i = 0; i < 256; i++) colors.push(rgbAt(view, at('palette') + i * 3));
|
|
1212
1265
|
this.colors = {
|
|
1213
1266
|
background: rgbAt(view, at('background')),
|
|
1214
1267
|
foreground: rgbAt(view, at('foreground')),
|
|
1215
1268
|
cursor: view.getUint8(at('cursor_has_value')) ? rgbAt(view, at('cursor')) : null,
|
|
1216
|
-
palette,
|
|
1269
|
+
palette: colors,
|
|
1217
1270
|
};
|
|
1218
|
-
}
|
|
1219
|
-
this.readTerminalColors();
|
|
1271
|
+
}
|
|
1272
|
+
this.readTerminalColors(palette);
|
|
1220
1273
|
}
|
|
1221
1274
|
|
|
1222
1275
|
/**
|
|
@@ -1224,28 +1277,27 @@ export class GhosttyTerminal {
|
|
|
1224
1277
|
* ghostty_terminal_set, so the effective colors (a program's OSC override,
|
|
1225
1278
|
* else the default) are read from the terminal itself.
|
|
1226
1279
|
*/
|
|
1227
|
-
private readTerminalColors(): void {
|
|
1280
|
+
private readTerminalColors(palette: boolean): void {
|
|
1228
1281
|
const abi = this.abi;
|
|
1229
1282
|
const data = (name: string) => abi.enumValue('GhosttyTerminalData', name);
|
|
1230
|
-
const rgb = (name: string): RGB | null =>
|
|
1231
|
-
abi.
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1283
|
+
const rgb = (name: string): RGB | null => {
|
|
1284
|
+
const ptr = abi.scratch(4);
|
|
1285
|
+
return abi.call('ghostty_terminal_get', this.handle, data(name), ptr) === GHOSTTY_SUCCESS
|
|
1286
|
+
? rgbAt(abi.view(), ptr)
|
|
1287
|
+
: null;
|
|
1288
|
+
};
|
|
1236
1289
|
this.colors.background = rgb('COLOR_BACKGROUND') ?? this.colors.background;
|
|
1237
1290
|
this.colors.foreground = rgb('COLOR_FOREGROUND') ?? this.colors.foreground;
|
|
1238
1291
|
this.colors.cursor = rgb('COLOR_CURSOR') ?? this.colors.cursor;
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
});
|
|
1292
|
+
if (!palette) return;
|
|
1293
|
+
const ptr = abi.scratch(256 * 3);
|
|
1294
|
+
if (
|
|
1295
|
+
abi.call('ghostty_terminal_get', this.handle, data('COLOR_PALETTE'), ptr) !== GHOSTTY_SUCCESS
|
|
1296
|
+
) {
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
const view = abi.view();
|
|
1300
|
+
for (let i = 0; i < 256; i++) this.colors.palette[i] = rgbAt(view, ptr + i * 3);
|
|
1249
1301
|
}
|
|
1250
1302
|
|
|
1251
1303
|
private extractRows(all: boolean): void {
|
|
@@ -1465,6 +1517,10 @@ export class GhosttyTerminal {
|
|
|
1465
1517
|
const total = this._cols * this._rows;
|
|
1466
1518
|
while (this.cellPool.length < total) this.cellPool.push(GhosttyTerminal.emptyCell());
|
|
1467
1519
|
this.cellPool.length = total;
|
|
1520
|
+
this.rowViews = [];
|
|
1521
|
+
for (let y = 0; y < this._rows; y++) {
|
|
1522
|
+
this.rowViews.push(this.cellPool.slice(y * this._cols, (y + 1) * this._cols));
|
|
1523
|
+
}
|
|
1468
1524
|
this.rowDirty = new Array(this._rows).fill(true);
|
|
1469
1525
|
this.dirty = DirtyState.FULL;
|
|
1470
1526
|
}
|
package/lib/input-handler.ts
CHANGED
|
@@ -409,8 +409,9 @@ export class InputHandler {
|
|
|
409
409
|
}
|
|
410
410
|
}
|
|
411
411
|
|
|
412
|
-
//
|
|
413
|
-
|
|
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') {
|
|
414
415
|
// Let the browser's native paste event fire
|
|
415
416
|
return;
|
|
416
417
|
}
|
package/lib/renderer.ts
CHANGED
|
@@ -17,6 +17,8 @@ import { CellFlags } from './types';
|
|
|
17
17
|
// Interface for objects that can be rendered
|
|
18
18
|
export interface IRenderable {
|
|
19
19
|
getLine(y: number): GhosttyCell[] | null;
|
|
20
|
+
/** A row's cells without a copy, read before the buffer next changes; getLine() when absent. */
|
|
21
|
+
getLineView?(y: number): readonly GhosttyCell[] | null;
|
|
20
22
|
getCursor(): { x: number; y: number; visible: boolean };
|
|
21
23
|
getDimensions(): { cols: number; rows: number };
|
|
22
24
|
isRowDirty(y: number): boolean;
|
|
@@ -247,6 +249,8 @@ export abstract class TerminalRenderer {
|
|
|
247
249
|
protected lastCursorPosition: { x: number; y: number } = { x: 0, y: 0 };
|
|
248
250
|
private lastViewportY = 0;
|
|
249
251
|
private fullRedrawPending = false;
|
|
252
|
+
/** The cursor and scrollbar the last finished frame showed. */
|
|
253
|
+
private shownOverlay: FrameOverlay | null = null;
|
|
250
254
|
|
|
251
255
|
/** The buffer of the frame being drawn, for grapheme lookups. */
|
|
252
256
|
protected currentBuffer: IRenderable | null = null;
|
|
@@ -290,7 +294,7 @@ export abstract class TerminalRenderer {
|
|
|
290
294
|
}
|
|
291
295
|
|
|
292
296
|
/** Draws one viewport row. */
|
|
293
|
-
protected abstract drawLine(line: GhosttyCell[], y: number, cols: number): void;
|
|
297
|
+
protected abstract drawLine(line: readonly GhosttyCell[], y: number, cols: number): void;
|
|
294
298
|
/** Whether the drawing surface no longer matches cols × rows. */
|
|
295
299
|
protected abstract surfaceMismatch(cols: number, rows: number): boolean;
|
|
296
300
|
/** Draws the cursor and scrollbar and presents the frame. */
|
|
@@ -334,14 +338,15 @@ export abstract class TerminalRenderer {
|
|
|
334
338
|
this.cursorVisible !== this.lastCursorVisible || this.focused !== this.lastFocused;
|
|
335
339
|
this.lastCursorVisible = this.cursorVisible;
|
|
336
340
|
this.lastFocused = this.focused;
|
|
337
|
-
|
|
341
|
+
// Scrolled back the cursor is not shown, and its row is not on screen where it is in the buffer.
|
|
342
|
+
if (this.cursorInRows && viewportY === 0 && (cursorMoved || blinked)) {
|
|
338
343
|
if (!forceAll && !buffer.isRowDirty(cursor.y)) {
|
|
339
|
-
const line =
|
|
344
|
+
const line = this.bufferLine(cursor.y);
|
|
340
345
|
if (line) this.drawLine(line, cursor.y, dims.cols);
|
|
341
346
|
}
|
|
342
347
|
if (cursorMoved && this.lastCursorPosition.y !== cursor.y) {
|
|
343
348
|
if (!forceAll && !buffer.isRowDirty(this.lastCursorPosition.y)) {
|
|
344
|
-
const line =
|
|
349
|
+
const line = this.bufferLine(this.lastCursorPosition.y);
|
|
345
350
|
if (line) this.drawLine(line, this.lastCursorPosition.y, dims.cols);
|
|
346
351
|
}
|
|
347
352
|
}
|
|
@@ -364,14 +369,19 @@ export abstract class TerminalRenderer {
|
|
|
364
369
|
}
|
|
365
370
|
}
|
|
366
371
|
|
|
367
|
-
const lineAt = (y: number): GhosttyCell[] | null => {
|
|
372
|
+
const lineAt = (y: number): readonly GhosttyCell[] | null => {
|
|
368
373
|
if (viewportY > 0) {
|
|
369
374
|
if (y < viewportY && scrollbackProvider) {
|
|
370
375
|
return scrollbackProvider.getScrollbackLine(scrollbackLength - Math.floor(viewportY) + y);
|
|
371
376
|
}
|
|
372
|
-
return
|
|
377
|
+
return this.bufferLine(y - Math.floor(viewportY));
|
|
373
378
|
}
|
|
374
|
-
return
|
|
379
|
+
return this.bufferLine(y);
|
|
380
|
+
};
|
|
381
|
+
// Scrolled back, viewport row y shows active row y - viewportY; rows above it show scrollback.
|
|
382
|
+
const rowDirty = (y: number): boolean => {
|
|
383
|
+
const active = y - Math.floor(viewportY);
|
|
384
|
+
return active >= 0 && buffer.isRowDirty(active);
|
|
375
385
|
};
|
|
376
386
|
|
|
377
387
|
const hyperlinkRows = new Set<number>();
|
|
@@ -401,12 +411,7 @@ export abstract class TerminalRenderer {
|
|
|
401
411
|
|
|
402
412
|
const rowsToRender = new Set<number>();
|
|
403
413
|
for (let y = 0; y < dims.rows; y++) {
|
|
404
|
-
const needsRender =
|
|
405
|
-
viewportY > 0 ||
|
|
406
|
-
forceAll ||
|
|
407
|
-
buffer.isRowDirty(y) ||
|
|
408
|
-
selectionRows.has(y) ||
|
|
409
|
-
hyperlinkRows.has(y);
|
|
414
|
+
const needsRender = forceAll || rowDirty(y) || selectionRows.has(y) || hyperlinkRows.has(y);
|
|
410
415
|
if (needsRender) {
|
|
411
416
|
rowsToRender.add(y);
|
|
412
417
|
if (y > 0) rowsToRender.add(y - 1);
|
|
@@ -419,7 +424,7 @@ export abstract class TerminalRenderer {
|
|
|
419
424
|
if (line) this.drawLine(line, y, dims.cols);
|
|
420
425
|
}
|
|
421
426
|
|
|
422
|
-
|
|
427
|
+
const overlay: FrameOverlay = {
|
|
423
428
|
cursorX: cursor.x,
|
|
424
429
|
cursorY: cursor.y,
|
|
425
430
|
showCursor: viewportY === 0 && cursor.visible && this.cursorVisible,
|
|
@@ -429,11 +434,23 @@ export abstract class TerminalRenderer {
|
|
|
429
434
|
cols: dims.cols,
|
|
430
435
|
rows: dims.rows,
|
|
431
436
|
scrollbarOpacity: scrollbackProvider ? scrollbarOpacity : 0,
|
|
432
|
-
}
|
|
437
|
+
};
|
|
438
|
+
// A frame with no row drawn and the same cursor and scrollbar has nothing to show.
|
|
439
|
+
if (rowsToRender.size > 0 || !sameOverlay(overlay, this.shownOverlay)) {
|
|
440
|
+
this.finishFrame(overlay);
|
|
441
|
+
this.shownOverlay = overlay;
|
|
442
|
+
}
|
|
433
443
|
this.lastCursorPosition = { x: cursor.x, y: cursor.y };
|
|
434
444
|
buffer.clearDirty();
|
|
435
445
|
}
|
|
436
446
|
|
|
447
|
+
/** A row of the buffer being drawn, without a copy when the buffer offers one. */
|
|
448
|
+
protected bufferLine(y: number): readonly GhosttyCell[] | null {
|
|
449
|
+
const buffer = this.currentBuffer;
|
|
450
|
+
if (!buffer) return null;
|
|
451
|
+
return buffer.getLineView ? buffer.getLineView(y) : buffer.getLine(y);
|
|
452
|
+
}
|
|
453
|
+
|
|
437
454
|
// ==========================================================================
|
|
438
455
|
// Shared helpers
|
|
439
456
|
// ==========================================================================
|
|
@@ -441,6 +458,7 @@ export abstract class TerminalRenderer {
|
|
|
441
458
|
/** Makes the next frame redraw every row. */
|
|
442
459
|
protected requestFullRedraw(): void {
|
|
443
460
|
this.fullRedrawPending = true;
|
|
461
|
+
this.shownOverlay = null;
|
|
444
462
|
}
|
|
445
463
|
|
|
446
464
|
/** The text of a cell: its grapheme cluster when it has one, else its codepoint. */
|
|
@@ -625,7 +643,9 @@ export abstract class TerminalRenderer {
|
|
|
625
643
|
}
|
|
626
644
|
|
|
627
645
|
public setCursorStyle(style: CursorStyle): void {
|
|
646
|
+
if (style === this.cursorStyle) return;
|
|
628
647
|
this.cursorStyle = style;
|
|
648
|
+
this.requestFullRedraw();
|
|
629
649
|
}
|
|
630
650
|
|
|
631
651
|
public setCursorBlink(enabled: boolean): void {
|
|
@@ -700,6 +720,21 @@ export function cssRgb(css: string): [number, number, number] {
|
|
|
700
720
|
return [0, 0, 0];
|
|
701
721
|
}
|
|
702
722
|
|
|
723
|
+
function sameOverlay(a: FrameOverlay, b: FrameOverlay | null): boolean {
|
|
724
|
+
return (
|
|
725
|
+
b !== null &&
|
|
726
|
+
a.cursorX === b.cursorX &&
|
|
727
|
+
a.cursorY === b.cursorY &&
|
|
728
|
+
a.showCursor === b.showCursor &&
|
|
729
|
+
a.focused === b.focused &&
|
|
730
|
+
a.viewportY === b.viewportY &&
|
|
731
|
+
a.scrollbackLength === b.scrollbackLength &&
|
|
732
|
+
a.cols === b.cols &&
|
|
733
|
+
a.rows === b.rows &&
|
|
734
|
+
a.scrollbarOpacity === b.scrollbarOpacity
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
703
738
|
function sameRange(a: LinkRange | null, b: LinkRange | null): boolean {
|
|
704
739
|
if (a === b) return true;
|
|
705
740
|
if (!a || !b) return false;
|
|
@@ -774,7 +809,7 @@ export class CanvasRenderer extends TerminalRenderer {
|
|
|
774
809
|
* of one color are one rect; runs of plain ASCII in one style are one
|
|
775
810
|
* fillText, spaced to the cell grid with letterSpacing.
|
|
776
811
|
*/
|
|
777
|
-
protected drawLine(line: GhosttyCell[], y: number, cols: number): void {
|
|
812
|
+
protected drawLine(line: readonly GhosttyCell[], y: number, cols: number): void {
|
|
778
813
|
const ctx = this.ctx;
|
|
779
814
|
const cellW = this.metrics.width;
|
|
780
815
|
const cellH = this.metrics.height;
|
|
@@ -1007,7 +1042,7 @@ export class CanvasRenderer extends TerminalRenderer {
|
|
|
1007
1042
|
);
|
|
1008
1043
|
} else if (this.cursorStyle === 'block') {
|
|
1009
1044
|
this.ctx.fillRect(cursorX, cursorY, this.metrics.width, this.metrics.height);
|
|
1010
|
-
const line = this.
|
|
1045
|
+
const line = this.bufferLine(y);
|
|
1011
1046
|
if (line?.[x]) {
|
|
1012
1047
|
this.ctx.save();
|
|
1013
1048
|
this.ctx.beginPath();
|
package/lib/selection-manager.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* - Mouse drag selection
|
|
6
6
|
* - Double-click word selection
|
|
7
7
|
* - Text extraction from terminal buffer
|
|
8
|
-
* - Automatic clipboard copy
|
|
9
8
|
* - Visual selection highlighting (integrated into CanvasRenderer cell rendering)
|
|
10
9
|
* - Auto-scroll during drag selection
|
|
11
10
|
*/
|
|
@@ -588,11 +587,7 @@ export class SelectionManager {
|
|
|
588
587
|
}
|
|
589
588
|
|
|
590
589
|
if (this.hasSelection()) {
|
|
591
|
-
|
|
592
|
-
if (text) {
|
|
593
|
-
this.copyToClipboard(text);
|
|
594
|
-
this.selectionChangedEmitter.fire();
|
|
595
|
-
}
|
|
590
|
+
if (this.getSelection()) this.selectionChangedEmitter.fire();
|
|
596
591
|
}
|
|
597
592
|
}
|
|
598
593
|
};
|
|
@@ -613,11 +608,7 @@ export class SelectionManager {
|
|
|
613
608
|
this.selectionEnd = { col: word.endCol, absoluteRow };
|
|
614
609
|
this.requestRender();
|
|
615
610
|
|
|
616
|
-
|
|
617
|
-
if (text) {
|
|
618
|
-
this.copyToClipboard(text);
|
|
619
|
-
this.selectionChangedEmitter.fire();
|
|
620
|
-
}
|
|
611
|
+
if (this.getSelection()) this.selectionChangedEmitter.fire();
|
|
621
612
|
}
|
|
622
613
|
} else if (e.detail >= 3) {
|
|
623
614
|
// Triple-click (or more) - select line content (like native Ghostty)
|
|
@@ -654,11 +645,7 @@ export class SelectionManager {
|
|
|
654
645
|
this.selectionEnd = { col: endCol, absoluteRow };
|
|
655
646
|
this.requestRender();
|
|
656
647
|
|
|
657
|
-
|
|
658
|
-
if (text) {
|
|
659
|
-
this.copyToClipboard(text);
|
|
660
|
-
this.selectionChangedEmitter.fire();
|
|
661
|
-
}
|
|
648
|
+
if (this.getSelection()) this.selectionChangedEmitter.fire();
|
|
662
649
|
}
|
|
663
650
|
}
|
|
664
651
|
});
|
package/lib/terminal.ts
CHANGED
|
@@ -263,7 +263,7 @@ export class Terminal implements ITerminalCore {
|
|
|
263
263
|
this.wasmTerm?.configure(this.themeConfig());
|
|
264
264
|
if (this.renderer && this.wasmTerm) {
|
|
265
265
|
this.renderer.setTheme(this.options.theme);
|
|
266
|
-
this.
|
|
266
|
+
this.draw(true, this.scrollbarOpacity);
|
|
267
267
|
}
|
|
268
268
|
break;
|
|
269
269
|
|
|
@@ -283,7 +283,7 @@ export class Terminal implements ITerminalCore {
|
|
|
283
283
|
case 'minimumContrastRatio':
|
|
284
284
|
if (this.renderer && this.wasmTerm) {
|
|
285
285
|
this.renderer.setMinimumContrastRatio(this.options.minimumContrastRatio);
|
|
286
|
-
this.
|
|
286
|
+
this.draw(true, this.scrollbarOpacity);
|
|
287
287
|
}
|
|
288
288
|
break;
|
|
289
289
|
|
|
@@ -329,7 +329,7 @@ export class Terminal implements ITerminalCore {
|
|
|
329
329
|
this.renderer.resize(this.cols, this.rows);
|
|
330
330
|
|
|
331
331
|
// Force full re-render with new font
|
|
332
|
-
this.
|
|
332
|
+
this.draw(true);
|
|
333
333
|
}
|
|
334
334
|
|
|
335
335
|
/**
|
|
@@ -578,13 +578,10 @@ export class Terminal implements ITerminalCore {
|
|
|
578
578
|
parent.addEventListener('wheel', this.handleWheel, { passive: false, capture: true });
|
|
579
579
|
|
|
580
580
|
// Render initial blank screen (force full redraw)
|
|
581
|
-
this.
|
|
581
|
+
this.draw(true, this.scrollbarOpacity);
|
|
582
582
|
|
|
583
583
|
// Start render loop
|
|
584
584
|
this.startRenderLoop();
|
|
585
|
-
|
|
586
|
-
// Focus input (auto-focus so user can start typing immediately)
|
|
587
|
-
this.focus();
|
|
588
585
|
} catch (error) {
|
|
589
586
|
// Clean up on error
|
|
590
587
|
this.isOpen = false;
|
|
@@ -641,10 +638,16 @@ export class Terminal implements ITerminalCore {
|
|
|
641
638
|
|
|
642
639
|
/** Replaces a WebGL renderer whose context was lost with a canvas renderer on the same canvas. */
|
|
643
640
|
private fallBackToCanvas(): void {
|
|
644
|
-
if (!this.
|
|
645
|
-
|
|
646
|
-
this.
|
|
647
|
-
|
|
641
|
+
if (!this.canvas || !(this.renderer instanceof WebglRenderer)) return;
|
|
642
|
+
this.installRenderer(new CanvasRenderer(this.canvas, this.rendererOptions()));
|
|
643
|
+
this.wake();
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Disposes the current renderer and draws with `renderer` from now on, in its state. */
|
|
647
|
+
private installRenderer(renderer: TerminalRenderer): void {
|
|
648
|
+
const old = this.renderer!;
|
|
649
|
+
const hoveredLink = old.getHoveredHyperlinkId();
|
|
650
|
+
old.dispose();
|
|
648
651
|
renderer.onNeedsFrame = () => this.requestFrame();
|
|
649
652
|
renderer.setHoveredHyperlinkId(hoveredLink);
|
|
650
653
|
renderer.setFocused(this.focused);
|
|
@@ -655,10 +658,47 @@ export class Terminal implements ITerminalCore {
|
|
|
655
658
|
}
|
|
656
659
|
this.renderer = renderer;
|
|
657
660
|
renderer.resize(this.cols, this.rows);
|
|
658
|
-
|
|
659
|
-
|
|
661
|
+
this.draw(true, this.scrollbarOpacity);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private paused = false;
|
|
665
|
+
private rendererReleased = false;
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Pauses drawing, for a terminal that is not on screen. Writes still parse and
|
|
669
|
+
* the buffer, markers and events stay current, but no frame is drawn and
|
|
670
|
+
* onRender does not fire until resumed, which redraws the whole screen once.
|
|
671
|
+
* `releaseRenderer` also gives up a WebGL context until then (browsers keep
|
|
672
|
+
* only a few), at the cost of rebuilding the glyph atlas on resume.
|
|
673
|
+
*/
|
|
674
|
+
setPaused(paused: boolean, options: { releaseRenderer?: boolean } = {}): void {
|
|
675
|
+
if (paused) {
|
|
676
|
+
this.paused = true;
|
|
677
|
+
this.cancelRenderLoop();
|
|
678
|
+
if (options.releaseRenderer && this.canvas && this.renderer instanceof WebglRenderer) {
|
|
679
|
+
// A canvas renderer that never draws keeps metrics and sizing until resume.
|
|
680
|
+
this.installRenderer(new CanvasRenderer(this.canvas, this.rendererOptions()));
|
|
681
|
+
this.rendererReleased = true;
|
|
682
|
+
}
|
|
683
|
+
return;
|
|
660
684
|
}
|
|
661
|
-
this.
|
|
685
|
+
if (!this.paused) return;
|
|
686
|
+
this.paused = false;
|
|
687
|
+
if (!this.isOpen || this.isDisposed) return;
|
|
688
|
+
if (this.rendererReleased) {
|
|
689
|
+
this.rendererReleased = false;
|
|
690
|
+
this.installRenderer(this.createRenderer());
|
|
691
|
+
} else {
|
|
692
|
+
this.draw(true, this.scrollbarOpacity);
|
|
693
|
+
}
|
|
694
|
+
this.renderEmitter.fire({ start: 0, end: this.rows - 1 });
|
|
695
|
+
this.requestFrame();
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Draws a frame now unless paused; forceAll redraws every row. */
|
|
699
|
+
private draw(forceAll: boolean, scrollbarOpacity?: number): void {
|
|
700
|
+
if (this.paused || !this.renderer || !this.wasmTerm) return;
|
|
701
|
+
this.renderer.render(this.wasmTerm, forceAll, this.viewportY, this, scrollbarOpacity);
|
|
662
702
|
}
|
|
663
703
|
|
|
664
704
|
/**
|
|
@@ -702,15 +742,14 @@ export class Terminal implements ITerminalCore {
|
|
|
702
742
|
|
|
703
743
|
// Call callback if provided
|
|
704
744
|
if (callback) {
|
|
705
|
-
//
|
|
706
|
-
|
|
745
|
+
// The data is parsed by now, so run the callback as soon as this task
|
|
746
|
+
// ends, as xterm.js does once a write is processed.
|
|
747
|
+
queueMicrotask(callback);
|
|
707
748
|
}
|
|
708
749
|
|
|
709
750
|
if (this.awaitingEcho) {
|
|
710
751
|
this.awaitingEcho = false;
|
|
711
|
-
|
|
712
|
-
this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
|
|
713
|
-
}
|
|
752
|
+
this.draw(false, this.scrollbarOpacity);
|
|
714
753
|
}
|
|
715
754
|
|
|
716
755
|
// Render will happen on next animation frame
|
|
@@ -802,6 +841,7 @@ export class Terminal implements ITerminalCore {
|
|
|
802
841
|
|
|
803
842
|
// Resize WASM terminal (may reallocate buffers, invalidating TypedArray views)
|
|
804
843
|
this.wasmTerm!.resize(cols, rows);
|
|
844
|
+
this.refreshMarkers(false);
|
|
805
845
|
|
|
806
846
|
// Resize renderer
|
|
807
847
|
this.renderer!.resize(cols, rows);
|
|
@@ -810,7 +850,7 @@ export class Terminal implements ITerminalCore {
|
|
|
810
850
|
this.resizeEmitter.fire({ cols, rows });
|
|
811
851
|
|
|
812
852
|
// Force full render
|
|
813
|
-
this.
|
|
853
|
+
this.draw(true);
|
|
814
854
|
} catch (e) {
|
|
815
855
|
console.error('Terminal resize failed:', e);
|
|
816
856
|
}
|
|
@@ -857,16 +897,7 @@ export class Terminal implements ITerminalCore {
|
|
|
857
897
|
*/
|
|
858
898
|
focus(): void {
|
|
859
899
|
this.wake();
|
|
860
|
-
if (this.isOpen && this.element)
|
|
861
|
-
// Focus immediately for immediate keyboard/wheel event handling
|
|
862
|
-
this.element.focus();
|
|
863
|
-
|
|
864
|
-
// Also schedule a delayed focus as backup to ensure it sticks
|
|
865
|
-
// (some browsers may need this if DOM isn't fully settled)
|
|
866
|
-
setTimeout(() => {
|
|
867
|
-
this.element?.focus();
|
|
868
|
-
}, 0);
|
|
869
|
-
}
|
|
900
|
+
if (this.isOpen && this.element) this.element.focus();
|
|
870
901
|
}
|
|
871
902
|
|
|
872
903
|
/**
|
|
@@ -1282,12 +1313,12 @@ export class Terminal implements ITerminalCore {
|
|
|
1282
1313
|
* Start the render loop
|
|
1283
1314
|
*/
|
|
1284
1315
|
private startRenderLoop(): void {
|
|
1285
|
-
if (this.animationFrameId) return;
|
|
1316
|
+
if (this.animationFrameId || this.paused) return;
|
|
1286
1317
|
const loop = () => {
|
|
1287
1318
|
this.animationFrameId = undefined;
|
|
1288
1319
|
if (this.isDisposed || !this.isOpen) return;
|
|
1289
1320
|
const drew = this.wasmTerm!.update() !== DirtyState.NONE;
|
|
1290
|
-
this.
|
|
1321
|
+
this.draw(false, this.scrollbarOpacity);
|
|
1291
1322
|
if (drew) this.renderEmitter.fire({ start: 0, end: this.rows - 1 });
|
|
1292
1323
|
const cursor = this.wasmTerm!.getCursor();
|
|
1293
1324
|
if (cursor.y !== this.lastCursorY) {
|
|
@@ -1900,9 +1931,7 @@ export class Terminal implements ITerminalCore {
|
|
|
1900
1931
|
this.scrollbarOpacity = progress;
|
|
1901
1932
|
|
|
1902
1933
|
// Trigger render to show updated opacity
|
|
1903
|
-
|
|
1904
|
-
this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
|
|
1905
|
-
}
|
|
1934
|
+
this.draw(false, this.scrollbarOpacity);
|
|
1906
1935
|
|
|
1907
1936
|
if (progress < 1) {
|
|
1908
1937
|
requestAnimationFrame(animate);
|
|
@@ -1924,9 +1953,7 @@ export class Terminal implements ITerminalCore {
|
|
|
1924
1953
|
this.scrollbarOpacity = startOpacity * (1 - progress);
|
|
1925
1954
|
|
|
1926
1955
|
// Trigger render to show updated opacity
|
|
1927
|
-
|
|
1928
|
-
this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
|
|
1929
|
-
}
|
|
1956
|
+
this.draw(false, this.scrollbarOpacity);
|
|
1930
1957
|
|
|
1931
1958
|
if (progress < 1) {
|
|
1932
1959
|
requestAnimationFrame(animate);
|
|
@@ -1934,9 +1961,7 @@ export class Terminal implements ITerminalCore {
|
|
|
1934
1961
|
this.scrollbarVisible = false;
|
|
1935
1962
|
this.scrollbarOpacity = 0;
|
|
1936
1963
|
// Final render to clear scrollbar completely
|
|
1937
|
-
|
|
1938
|
-
this.renderer.render(this.wasmTerm, false, this.viewportY, this, 0);
|
|
1939
|
-
}
|
|
1964
|
+
this.draw(false, 0);
|
|
1940
1965
|
}
|
|
1941
1966
|
};
|
|
1942
1967
|
animate();
|
|
@@ -1984,6 +2009,8 @@ export class Terminal implements ITerminalCore {
|
|
|
1984
2009
|
if (!this.wasmTerm) return undefined;
|
|
1985
2010
|
const y = this.wasmTerm.cursorPosition().y + cursorYOffset;
|
|
1986
2011
|
if (y < 0 || y >= this.rows) return undefined;
|
|
2012
|
+
// Starts watching the active screen for discarded rows before the marker exists.
|
|
2013
|
+
if (this.wasmTerm.rowsDiscarded()) this.refreshMarkers(true);
|
|
1987
2014
|
const row = this.wasmTerm.trackRow(y);
|
|
1988
2015
|
if (!row) return undefined;
|
|
1989
2016
|
const marker = new Marker(row);
|
|
@@ -2076,8 +2103,10 @@ export class Terminal implements ITerminalCore {
|
|
|
2076
2103
|
if (alternate !== this.lastAlternate) {
|
|
2077
2104
|
this.lastAlternate = alternate;
|
|
2078
2105
|
(this.buffer as BufferNamespace)._fireBufferChange(this.buffer.active);
|
|
2106
|
+
this.refreshMarkers(true);
|
|
2107
|
+
} else {
|
|
2108
|
+
this.refreshMarkers(false);
|
|
2079
2109
|
}
|
|
2080
|
-
for (const marker of [...this.markers]) marker.refresh();
|
|
2081
2110
|
this.writeParsedEmitter.fire();
|
|
2082
2111
|
this.wake();
|
|
2083
2112
|
}
|
|
@@ -2099,6 +2128,13 @@ export class Terminal implements ITerminalCore {
|
|
|
2099
2128
|
this.scrollEmitter.fire(this.buffer.active.viewportY);
|
|
2100
2129
|
}
|
|
2101
2130
|
|
|
2131
|
+
/** Disposes the markers whose line is gone; only rows the engine discarded can be. */
|
|
2132
|
+
private refreshMarkers(force: boolean): void {
|
|
2133
|
+
if (this.markers.size === 0 || !this.wasmTerm) return;
|
|
2134
|
+
if (!this.wasmTerm.rowsDiscarded() && !force) return;
|
|
2135
|
+
for (const marker of [...this.markers]) marker.refresh();
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2102
2138
|
private disposeMarkers(): void {
|
|
2103
2139
|
for (const marker of [...this.markers]) marker.dispose();
|
|
2104
2140
|
this.markers.clear();
|
package/lib/webgl-renderer.ts
CHANGED
|
@@ -524,7 +524,7 @@ export class WebglRenderer extends TerminalRenderer {
|
|
|
524
524
|
// Rows
|
|
525
525
|
// ==========================================================================
|
|
526
526
|
|
|
527
|
-
protected drawLine(line: GhosttyCell[], y: number, cols: number): void {
|
|
527
|
+
protected drawLine(line: readonly GhosttyCell[], y: number, cols: number): void {
|
|
528
528
|
if (y < 0 || y >= this.rows) return;
|
|
529
529
|
const cells = this.cols * this.rows;
|
|
530
530
|
const rects = this.rectWords;
|
|
@@ -721,7 +721,7 @@ export class WebglRenderer extends TerminalRenderer {
|
|
|
721
721
|
colors[4] = theme.cursor;
|
|
722
722
|
key = `${left},${top},${this.cursorStyle},${overlay.focused}`;
|
|
723
723
|
if (overlay.focused && this.cursorStyle === 'block') {
|
|
724
|
-
const cell = this.
|
|
724
|
+
const cell = this.bufferLine(overlay.cursorY)?.[overlay.cursorX];
|
|
725
725
|
if (cell && !isBlank(cell) && !(cell.flags & CellFlags.INVISIBLE)) {
|
|
726
726
|
const entry = this.glyphFor(cell, overlay.cursorX, overlay.cursorY);
|
|
727
727
|
if (entry) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aurigax-ai/ghostty-web",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Ghostty's terminal engine (libghostty-vt) in the browser: WebAssembly build, an xterm.js-shaped API and WebGL and canvas renderers. A hard fork of coder/ghostty-web.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.ts",
|