@aurigax-ai/ghostty-web 0.3.1 → 0.3.3
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/ghostty.ts +140 -95
- package/lib/input-handler.ts +1 -10
- package/lib/renderer.ts +52 -17
- package/lib/terminal.ts +81 -28
- 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/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;
|
|
@@ -1009,6 +1060,8 @@ export class GhosttyTerminal {
|
|
|
1009
1060
|
*/
|
|
1010
1061
|
configure(config: GhosttyTerminalConfig): void {
|
|
1011
1062
|
const abi = this.abi;
|
|
1063
|
+
this.paletteStale = true;
|
|
1064
|
+
this.changed = true;
|
|
1012
1065
|
if (config.scrollbackLimit !== undefined) {
|
|
1013
1066
|
this.setOption('SCROLLBACK_MAX_BYTES', 0);
|
|
1014
1067
|
abi.with(4, (ptr) => {
|
|
@@ -1170,46 +1223,35 @@ export class GhosttyTerminal {
|
|
|
1170
1223
|
|
|
1171
1224
|
private readTerminalU32(data: string): number {
|
|
1172
1225
|
const abi = this.abi;
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
this.handle,
|
|
1177
|
-
abi.enumValue('GhosttyTerminalData', data),
|
|
1178
|
-
out
|
|
1179
|
-
);
|
|
1180
|
-
return abi.view().getUint32(out, true);
|
|
1181
|
-
});
|
|
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);
|
|
1182
1229
|
}
|
|
1183
1230
|
|
|
1184
1231
|
private readTerminalU16(data: string): number {
|
|
1185
1232
|
const abi = this.abi;
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
this.handle,
|
|
1190
|
-
abi.enumValue('GhosttyTerminalData', data),
|
|
1191
|
-
out
|
|
1192
|
-
);
|
|
1193
|
-
return abi.view().getUint16(out, true);
|
|
1194
|
-
});
|
|
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);
|
|
1195
1236
|
}
|
|
1196
1237
|
|
|
1197
1238
|
private readRenderU32(data: string): number {
|
|
1198
1239
|
const abi = this.abi;
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
});
|
|
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);
|
|
1208
1248
|
}
|
|
1209
1249
|
|
|
1210
|
-
|
|
1250
|
+
/** Reads the colors cells are drawn with; the 256-color palette only when `palette`. */
|
|
1251
|
+
private readColors(palette: boolean): void {
|
|
1211
1252
|
const abi = this.abi;
|
|
1212
|
-
|
|
1253
|
+
if (palette) {
|
|
1254
|
+
const ptr = abi.scratchSized('GhosttyRenderStateColors');
|
|
1213
1255
|
abi.check(
|
|
1214
1256
|
'ghostty_render_state_get',
|
|
1215
1257
|
this.renderState,
|
|
@@ -1218,16 +1260,16 @@ export class GhosttyTerminal {
|
|
|
1218
1260
|
);
|
|
1219
1261
|
const view = abi.view();
|
|
1220
1262
|
const at = (f: string) => ptr + abi.offset('GhosttyRenderStateColors', f);
|
|
1221
|
-
const
|
|
1222
|
-
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));
|
|
1223
1265
|
this.colors = {
|
|
1224
1266
|
background: rgbAt(view, at('background')),
|
|
1225
1267
|
foreground: rgbAt(view, at('foreground')),
|
|
1226
1268
|
cursor: view.getUint8(at('cursor_has_value')) ? rgbAt(view, at('cursor')) : null,
|
|
1227
|
-
palette,
|
|
1269
|
+
palette: colors,
|
|
1228
1270
|
};
|
|
1229
|
-
}
|
|
1230
|
-
this.readTerminalColors();
|
|
1271
|
+
}
|
|
1272
|
+
this.readTerminalColors(palette);
|
|
1231
1273
|
}
|
|
1232
1274
|
|
|
1233
1275
|
/**
|
|
@@ -1235,28 +1277,27 @@ export class GhosttyTerminal {
|
|
|
1235
1277
|
* ghostty_terminal_set, so the effective colors (a program's OSC override,
|
|
1236
1278
|
* else the default) are read from the terminal itself.
|
|
1237
1279
|
*/
|
|
1238
|
-
private readTerminalColors(): void {
|
|
1280
|
+
private readTerminalColors(palette: boolean): void {
|
|
1239
1281
|
const abi = this.abi;
|
|
1240
1282
|
const data = (name: string) => abi.enumValue('GhosttyTerminalData', name);
|
|
1241
|
-
const rgb = (name: string): RGB | null =>
|
|
1242
|
-
abi.
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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
|
+
};
|
|
1247
1289
|
this.colors.background = rgb('COLOR_BACKGROUND') ?? this.colors.background;
|
|
1248
1290
|
this.colors.foreground = rgb('COLOR_FOREGROUND') ?? this.colors.foreground;
|
|
1249
1291
|
this.colors.cursor = rgb('COLOR_CURSOR') ?? this.colors.cursor;
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
});
|
|
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);
|
|
1260
1301
|
}
|
|
1261
1302
|
|
|
1262
1303
|
private extractRows(all: boolean): void {
|
|
@@ -1476,6 +1517,10 @@ export class GhosttyTerminal {
|
|
|
1476
1517
|
const total = this._cols * this._rows;
|
|
1477
1518
|
while (this.cellPool.length < total) this.cellPool.push(GhosttyTerminal.emptyCell());
|
|
1478
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
|
+
}
|
|
1479
1524
|
this.rowDirty = new Array(this._rows).fill(true);
|
|
1480
1525
|
this.dirty = DirtyState.FULL;
|
|
1481
1526
|
}
|
package/lib/input-handler.ts
CHANGED
|
@@ -192,7 +192,6 @@ export class InputHandler {
|
|
|
192
192
|
private mousedownListener: ((e: MouseEvent) => void) | null = null;
|
|
193
193
|
private mouseupListener: ((e: MouseEvent) => void) | null = null;
|
|
194
194
|
private mousemoveListener: ((e: MouseEvent) => void) | null = null;
|
|
195
|
-
private wheelListener: ((e: WheelEvent) => void) | null = null;
|
|
196
195
|
private isComposing = false;
|
|
197
196
|
private isDisposed = false;
|
|
198
197
|
/**
|
|
@@ -319,9 +318,6 @@ export class InputHandler {
|
|
|
319
318
|
|
|
320
319
|
this.mousemoveListener = this.handleMouseMove.bind(this);
|
|
321
320
|
this.container.addEventListener('mousemove', this.mousemoveListener);
|
|
322
|
-
|
|
323
|
-
this.wheelListener = this.handleWheel.bind(this);
|
|
324
|
-
this.container.addEventListener('wheel', this.wheelListener, { passive: false });
|
|
325
321
|
}
|
|
326
322
|
|
|
327
323
|
/**
|
|
@@ -925,7 +921,7 @@ export class InputHandler {
|
|
|
925
921
|
/**
|
|
926
922
|
* Handle wheel event (scroll)
|
|
927
923
|
*/
|
|
928
|
-
|
|
924
|
+
handleWheel(event: WheelEvent): void {
|
|
929
925
|
if (this.isDisposed) return;
|
|
930
926
|
if (!this.mouseConfig?.hasMouseTracking()) return;
|
|
931
927
|
|
|
@@ -1129,11 +1125,6 @@ export class InputHandler {
|
|
|
1129
1125
|
this.mousemoveListener = null;
|
|
1130
1126
|
}
|
|
1131
1127
|
|
|
1132
|
-
if (this.wheelListener) {
|
|
1133
|
-
this.container.removeEventListener('wheel', this.wheelListener);
|
|
1134
|
-
this.wheelListener = null;
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
1128
|
this.isDisposed = true;
|
|
1138
1129
|
}
|
|
1139
1130
|
|
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/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,7 +578,7 @@ 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();
|
|
@@ -638,10 +638,16 @@ export class Terminal implements ITerminalCore {
|
|
|
638
638
|
|
|
639
639
|
/** Replaces a WebGL renderer whose context was lost with a canvas renderer on the same canvas. */
|
|
640
640
|
private fallBackToCanvas(): void {
|
|
641
|
-
if (!this.
|
|
642
|
-
|
|
643
|
-
this.
|
|
644
|
-
|
|
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();
|
|
645
651
|
renderer.onNeedsFrame = () => this.requestFrame();
|
|
646
652
|
renderer.setHoveredHyperlinkId(hoveredLink);
|
|
647
653
|
renderer.setFocused(this.focused);
|
|
@@ -652,10 +658,47 @@ export class Terminal implements ITerminalCore {
|
|
|
652
658
|
}
|
|
653
659
|
this.renderer = renderer;
|
|
654
660
|
renderer.resize(this.cols, this.rows);
|
|
655
|
-
|
|
656
|
-
|
|
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;
|
|
657
684
|
}
|
|
658
|
-
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);
|
|
659
702
|
}
|
|
660
703
|
|
|
661
704
|
/**
|
|
@@ -706,9 +749,7 @@ export class Terminal implements ITerminalCore {
|
|
|
706
749
|
|
|
707
750
|
if (this.awaitingEcho) {
|
|
708
751
|
this.awaitingEcho = false;
|
|
709
|
-
|
|
710
|
-
this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
|
|
711
|
-
}
|
|
752
|
+
this.draw(false, this.scrollbarOpacity);
|
|
712
753
|
}
|
|
713
754
|
|
|
714
755
|
// Render will happen on next animation frame
|
|
@@ -800,6 +841,7 @@ export class Terminal implements ITerminalCore {
|
|
|
800
841
|
|
|
801
842
|
// Resize WASM terminal (may reallocate buffers, invalidating TypedArray views)
|
|
802
843
|
this.wasmTerm!.resize(cols, rows);
|
|
844
|
+
this.refreshMarkers(false);
|
|
803
845
|
|
|
804
846
|
// Resize renderer
|
|
805
847
|
this.renderer!.resize(cols, rows);
|
|
@@ -808,7 +850,7 @@ export class Terminal implements ITerminalCore {
|
|
|
808
850
|
this.resizeEmitter.fire({ cols, rows });
|
|
809
851
|
|
|
810
852
|
// Force full render
|
|
811
|
-
this.
|
|
853
|
+
this.draw(true);
|
|
812
854
|
} catch (e) {
|
|
813
855
|
console.error('Terminal resize failed:', e);
|
|
814
856
|
}
|
|
@@ -1271,12 +1313,12 @@ export class Terminal implements ITerminalCore {
|
|
|
1271
1313
|
* Start the render loop
|
|
1272
1314
|
*/
|
|
1273
1315
|
private startRenderLoop(): void {
|
|
1274
|
-
if (this.animationFrameId) return;
|
|
1316
|
+
if (this.animationFrameId || this.paused) return;
|
|
1275
1317
|
const loop = () => {
|
|
1276
1318
|
this.animationFrameId = undefined;
|
|
1277
1319
|
if (this.isDisposed || !this.isOpen) return;
|
|
1278
1320
|
const drew = this.wasmTerm!.update() !== DirtyState.NONE;
|
|
1279
|
-
this.
|
|
1321
|
+
this.draw(false, this.scrollbarOpacity);
|
|
1280
1322
|
if (drew) this.renderEmitter.fire({ start: 0, end: this.rows - 1 });
|
|
1281
1323
|
const cursor = this.wasmTerm!.getCursor();
|
|
1282
1324
|
if (cursor.y !== this.lastCursorY) {
|
|
@@ -1667,6 +1709,11 @@ export class Terminal implements ITerminalCore {
|
|
|
1667
1709
|
return;
|
|
1668
1710
|
}
|
|
1669
1711
|
|
|
1712
|
+
if (this.wasmTerm?.hasMouseTracking()) {
|
|
1713
|
+
this.inputHandler?.handleWheel(e);
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1670
1717
|
// Check if in alternate screen mode (vim, less, htop, etc.)
|
|
1671
1718
|
const isAltScreen = this.wasmTerm?.isAlternateScreen() ?? false;
|
|
1672
1719
|
|
|
@@ -1675,7 +1722,8 @@ export class Terminal implements ITerminalCore {
|
|
|
1675
1722
|
// Applications like vim handle scrolling internally
|
|
1676
1723
|
// Standard: ~3 arrow presses per wheel "click"
|
|
1677
1724
|
const direction = e.deltaY > 0 ? 'down' : 'up';
|
|
1678
|
-
const
|
|
1725
|
+
const lines = e.deltaMode === WheelEvent.DOM_DELTA_LINE ? e.deltaY : e.deltaY / 33;
|
|
1726
|
+
const count = Math.min(Math.abs(Math.round(lines)), 5); // Cap at 5
|
|
1679
1727
|
|
|
1680
1728
|
for (let i = 0; i < count; i++) {
|
|
1681
1729
|
if (direction === 'up') {
|
|
@@ -1889,9 +1937,7 @@ export class Terminal implements ITerminalCore {
|
|
|
1889
1937
|
this.scrollbarOpacity = progress;
|
|
1890
1938
|
|
|
1891
1939
|
// Trigger render to show updated opacity
|
|
1892
|
-
|
|
1893
|
-
this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
|
|
1894
|
-
}
|
|
1940
|
+
this.draw(false, this.scrollbarOpacity);
|
|
1895
1941
|
|
|
1896
1942
|
if (progress < 1) {
|
|
1897
1943
|
requestAnimationFrame(animate);
|
|
@@ -1913,9 +1959,7 @@ export class Terminal implements ITerminalCore {
|
|
|
1913
1959
|
this.scrollbarOpacity = startOpacity * (1 - progress);
|
|
1914
1960
|
|
|
1915
1961
|
// Trigger render to show updated opacity
|
|
1916
|
-
|
|
1917
|
-
this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
|
|
1918
|
-
}
|
|
1962
|
+
this.draw(false, this.scrollbarOpacity);
|
|
1919
1963
|
|
|
1920
1964
|
if (progress < 1) {
|
|
1921
1965
|
requestAnimationFrame(animate);
|
|
@@ -1923,9 +1967,7 @@ export class Terminal implements ITerminalCore {
|
|
|
1923
1967
|
this.scrollbarVisible = false;
|
|
1924
1968
|
this.scrollbarOpacity = 0;
|
|
1925
1969
|
// Final render to clear scrollbar completely
|
|
1926
|
-
|
|
1927
|
-
this.renderer.render(this.wasmTerm, false, this.viewportY, this, 0);
|
|
1928
|
-
}
|
|
1970
|
+
this.draw(false, 0);
|
|
1929
1971
|
}
|
|
1930
1972
|
};
|
|
1931
1973
|
animate();
|
|
@@ -1973,6 +2015,8 @@ export class Terminal implements ITerminalCore {
|
|
|
1973
2015
|
if (!this.wasmTerm) return undefined;
|
|
1974
2016
|
const y = this.wasmTerm.cursorPosition().y + cursorYOffset;
|
|
1975
2017
|
if (y < 0 || y >= this.rows) return undefined;
|
|
2018
|
+
// Starts watching the active screen for discarded rows before the marker exists.
|
|
2019
|
+
if (this.wasmTerm.rowsDiscarded()) this.refreshMarkers(true);
|
|
1976
2020
|
const row = this.wasmTerm.trackRow(y);
|
|
1977
2021
|
if (!row) return undefined;
|
|
1978
2022
|
const marker = new Marker(row);
|
|
@@ -2065,8 +2109,10 @@ export class Terminal implements ITerminalCore {
|
|
|
2065
2109
|
if (alternate !== this.lastAlternate) {
|
|
2066
2110
|
this.lastAlternate = alternate;
|
|
2067
2111
|
(this.buffer as BufferNamespace)._fireBufferChange(this.buffer.active);
|
|
2112
|
+
this.refreshMarkers(true);
|
|
2113
|
+
} else {
|
|
2114
|
+
this.refreshMarkers(false);
|
|
2068
2115
|
}
|
|
2069
|
-
for (const marker of [...this.markers]) marker.refresh();
|
|
2070
2116
|
this.writeParsedEmitter.fire();
|
|
2071
2117
|
this.wake();
|
|
2072
2118
|
}
|
|
@@ -2088,6 +2134,13 @@ export class Terminal implements ITerminalCore {
|
|
|
2088
2134
|
this.scrollEmitter.fire(this.buffer.active.viewportY);
|
|
2089
2135
|
}
|
|
2090
2136
|
|
|
2137
|
+
/** Disposes the markers whose line is gone; only rows the engine discarded can be. */
|
|
2138
|
+
private refreshMarkers(force: boolean): void {
|
|
2139
|
+
if (this.markers.size === 0 || !this.wasmTerm) return;
|
|
2140
|
+
if (!this.wasmTerm.rowsDiscarded() && !force) return;
|
|
2141
|
+
for (const marker of [...this.markers]) marker.refresh();
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2091
2144
|
private disposeMarkers(): void {
|
|
2092
2145
|
for (const marker of [...this.markers]) marker.dispose();
|
|
2093
2146
|
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.3",
|
|
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",
|