Skip to content

Commit 125e976

Browse files
authored
feat: add hyperlink rendering (#37)
1 parent 9f5523b commit 125e976

File tree

8 files changed

+1000
-10
lines changed

8 files changed

+1000
-10
lines changed

lib/buffer.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,10 @@ export class Buffer implements IBuffer {
177177

178178
if (this.bufferType === 'normal' && y < scrollbackLength) {
179179
// Accessing scrollback
180-
const scrollbackOffset = scrollbackLength - y - 1; // Most recent = 0
180+
// WASM getScrollbackLine: offset 0 = oldest, offset (length-1) = newest
181+
// Buffer coords: y=0 = oldest, y=(length-1) = newest
182+
// So scrollbackOffset = y directly!
183+
const scrollbackOffset = y;
181184
cells = wasmTerm.getScrollbackLine(scrollbackOffset);
182185
// TODO: We'd need WASM API to check if scrollback line is wrapped
183186
// For now, assume not wrapped
@@ -364,4 +367,28 @@ export class BufferCell implements IBufferCell {
364367
isFaint(): number {
365368
return (this.cell.flags & CellFlags.FAINT) !== 0 ? 1 : 0;
366369
}
370+
371+
/**
372+
* Get hyperlink ID for this cell (0 = no link)
373+
* Used by link detection system
374+
*/
375+
getHyperlinkId(): number {
376+
return this.cell.hyperlink_id;
377+
}
378+
379+
/**
380+
* Get the Unicode codepoint for this cell
381+
* Used by link detection system
382+
*/
383+
getCodepoint(): number {
384+
return this.cell.codepoint;
385+
}
386+
387+
/**
388+
* Check if cell has dim/faint attribute
389+
* Added for IBufferCell compatibility
390+
*/
391+
isDim(): boolean {
392+
return (this.cell.flags & CellFlags.FAINT) !== 0;
393+
}
367394
}

lib/interfaces.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,4 +192,12 @@ export interface IBufferCell {
192192
isInvisible(): number;
193193
/** Whether cell has faint/dim style */
194194
isFaint(): number;
195+
196+
// Link detection support
197+
/** Get hyperlink ID for this cell (0 = no link) */
198+
getHyperlinkId(): number;
199+
/** Get the Unicode codepoint for this cell */
200+
getCodepoint(): number;
201+
/** Whether cell has dim/faint attribute (boolean version) */
202+
isDim(): boolean;
195203
}

lib/link-detector.ts

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
/**
2+
* Link detection and caching system
3+
*
4+
* The LinkDetector coordinates between multiple link providers and caches
5+
* results for performance. It uses hyperlink_id for intelligent caching
6+
* since the same hyperlink_id always represents the same link.
7+
*/
8+
9+
import type { IBufferCellPosition, ILink, ILinkProvider } from './types';
10+
11+
/**
12+
* Manages link detection across multiple providers with intelligent caching
13+
*/
14+
export class LinkDetector {
15+
private providers: ILinkProvider[] = [];
16+
17+
// Cache links by hyperlink_id for fast lookups
18+
// Key format: `h${hyperlinkId}` for OSC 8 links
19+
// Key format: `r${row}:${startX}-${endX}` for regex links (future)
20+
private linkCache = new Map<string, ILink>();
21+
22+
// Track which rows have been scanned to avoid redundant provider calls
23+
private scannedRows = new Set<number>();
24+
25+
// Terminal instance for buffer access
26+
constructor(private terminal: ITerminalForLinkDetector) {}
27+
28+
/**
29+
* Register a link provider
30+
*/
31+
registerProvider(provider: ILinkProvider): void {
32+
this.providers.push(provider);
33+
this.invalidateCache(); // New provider may detect different links
34+
}
35+
36+
/**
37+
* Get link at the specified buffer position
38+
* @param col Column (0-based)
39+
* @param row Absolute row in buffer (0-based)
40+
* @returns Link at position, or undefined if none
41+
*/
42+
async getLinkAt(col: number, row: number): Promise<ILink | undefined> {
43+
// First, check if this cell has a hyperlink_id (fast path for OSC 8)
44+
const line = this.terminal.buffer.active.getLine(row);
45+
if (!line || col < 0 || col >= line.length) {
46+
return undefined;
47+
}
48+
49+
const cell = line.getCell(col);
50+
if (!cell) {
51+
return undefined;
52+
}
53+
const hyperlinkId = cell.getHyperlinkId();
54+
55+
if (hyperlinkId > 0) {
56+
// Fast path: check cache by hyperlink_id
57+
const cacheKey = `h${hyperlinkId}`;
58+
if (this.linkCache.has(cacheKey)) {
59+
return this.linkCache.get(cacheKey);
60+
}
61+
}
62+
63+
// Slow path: scan this row if not already scanned
64+
if (!this.scannedRows.has(row)) {
65+
await this.scanRow(row);
66+
}
67+
68+
// Check cache again (hyperlinkId or position-based)
69+
if (hyperlinkId > 0) {
70+
const cacheKey = `h${hyperlinkId}`;
71+
const link = this.linkCache.get(cacheKey);
72+
if (link) return link;
73+
}
74+
75+
// Check if any cached link contains this position
76+
for (const link of this.linkCache.values()) {
77+
if (this.isPositionInLink(col, row, link)) {
78+
return link;
79+
}
80+
}
81+
82+
return undefined;
83+
}
84+
85+
/**
86+
* Scan a row for links using all registered providers
87+
*/
88+
private async scanRow(row: number): Promise<void> {
89+
this.scannedRows.add(row);
90+
91+
const allLinks: ILink[] = [];
92+
93+
// Query all providers
94+
for (const provider of this.providers) {
95+
const links = await new Promise<ILink[] | undefined>((resolve) => {
96+
provider.provideLinks(row, resolve);
97+
});
98+
99+
if (links) {
100+
allLinks.push(...links);
101+
}
102+
}
103+
104+
// Cache all discovered links
105+
for (const link of allLinks) {
106+
this.cacheLink(link);
107+
}
108+
}
109+
110+
/**
111+
* Cache a link for fast lookup
112+
*/
113+
private cacheLink(link: ILink): void {
114+
// Try to get hyperlink_id for this link
115+
const { start } = link.range;
116+
const line = this.terminal.buffer.active.getLine(start.y);
117+
if (line) {
118+
const cell = line.getCell(start.x);
119+
if (!cell) {
120+
// Fallback: cache by position range
121+
const { start: s, end: e } = link.range;
122+
const cacheKey = `r${s.y}:${s.x}-${e.x}`;
123+
this.linkCache.set(cacheKey, link);
124+
return;
125+
}
126+
const hyperlinkId = cell.getHyperlinkId();
127+
128+
if (hyperlinkId > 0) {
129+
// Cache by hyperlink_id (best case - stable across rows)
130+
this.linkCache.set(`h${hyperlinkId}`, link);
131+
return;
132+
}
133+
}
134+
135+
// Fallback: cache by position range
136+
// Format: r${row}:${startX}-${endX}
137+
const { start: s, end: e } = link.range;
138+
const cacheKey = `r${s.y}:${s.x}-${e.x}`;
139+
this.linkCache.set(cacheKey, link);
140+
}
141+
142+
/**
143+
* Check if a position is within a link's range
144+
*/
145+
private isPositionInLink(col: number, row: number, link: ILink): boolean {
146+
const { start, end } = link.range;
147+
148+
// Check if row is in range
149+
if (row < start.y || row > end.y) {
150+
return false;
151+
}
152+
153+
// Single-line link
154+
if (start.y === end.y) {
155+
return col >= start.x && col <= end.x;
156+
}
157+
158+
// Multi-line link
159+
if (row === start.y) {
160+
return col >= start.x; // First line: from start.x to end of line
161+
} else if (row === end.y) {
162+
return col <= end.x; // Last line: from start of line to end.x
163+
} else {
164+
return true; // Middle line: entire line is part of link
165+
}
166+
}
167+
168+
/**
169+
* Invalidate cache when terminal content changes
170+
* Should be called on terminal write, resize, or clear
171+
*/
172+
invalidateCache(): void {
173+
this.linkCache.clear();
174+
this.scannedRows.clear();
175+
}
176+
177+
/**
178+
* Invalidate cache for specific rows
179+
* Used when only part of the terminal changed
180+
*/
181+
invalidateRows(startRow: number, endRow: number): void {
182+
// Remove scanned markers
183+
for (let row = startRow; row <= endRow; row++) {
184+
this.scannedRows.delete(row);
185+
}
186+
187+
// Remove cached links in this range
188+
// This is conservative - we remove any link that touches these rows
189+
const toDelete: string[] = [];
190+
for (const [key, link] of this.linkCache.entries()) {
191+
const { start, end } = link.range;
192+
if (
193+
(start.y >= startRow && start.y <= endRow) ||
194+
(end.y >= startRow && end.y <= endRow) ||
195+
(start.y < startRow && end.y > endRow)
196+
) {
197+
toDelete.push(key);
198+
}
199+
}
200+
201+
for (const key of toDelete) {
202+
this.linkCache.delete(key);
203+
}
204+
}
205+
206+
/**
207+
* Dispose and cleanup
208+
*/
209+
dispose(): void {
210+
this.linkCache.clear();
211+
this.scannedRows.clear();
212+
213+
// Dispose all providers
214+
for (const provider of this.providers) {
215+
provider.dispose?.();
216+
}
217+
this.providers = [];
218+
}
219+
}
220+
221+
/**
222+
* Minimal terminal interface required by LinkDetector
223+
* Keeps coupling low and testing easy
224+
*/
225+
export interface ITerminalForLinkDetector {
226+
buffer: {
227+
active: {
228+
getLine(y: number):
229+
| {
230+
length: number;
231+
getCell(x: number):
232+
| {
233+
getHyperlinkId(): number;
234+
}
235+
| undefined;
236+
}
237+
| undefined;
238+
};
239+
};
240+
}

0 commit comments

Comments
 (0)