@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.
@@ -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
@@ -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
- : undefined;
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
- text: url,
79
- range: {
80
- start: { x: startX, y },
81
- end: { x: endX, y },
82
- },
83
- activate: (event) => {
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
+ }