@aurigax-ai/ghostty-web 0.1.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 +6 -1
- package/lib/addons/search.ts +190 -0
- package/lib/box-glyphs.ts +278 -0
- package/lib/ghostty.ts +465 -129
- package/lib/glyph-ink.ts +42 -0
- package/lib/index.ts +8 -1
- package/lib/input-handler.ts +55 -2
- package/lib/interfaces.ts +29 -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 +781 -686
- package/lib/selection-manager.ts +8 -3
- package/lib/terminal.ts +205 -85
- package/lib/types.ts +2 -1
- package/lib/webgl-instances.ts +69 -0
- package/lib/webgl-renderer.ts +943 -0
- package/package.json +2 -2
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
|
@@ -92,7 +92,8 @@ export { Key, KeyAction, Mods } from './types';
|
|
|
92
92
|
export type { KeyEvent, GhosttyCell, RGB, Cursor, TerminalHandle } from './types';
|
|
93
93
|
|
|
94
94
|
// Low-level components (for custom integrations)
|
|
95
|
-
export { CanvasRenderer } from './renderer';
|
|
95
|
+
export { CanvasRenderer, TerminalRenderer } from './renderer';
|
|
96
|
+
export { WebglRenderer } from './webgl-renderer';
|
|
96
97
|
export type { RendererOptions, FontMetrics, IRenderable } from './renderer';
|
|
97
98
|
export { InputHandler } from './input-handler';
|
|
98
99
|
export { EventEmitter } from './event-emitter';
|
|
@@ -101,6 +102,12 @@ export type { SelectionCoordinates } from './selection-manager';
|
|
|
101
102
|
|
|
102
103
|
// Addons
|
|
103
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';
|
|
104
111
|
export type { ITerminalDimensions } from './addons/fit';
|
|
105
112
|
|
|
106
113
|
// Link providers
|
package/lib/input-handler.ts
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
* - Emitting data for Terminal to send to PTY
|
|
10
10
|
*
|
|
11
11
|
* Limitations:
|
|
12
|
-
* - Does not handle IME/composition events (CJK input) - to be added later
|
|
13
12
|
* - Captures all keyboard input (preventDefault on everything)
|
|
14
13
|
*/
|
|
15
14
|
|
|
@@ -186,6 +185,7 @@ export class InputHandler {
|
|
|
186
185
|
private keypressListener: ((e: KeyboardEvent) => void) | null = null;
|
|
187
186
|
private pasteListener: ((e: ClipboardEvent) => void) | null = null;
|
|
188
187
|
private beforeInputListener: ((e: InputEvent) => void) | null = null;
|
|
188
|
+
private containerBeforeInputListener: ((e: InputEvent) => void) | null = null;
|
|
189
189
|
private compositionStartListener: ((e: CompositionEvent) => void) | null = null;
|
|
190
190
|
private compositionUpdateListener: ((e: CompositionEvent) => void) | null = null;
|
|
191
191
|
private compositionEndListener: ((e: CompositionEvent) => void) | null = null;
|
|
@@ -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;
|
|
@@ -286,6 +294,13 @@ export class InputHandler {
|
|
|
286
294
|
this.inputElement.addEventListener('beforeinput', this.beforeInputListener);
|
|
287
295
|
}
|
|
288
296
|
|
|
297
|
+
if (this.inputElement !== this.container) {
|
|
298
|
+
this.containerBeforeInputListener = (event: InputEvent) => {
|
|
299
|
+
if (event.target === this.container) this.handleBeforeInput(event);
|
|
300
|
+
};
|
|
301
|
+
this.container.addEventListener('beforeinput', this.containerBeforeInputListener);
|
|
302
|
+
}
|
|
303
|
+
|
|
289
304
|
this.compositionStartListener = this.handleCompositionStart.bind(this);
|
|
290
305
|
this.container.addEventListener('compositionstart', this.compositionStartListener);
|
|
291
306
|
|
|
@@ -338,6 +353,18 @@ export class InputHandler {
|
|
|
338
353
|
return mods;
|
|
339
354
|
}
|
|
340
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
|
+
|
|
341
368
|
/**
|
|
342
369
|
* Check if this is a printable character with no special modifiers
|
|
343
370
|
* @param event - KeyboardEvent
|
|
@@ -400,6 +427,13 @@ export class InputHandler {
|
|
|
400
427
|
return;
|
|
401
428
|
}
|
|
402
429
|
|
|
430
|
+
if (this.optionComposes(event)) {
|
|
431
|
+
event.preventDefault();
|
|
432
|
+
this.onDataCallback(event.key);
|
|
433
|
+
this.recordKeyDownData(event.key);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
403
437
|
// For printable characters without modifiers, send the character directly
|
|
404
438
|
// This handles: a-z, A-Z (with shift), 0-9, punctuation, etc.
|
|
405
439
|
if (this.isPrintableCharacter(event)) {
|
|
@@ -525,7 +559,9 @@ export class InputHandler {
|
|
|
525
559
|
const utf8 =
|
|
526
560
|
event.key.length === 1 && event.key.charCodeAt(0) < 128
|
|
527
561
|
? event.key.toLowerCase() // Use lowercase for consistency
|
|
528
|
-
:
|
|
562
|
+
: event.altKey
|
|
563
|
+
? baseCharacter(event.code)
|
|
564
|
+
: undefined;
|
|
529
565
|
|
|
530
566
|
const encoded = this.encoder.encode({
|
|
531
567
|
action,
|
|
@@ -1057,6 +1093,11 @@ export class InputHandler {
|
|
|
1057
1093
|
this.beforeInputListener = null;
|
|
1058
1094
|
}
|
|
1059
1095
|
|
|
1096
|
+
if (this.containerBeforeInputListener) {
|
|
1097
|
+
this.container.removeEventListener('beforeinput', this.containerBeforeInputListener);
|
|
1098
|
+
this.containerBeforeInputListener = null;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1060
1101
|
if (this.compositionStartListener) {
|
|
1061
1102
|
this.container.removeEventListener('compositionstart', this.compositionStartListener);
|
|
1062
1103
|
this.compositionStartListener = null;
|
|
@@ -1102,3 +1143,15 @@ export class InputHandler {
|
|
|
1102
1143
|
return !this.isDisposed;
|
|
1103
1144
|
}
|
|
1104
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,26 @@ 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;
|
|
25
|
+
/**
|
|
26
|
+
* How the terminal draws, read when it opens. 'webgl' draws on the GPU and
|
|
27
|
+
* falls back to 'canvas' when WebGL 2 is unavailable or its context is lost.
|
|
28
|
+
* Default: 'webgl'.
|
|
29
|
+
*/
|
|
30
|
+
renderer?: 'webgl' | 'canvas';
|
|
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;
|
|
17
37
|
|
|
18
38
|
// Phase 1 additions
|
|
19
39
|
convertEol?: boolean; // Convert \n to \r\n (default: false)
|
|
@@ -27,6 +47,15 @@ export interface ITerminalOptions {
|
|
|
27
47
|
ghostty?: Ghostty;
|
|
28
48
|
}
|
|
29
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
|
+
|
|
30
59
|
export interface ITheme {
|
|
31
60
|
foreground?: string;
|
|
32
61
|
background?: string;
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* so we just need to scan for contiguous regions with the same ID.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import type { ILinkHandler } from '../interfaces';
|
|
13
14
|
import type { IBufferRange, ILink, ILinkProvider } from '../types';
|
|
15
|
+
import { webLink } from './web-link';
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* OSC 8 Hyperlink Provider
|
|
@@ -96,16 +98,7 @@ export class OSC8LinkProvider implements ILinkProvider {
|
|
|
96
98
|
end: { x: endX, y },
|
|
97
99
|
};
|
|
98
100
|
|
|
99
|
-
links.push(
|
|
100
|
-
text: uri,
|
|
101
|
-
range,
|
|
102
|
-
activate: (event) => {
|
|
103
|
-
// Open link if Ctrl/Cmd is pressed
|
|
104
|
-
if (event.ctrlKey || event.metaKey) {
|
|
105
|
-
window.open(uri, '_blank', 'noopener,noreferrer');
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
});
|
|
101
|
+
links.push(webLink(uri, range, () => this.terminal.options?.linkHandler ?? undefined));
|
|
109
102
|
}
|
|
110
103
|
}
|
|
111
104
|
|
|
@@ -121,6 +114,7 @@ export class OSC8LinkProvider implements ILinkProvider {
|
|
|
121
114
|
* Minimal terminal interface required by OSC8LinkProvider
|
|
122
115
|
*/
|
|
123
116
|
export interface ITerminalForOSC8Provider {
|
|
117
|
+
options?: { linkHandler?: ILinkHandler | null };
|
|
124
118
|
buffer: {
|
|
125
119
|
active: {
|
|
126
120
|
length: number;
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* take precedence over regex-detected URLs.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import type { ILinkHandler } from '../interfaces';
|
|
11
12
|
import type { ILink, ILinkProvider } from '../types';
|
|
13
|
+
import { webLink } from './web-link';
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* URL Regex Provider
|
|
@@ -74,19 +76,13 @@ export class UrlRegexProvider implements ILinkProvider {
|
|
|
74
76
|
|
|
75
77
|
// Skip if URL is too short (e.g., just "http://")
|
|
76
78
|
if (url.length > 8) {
|
|
77
|
-
links.push(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
start: { x: startX, y },
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// Open link if Ctrl/Cmd is pressed
|
|
85
|
-
if (event.ctrlKey || event.metaKey) {
|
|
86
|
-
window.open(url, '_blank', 'noopener,noreferrer');
|
|
87
|
-
}
|
|
88
|
-
},
|
|
89
|
-
});
|
|
79
|
+
links.push(
|
|
80
|
+
webLink(
|
|
81
|
+
url,
|
|
82
|
+
{ start: { x: startX, y }, end: { x: endX, y } },
|
|
83
|
+
() => this.terminal.options?.linkHandler ?? undefined
|
|
84
|
+
)
|
|
85
|
+
);
|
|
90
86
|
}
|
|
91
87
|
|
|
92
88
|
// Get next match
|
|
@@ -130,6 +126,7 @@ export class UrlRegexProvider implements ILinkProvider {
|
|
|
130
126
|
* Minimal terminal interface required by UrlRegexProvider
|
|
131
127
|
*/
|
|
132
128
|
export interface ITerminalForUrlProvider {
|
|
129
|
+
options?: { linkHandler?: ILinkHandler | null };
|
|
133
130
|
buffer: {
|
|
134
131
|
active: {
|
|
135
132
|
getLine(y: number): IBufferLineForUrlProvider | undefined;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ILinkHandler } from '../interfaces';
|
|
2
|
+
import type { IBufferRange, ILink } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A link to a URI. With a host link handler the host decides what a click
|
|
6
|
+
* does (and shows its own hover hint); without one, Ctrl/Cmd+click opens the
|
|
7
|
+
* URI in a new window.
|
|
8
|
+
*/
|
|
9
|
+
export function webLink(
|
|
10
|
+
uri: string,
|
|
11
|
+
range: IBufferRange,
|
|
12
|
+
handler: () => ILinkHandler | undefined
|
|
13
|
+
): ILink {
|
|
14
|
+
return {
|
|
15
|
+
text: uri,
|
|
16
|
+
range,
|
|
17
|
+
activate: (event) => {
|
|
18
|
+
const host = handler();
|
|
19
|
+
if (host) {
|
|
20
|
+
host.activate(event, uri, range);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (event.ctrlKey || event.metaKey) {
|
|
24
|
+
window.open(uri, '_blank', 'noopener,noreferrer');
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
hover: (hovered) => {
|
|
28
|
+
const host = handler();
|
|
29
|
+
if (!host) return;
|
|
30
|
+
const event = new MouseEvent(hovered ? 'mouseover' : 'mouseout');
|
|
31
|
+
if (hovered) host.hover?.(event, uri, range);
|
|
32
|
+
else host.leave?.(event, uri, range);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|