diff --git a/packages/core/src/lib/styled-text.ts b/packages/core/src/lib/styled-text.ts index 497b1e0b..395f9df3 100644 --- a/packages/core/src/lib/styled-text.ts +++ b/packages/core/src/lib/styled-text.ts @@ -17,6 +17,7 @@ export interface StyleAttrs { dim?: boolean reverse?: boolean blink?: boolean + href?: string } export function isStyledText(obj: any): obj is StyledText { @@ -49,6 +50,7 @@ function applyStyle(input: StylableInput, style: StyleAttrs): TextChunk { const fg = style.fg ? parseColor(style.fg) : existingChunk.fg const bg = style.bg ? parseColor(style.bg) : existingChunk.bg + const href = style.href ?? existingChunk.href const newAttrs = createTextAttributes(style) const mergedAttrs = existingChunk.attributes ? existingChunk.attributes | newAttrs : newAttrs @@ -59,6 +61,7 @@ function applyStyle(input: StylableInput, style: StyleAttrs): TextChunk { fg, bg, attributes: mergedAttrs, + href, } } else { const plainTextStr = String(input) @@ -72,6 +75,7 @@ function applyStyle(input: StylableInput, style: StyleAttrs): TextChunk { fg, bg, attributes, + href: style.href, } } } @@ -125,6 +129,12 @@ export const bg = (input: StylableInput): TextChunk => applyStyle(input, { bg: color }) +// Hyperlink function - applies underline style and href +export const link = + (url: string) => + (input: StylableInput): TextChunk => + applyStyle(input, { href: url, underline: true }) + /** * Template literal handler for styled text (non-cached version). * Returns a StyledText object containing chunks of text with optional styles. diff --git a/packages/core/src/lib/tree-sitter-styled-text.test.ts b/packages/core/src/lib/tree-sitter-styled-text.test.ts index bd0cf251..266ffe00 100644 --- a/packages/core/src/lib/tree-sitter-styled-text.test.ts +++ b/packages/core/src/lib/tree-sitter-styled-text.test.ts @@ -944,6 +944,117 @@ Normal paragraph with [link](https://example.com).` }) }) + describe("URL/Link href extraction", () => { + test("should extract href from autolink URLs", async () => { + // Autolinks in markdown use angle brackets + const markdownCode = "Check out for more info" + + const styledText = await treeSitterToStyledText(markdownCode, "markdown", syntaxStyle, client, { + conceal: { enabled: false }, + }) + const chunks = styledText.chunks + + // Find the URL chunk (includes angle brackets in text) + const urlChunk = chunks.find((c) => c.text.includes("https://example.com")) + expect(urlChunk).toBeDefined() + + // The href should be set to the URL text (with angle brackets since that's how tree-sitter parses it) + expect(urlChunk!.href).toBe("") + }) + + test("should extract href from inline markdown links", async () => { + const markdownCode = "[Click here](https://test.org)" + + const styledText = await treeSitterToStyledText(markdownCode, "markdown", syntaxStyle, client, { + conceal: { enabled: false }, + }) + const chunks = styledText.chunks + + // Find the URL chunk (in the parentheses, without the parens) + const urlChunk = chunks.find((c) => c.text === "https://test.org") + expect(urlChunk).toBeDefined() + expect(urlChunk!.href).toBe("https://test.org") + }) + + test("should add underline attribute to URL chunks with href", async () => { + const markdownCode = "[Link]()" + + const styledText = await treeSitterToStyledText(markdownCode, "markdown", syntaxStyle, client, { + conceal: { enabled: false }, + }) + const chunks = styledText.chunks + + // Find the chunk with href set + const urlChunk = chunks.find((c) => c.href !== undefined) + expect(urlChunk).toBeDefined() + + // Should have underline attribute + const underlineAttr = createTextAttributes({ underline: true }) + expect(urlChunk!.attributes! & underlineAttr).toBe(underlineAttr) + }) + + test("should not set href for non-URL text chunks", async () => { + const markdownCode = "Regular text without URLs" + + const styledText = await treeSitterToStyledText(markdownCode, "markdown", syntaxStyle, client, { + conceal: { enabled: false }, + }) + const chunks = styledText.chunks + + // None of the chunks should have href set + for (const chunk of chunks) { + expect(chunk.href).toBeUndefined() + } + }) + + test("should extract href from plain URLs without markdown syntax", async () => { + // Plain URLs should be automatically detected and made clickable + const markdownCode = "Visit https://example.com today" + + const styledText = await treeSitterToStyledText(markdownCode, "markdown", syntaxStyle, client, { + conceal: { enabled: false }, + }) + const chunks = styledText.chunks + + // Should find a chunk with the URL as href + const urlChunk = chunks.find((c) => c.href === "https://example.com") + expect(urlChunk).toBeDefined() + expect(urlChunk!.text).toBe("https://example.com") + + // Should have underline attribute + const underlineAttr = createTextAttributes({ underline: true }) + expect(urlChunk!.attributes! & underlineAttr).toBe(underlineAttr) + }) + + test("should handle multiple plain URLs in text", async () => { + const markdownCode = "Check https://first.com and https://second.com for info" + + const styledText = await treeSitterToStyledText(markdownCode, "markdown", syntaxStyle, client, { + conceal: { enabled: false }, + }) + const chunks = styledText.chunks + + const urlChunks = chunks.filter((c) => c.href !== undefined) + expect(urlChunks.length).toBe(2) + expect(urlChunks[0].href).toBe("https://first.com") + expect(urlChunks[1].href).toBe("https://second.com") + }) + + test("should handle URLs at start and end of text", async () => { + const markdownCode = "https://start.com is good and so is https://end.com" + + const styledText = await treeSitterToStyledText(markdownCode, "markdown", syntaxStyle, client, { + conceal: { enabled: false }, + }) + const chunks = styledText.chunks + + const urlChunks = chunks.filter((c) => c.href !== undefined) + expect(urlChunks.length).toBe(2) + expect(urlChunks[0].href).toBe("https://start.com") + expect(urlChunks[1].href).toBe("https://end.com") + }) + }) + describe("Style Inheritance", () => { test("should merge styles from nested highlights with child overriding parent", () => { const mockHighlights: SimpleHighlight[] = [ diff --git a/packages/core/src/lib/tree-sitter-styled-text.ts b/packages/core/src/lib/tree-sitter-styled-text.ts index 74f798fb..f57ef6bb 100644 --- a/packages/core/src/lib/tree-sitter-styled-text.ts +++ b/packages/core/src/lib/tree-sitter-styled-text.ts @@ -22,6 +22,92 @@ function getSpecificity(group: string): number { return group.split(".").length } +/** + * Check if a highlight group represents a URL/link that should be clickable + */ +function isLinkUrlGroup(group: string): boolean { + return group === "markup.link.url" || group === "string.special.url" +} + +/** + * Regular expression to match URLs in text + * Matches http://, https://, and common URL patterns + */ +const URL_REGEX = /https?:\/\/[^\s<>\[\]()'"`,;]+[^\s<>\[\]()'"`,;.!?:]/g + +/** + * Extract plain URLs from a text chunk and split it into multiple chunks + * with proper href attributes for the URL portions + */ +function splitChunkByUrls(chunk: TextChunk): TextChunk[] { + // If chunk already has href, don't process it + if (chunk.href) { + return [chunk] + } + + const text = chunk.text + const results: TextChunk[] = [] + let lastIndex = 0 + + // Reset regex state + URL_REGEX.lastIndex = 0 + + let match: RegExpExecArray | null + while ((match = URL_REGEX.exec(text)) !== null) { + const url = match[0] + const startIndex = match.index + + // Add text before the URL (if any) + if (startIndex > lastIndex) { + results.push({ + ...chunk, + text: text.slice(lastIndex, startIndex), + href: undefined, + }) + } + + // Add the URL chunk with href and underline + const urlAttributes = chunk.attributes ?? 0 + const underlineAttr = createTextAttributes({ underline: true }) + + results.push({ + ...chunk, + text: url, + href: url, + attributes: urlAttributes | underlineAttr, + }) + + lastIndex = startIndex + url.length + } + + // Add remaining text after the last URL (if any) + if (lastIndex < text.length) { + results.push({ + ...chunk, + text: text.slice(lastIndex), + href: undefined, + }) + } + + // If no URLs were found, return the original chunk + if (results.length === 0) { + return [chunk] + } + + return results +} + +/** + * Process all chunks to extract plain URLs and make them clickable + */ +function processChunksForUrls(chunks: TextChunk[]): TextChunk[] { + const result: TextChunk[] = [] + for (const chunk of chunks) { + result.push(...splitChunkByUrls(chunk)) + } + return result +} + function shouldSuppressInInjection(group: string, meta: any): boolean { if (meta?.isInjection) { return false @@ -182,6 +268,11 @@ export function treeSitterToTextChunks( // Use merged style, falling back to default if nothing was merged const finalStyle = Object.keys(mergedStyle).length > 0 ? mergedStyle : defaultStyle + // Check if this segment is a URL that should be clickable + // For markup.link.url groups, the segment text itself is the URL + const linkUrlGroup = sortedGroups.find((h) => isLinkUrlGroup(h.group)) + const href = linkUrlGroup ? segmentText : undefined + chunks.push({ __isChunk: true, text: segmentText, @@ -191,10 +282,11 @@ export function treeSitterToTextChunks( ? createTextAttributes({ bold: finalStyle.bold, italic: finalStyle.italic, - underline: finalStyle.underline, + underline: finalStyle.underline || !!href, // Underline links dim: finalStyle.dim, }) : 0, + href, }) } } else if (currentOffset < boundary.offset) { @@ -272,7 +364,8 @@ export function treeSitterToTextChunks( }) } - return chunks + // Post-process chunks to extract plain URLs and make them clickable + return processChunksForUrls(chunks) } export interface TreeSitterToStyledTextOptions { diff --git a/packages/core/src/renderables/TextBufferRenderable.ts b/packages/core/src/renderables/TextBufferRenderable.ts index d5ed49d2..b5a52ef4 100644 --- a/packages/core/src/renderables/TextBufferRenderable.ts +++ b/packages/core/src/renderables/TextBufferRenderable.ts @@ -8,6 +8,9 @@ import type { OptimizedBuffer } from "../buffer" import { MeasureMode } from "yoga-layout" import type { LineInfo } from "../zig" import { SyntaxStyle } from "../syntax-style" +import type { MouseEvent } from "../renderer" + +export type LinkClickHandler = (url: string, event: MouseEvent) => void export interface TextBufferOptions extends RenderableOptions { fg?: string | RGBA @@ -19,6 +22,7 @@ export interface TextBufferOptions extends RenderableOptions this._ctx.registerLink(uri)) + const style = SyntaxStyle.create() this.textBuffer.setSyntaxStyle(style) @@ -97,9 +106,39 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf this.updateTextInfo() } - protected onMouseEvent(event: any): void { + protected onMouseEvent(event: MouseEvent): void { if (event.type === "scroll") { this.handleScroll(event) + return + } + + // Handle link clicks with alt (Option on macOS) or ctrl modifier + const isLinkModifier = event.modifiers.alt || event.modifiers.ctrl + if (event.type === "down" && event.button === 0 && isLinkModifier && this._onLinkClick) { + this.handleLinkClick(event) + } + } + + /** + * Check if a click at the given position is on a link with alt or ctrl modifier held + * Used to determine if we should activate a link instead of starting selection + */ + protected isLinkActivation(event: MouseEvent): boolean { + const isLinkModifier = event.modifiers.alt || event.modifiers.ctrl + if (!isLinkModifier) return false + const linkId = this.getLinkIdAt(event.x, event.y) + return linkId !== 0 + } + + /** + * Handle alt+click on links - calls the onLinkClick handler if set + */ + protected handleLinkClick(event: MouseEvent): void { + const url = this.getLinkAt(event.x, event.y) + if (url && this._onLinkClick) { + event.preventDefault() + event.stopPropagation() + this._onLinkClick(url, event) } } @@ -456,6 +495,45 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf return this.textBufferView.getSelection() } + /** + * Get the link ID at a given screen position (absolute coordinates) + * Returns 0 if no link at that position + */ + getLinkIdAt(screenX: number, screenY: number): number { + const localX = screenX - this.x + const localY = screenY - this.y + + if (localX < 0 || localY < 0 || localX >= this.width || localY >= this.height) { + return 0 + } + + return this.textBufferView.getLinkIdAtPosition(localX, localY) + } + + /** + * Get the link URL at a given screen position (absolute coordinates) + * Returns null if no link at that position + */ + getLinkAt(screenX: number, screenY: number): string | null { + const linkId = this.getLinkIdAt(screenX, screenY) + if (linkId === 0) return null + return this._ctx.getLink(linkId) + } + + /** + * Get the current link click handler + */ + get onLinkClick(): LinkClickHandler | undefined { + return this._onLinkClick + } + + /** + * Set the link click handler for alt+click on hyperlinks + */ + set onLinkClick(handler: LinkClickHandler | undefined) { + this._onLinkClick = handler + } + render(buffer: OptimizedBuffer, deltaTime: number): void { if (!this.visible) return diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 805f0070..6875eb5f 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -603,6 +603,18 @@ export class CliRenderer extends EventEmitter implements RenderContext { } } + public registerLink(uri: string): number { + return this.lib.registerLink(this.rendererPtr, uri) + } + + public getLink(linkId: number): string | null { + return this.lib.getLink(this.rendererPtr, linkId) + } + + public clearLinks(): void { + this.lib.clearLinks(this.rendererPtr) + } + public get widthMethod(): WidthMethod { const caps = this.capabilities return caps?.unicode === "wcwidth" ? "wcwidth" : "unicode" @@ -1047,7 +1059,8 @@ export class CliRenderer extends EventEmitter implements RenderContext { mouseEvent.type === "down" && mouseEvent.button === MouseButton.LEFT && !this.currentSelection?.isSelecting && - !mouseEvent.modifiers.ctrl + !mouseEvent.modifiers.ctrl && + !mouseEvent.modifiers.alt // alt+click is reserved for link activation ) { if ( maybeRenderable && diff --git a/packages/core/src/tests/hyperlinks.test.ts b/packages/core/src/tests/hyperlinks.test.ts new file mode 100644 index 00000000..1b2dd2ed --- /dev/null +++ b/packages/core/src/tests/hyperlinks.test.ts @@ -0,0 +1,245 @@ +import { test, expect, beforeEach, afterEach, describe } from "bun:test" +import { createTestRenderer, type TestRenderer, type MockMouse } from "../testing/test-renderer" +import { TextRenderable } from "../renderables/Text" +import { link, t, fg } from "../lib/styled-text" +import { TextAttributes } from "../types" + +let renderer: TestRenderer +let mockMouse: MockMouse +let renderOnce: () => Promise + +beforeEach(async () => { + ;({ renderer, mockMouse, renderOnce } = await createTestRenderer({ + width: 80, + height: 24, + })) +}) + +afterEach(() => { + renderer.destroy() +}) + +describe("link() styled text helper", () => { + test("should create a text chunk with href and underline", () => { + const linkedText = link("https://example.com")("Click here") + + expect(linkedText.__isChunk).toBe(true) + expect(linkedText.text).toBe("Click here") + expect(linkedText.href).toBe("https://example.com") + // Underline is stored in the attributes bitmask + expect(linkedText.attributes! & TextAttributes.UNDERLINE).toBe(TextAttributes.UNDERLINE) + }) + + test("should work with template literals", () => { + const styledText = t`Visit ${link("https://example.com")("our website")} for more info` + + expect(styledText.chunks.length).toBe(3) + expect(styledText.chunks[0].text).toBe("Visit ") + expect(styledText.chunks[1].text).toBe("our website") + expect(styledText.chunks[1].href).toBe("https://example.com") + expect(styledText.chunks[1].attributes! & TextAttributes.UNDERLINE).toBe(TextAttributes.UNDERLINE) + expect(styledText.chunks[2].text).toBe(" for more info") + }) + + test("should compose with other styles", () => { + const styledLink = fg("blue")(link("https://example.com")("Blue link")) + + expect(styledLink.__isChunk).toBe(true) + expect(styledLink.text).toBe("Blue link") + expect(styledLink.href).toBe("https://example.com") + expect(styledLink.attributes! & TextAttributes.UNDERLINE).toBe(TextAttributes.UNDERLINE) + expect(styledLink.fg).toBeDefined() + }) +}) + +describe("link registration", () => { + test("should register links and return unique IDs", () => { + const id1 = renderer.registerLink("https://example.com") + const id2 = renderer.registerLink("https://other.com") + const id3 = renderer.registerLink("https://example.com") // duplicate + + expect(id1).toBeGreaterThan(0) + expect(id2).toBeGreaterThan(0) + expect(id1).not.toBe(id2) + expect(id3).toBe(id1) // duplicate should return same ID + }) + + test("should retrieve registered links by ID", () => { + const id = renderer.registerLink("https://example.com") + const url = renderer.getLink(id) + + expect(url).toBe("https://example.com") + }) + + test("should return null for invalid link ID", () => { + const url = renderer.getLink(9999) + + expect(url).toBeNull() + }) + + test("should clear all links", () => { + const id = renderer.registerLink("https://example.com") + expect(renderer.getLink(id)).toBe("https://example.com") + + renderer.clearLinks() + expect(renderer.getLink(id)).toBeNull() + }) +}) + +describe("TextRenderable with links", () => { + test("should render text with hyperlinks", async () => { + const text = new TextRenderable(renderer, { + content: t`Click ${link("https://example.com")("here")} to visit`, + width: 30, + height: 1, + }) + + renderer.root.add(text) + await renderOnce() + + expect(text.plainText).toBe("Click here to visit") + }) + + test("should detect link at position", async () => { + const text = new TextRenderable(renderer, { + content: t`Click ${link("https://example.com")("here")} to visit`, + width: 30, + height: 1, + }) + + renderer.root.add(text) + await renderOnce() + + // "Click " is 6 chars, link starts at position 6 + const linkUrl = text.getLinkAt(text.x + 6, text.y) + expect(linkUrl).toBe("https://example.com") + + // Position 0 should not have a link + const noLink = text.getLinkAt(text.x, text.y) + expect(noLink).toBeNull() + }) + + test("should return 0 for getLinkIdAt outside bounds", async () => { + const text = new TextRenderable(renderer, { + content: t`${link("https://example.com")("Link")}`, + width: 10, + height: 1, + }) + + renderer.root.add(text) + await renderOnce() + + // Outside renderable bounds + const linkId = text.getLinkIdAt(-1, -1) + expect(linkId).toBe(0) + }) +}) + +describe("link click handling", () => { + test("should call onLinkClick handler on alt+click", async () => { + let clickedUrl: string | undefined + let clickedEvent: any + + const text = new TextRenderable(renderer, { + content: t`${link("https://example.com")("Click me")}`, + width: 20, + height: 1, + onLinkClick: (url, event) => { + clickedUrl = url + clickedEvent = event + }, + }) + + renderer.root.add(text) + await renderOnce() + + // Alt+click on the link + await mockMouse.click(text.x + 2, text.y, 0, { modifiers: { alt: true } }) + await renderOnce() + + expect(clickedUrl).toBe("https://example.com") + expect(clickedEvent).toBeDefined() + }) + + test("should not call onLinkClick without alt modifier", async () => { + let clickedUrl: string | undefined + + const text = new TextRenderable(renderer, { + content: t`${link("https://example.com")("Click me")}`, + width: 20, + height: 1, + onLinkClick: (url) => { + clickedUrl = url + }, + }) + + renderer.root.add(text) + await renderOnce() + + // Regular click (no alt) + await mockMouse.click(text.x + 2, text.y) + await renderOnce() + + expect(clickedUrl).toBeUndefined() + }) + + test("should not call onLinkClick when clicking outside link", async () => { + let clickedUrl: string | undefined + + const text = new TextRenderable(renderer, { + content: t`No link here ${link("https://example.com")("but here")}`, + width: 30, + height: 1, + onLinkClick: (url) => { + clickedUrl = url + }, + }) + + renderer.root.add(text) + await renderOnce() + + // Alt+click on "No link here" (before the link) + await mockMouse.click(text.x + 2, text.y, 0, { modifiers: { alt: true } }) + await renderOnce() + + expect(clickedUrl).toBeUndefined() + }) + + test("alt+click should not start text selection", async () => { + const text = new TextRenderable(renderer, { + content: t`${link("https://example.com")("Click me")}`, + width: 20, + height: 1, + selectable: true, + }) + + renderer.root.add(text) + await renderOnce() + + // Alt+click + await mockMouse.pressDown(text.x + 2, text.y, 0, { modifiers: { alt: true } }) + await renderOnce() + + // Should not have started selection + expect(renderer.getSelection()).toBeNull() + }) + + test("regular click should still start selection on selectable text", async () => { + const text = new TextRenderable(renderer, { + content: t`${link("https://example.com")("Click me")}`, + width: 20, + height: 1, + selectable: true, + }) + + renderer.root.add(text) + await renderOnce() + + // Regular click (no alt) + await mockMouse.pressDown(text.x + 2, text.y) + await renderOnce() + + // Should have started selection + expect(renderer.getSelection()).not.toBeNull() + }) +}) diff --git a/packages/core/src/text-buffer-view.ts b/packages/core/src/text-buffer-view.ts index b5c8d60b..cb7a6291 100644 --- a/packages/core/src/text-buffer-view.ts +++ b/packages/core/src/text-buffer-view.ts @@ -171,6 +171,11 @@ export class TextBufferView { return this.lib.textBufferViewMeasureForDimensions(this.viewPtr, width, height) } + public getLinkIdAtPosition(x: number, y: number): number { + this.guard() + return this.lib.textBufferViewGetLinkIdAtPosition(this.viewPtr, x, y) + } + public getVirtualLineCount(): number { this.guard() return this.lib.textBufferViewGetVirtualLineCount(this.viewPtr) diff --git a/packages/core/src/text-buffer.ts b/packages/core/src/text-buffer.ts index 0d1c0ec4..b873c3ed 100644 --- a/packages/core/src/text-buffer.ts +++ b/packages/core/src/text-buffer.ts @@ -11,8 +11,11 @@ export interface TextChunk { fg?: RGBA bg?: RGBA attributes?: number + href?: string } +export type LinkResolver = (uri: string) => number + export class TextBuffer { private lib: RenderLib private bufferPtr: Pointer @@ -24,12 +27,17 @@ export class TextBuffer { private _textBytes?: Uint8Array private _memId?: number private _appendedChunks: Uint8Array[] = [] + private _linkResolver?: LinkResolver constructor(lib: RenderLib, ptr: Pointer) { this.lib = lib this.bufferPtr = ptr } + public setLinkResolver(resolver: LinkResolver | undefined): void { + this._linkResolver = resolver + } + static create(widthMethod: WidthMethod): TextBuffer { const lib = resolveRenderLib() return lib.createTextBuffer(widthMethod) @@ -85,13 +93,20 @@ export class TextBuffer { public setStyledText(text: StyledText): void { this.guard() - // TODO: This should not be necessary anymore, the struct packing should take care of this - const chunks = text.chunks.map((chunk) => ({ - text: chunk.text, - fg: chunk.fg || null, - bg: chunk.bg || null, - attributes: chunk.attributes ?? 0, - })) + // Map chunks and resolve hrefs to link_ids if a resolver is available + const chunks = text.chunks.map((chunk) => { + let link_id = 0 + if (chunk.href && this._linkResolver) { + link_id = this._linkResolver(chunk.href) + } + return { + text: chunk.text, + fg: chunk.fg || null, + bg: chunk.bg || null, + attributes: chunk.attributes ?? 0, + link_id, + } + }) this.lib.textBufferSetStyledText(this.bufferPtr, chunks) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7387701c..868cc4bf 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -65,6 +65,10 @@ export interface RenderContext extends EventEmitter { clearSelection: () => void startSelection: (renderable: Renderable, x: number, y: number) => void updateSelection: (currentRenderable: Renderable | undefined, x: number, y: number) => void + // Hyperlink support + registerLink: (uri: string) => number + getLink: (linkId: number) => string | null + clearLinks: () => void } export type Timeout = ReturnType | undefined diff --git a/packages/core/src/zig-structs.ts b/packages/core/src/zig-structs.ts index 8c0babb6..9c776972 100644 --- a/packages/core/src/zig-structs.ts +++ b/packages/core/src/zig-structs.ts @@ -27,6 +27,8 @@ export const StyledChunkStruct = defineStruct([ }, ], ["attributes", "u8", { optional: true }], + ["_pad1", "u8", { default: 0 }], + ["link_id", "u16", { optional: true, default: 0 }], ]) export const HighlightStruct = defineStruct([ diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index 8a2c801e..89e3dd3a 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -304,6 +304,18 @@ function getOpenTUILib(libPath?: string) { args: ["ptr", "i64"], returns: "void", }, + registerLink: { + args: ["ptr", "ptr", "u64"], + returns: "u16", + }, + getLink: { + args: ["ptr", "u16", "ptr", "u64"], + returns: "u64", + }, + clearLinks: { + args: ["ptr"], + returns: "void", + }, enableMouse: { args: ["ptr", "bool"], returns: "void", @@ -557,6 +569,10 @@ function getOpenTUILib(libPath?: string) { args: ["ptr", "ptr"], returns: "void", }, + textBufferViewGetLinkIdAtPosition: { + args: ["ptr", "u32", "u32"], + returns: "u16", + }, textBufferViewMeasureForDimensions: { args: ["ptr", "u32", "u32", "ptr"], returns: "bool", @@ -1285,6 +1301,9 @@ export interface RenderLib { dumpHitGrid: (renderer: Pointer) => void dumpBuffers: (renderer: Pointer, timestamp?: number) => void dumpStdoutBuffer: (renderer: Pointer, timestamp?: number) => void + registerLink: (renderer: Pointer, uri: string) => number + getLink: (renderer: Pointer, linkId: number) => string | null + clearLinks: (renderer: Pointer) => void enableMouse: (renderer: Pointer, enableMovement: boolean) => void disableMouse: (renderer: Pointer) => void enableKittyKeyboard: (renderer: Pointer, flags: number) => void @@ -1380,6 +1399,7 @@ export interface RenderLib { textBufferViewGetPlainTextBytes: (view: Pointer, maxLength: number) => Uint8Array | null textBufferViewSetTabIndicator: (view: Pointer, indicator: number) => void textBufferViewSetTabIndicatorColor: (view: Pointer, color: RGBA) => void + textBufferViewGetLinkIdAtPosition: (view: Pointer, x: number, y: number) => number textBufferViewMeasureForDimensions: ( view: Pointer, width: number, @@ -2047,6 +2067,25 @@ class FFIRenderLib implements RenderLib { this.opentui.symbols.dumpStdoutBuffer(renderer, ts) } + public registerLink(renderer: Pointer, uri: string): number { + const uriBytes = this.encoder.encode(uri) + return this.opentui.symbols.registerLink(renderer, ptr(uriBytes), uriBytes.length) + } + + public getLink(renderer: Pointer, linkId: number): string | null { + if (linkId === 0) return null + const maxLen = 2048 // Max URL length + const outBuffer = new Uint8Array(maxLen) + const actualLen = this.opentui.symbols.getLink(renderer, linkId, ptr(outBuffer), maxLen) + const len = typeof actualLen === "bigint" ? Number(actualLen) : actualLen + if (len === 0) return null + return this.decoder.decode(outBuffer.slice(0, len)) + } + + public clearLinks(renderer: Pointer): void { + this.opentui.symbols.clearLinks(renderer) + } + public enableMouse(renderer: Pointer, enableMovement: boolean): void { this.opentui.symbols.enableMouse(renderer, enableMovement) } @@ -2185,7 +2224,7 @@ class FFIRenderLib implements RenderLib { public textBufferSetStyledText( buffer: Pointer, - chunks: Array<{ text: string; fg?: RGBA | null; bg?: RGBA | null; attributes?: number }>, + chunks: Array<{ text: string; fg?: RGBA | null; bg?: RGBA | null; attributes?: number; link_id?: number }>, ): void { // TODO: This should be a filter on the struct packing to not iterate twice const nonEmptyChunks = chunks.filter((c) => c.text.length > 0) @@ -2456,6 +2495,10 @@ class FFIRenderLib implements RenderLib { this.opentui.symbols.textBufferViewSetTabIndicatorColor(view, color.buffer) } + public textBufferViewGetLinkIdAtPosition(view: Pointer, x: number, y: number): number { + return this.opentui.symbols.textBufferViewGetLinkIdAtPosition(view, x, y) + } + public textBufferViewMeasureForDimensions( view: Pointer, width: number, diff --git a/packages/core/src/zig/ansi.zig b/packages/core/src/zig/ansi.zig index f12586a9..e66ee074 100644 --- a/packages/core/src/zig/ansi.zig +++ b/packages/core/src/zig/ansi.zig @@ -139,6 +139,30 @@ pub const ANSI = struct { writer.writeByteNTimes('\n', height - 1) catch return AnsiError.WriteFailed; } } + + // OSC 8 - Hyperlinks + // Format: OSC 8 ; params ; URI ST (where ST is \x1b\\ or \x07) + // Using \x1b\\ (ESC + backslash) as the string terminator for better compatibility + + /// Start a hyperlink with the given URI + pub fn hyperlinkStartOutput(writer: anytype, uri: []const u8) AnsiError!void { + // Format: ESC ] 8 ; ; ESC \ + writer.writeAll("\x1b]8;;") catch return AnsiError.WriteFailed; + writer.writeAll(uri) catch return AnsiError.WriteFailed; + writer.writeAll("\x1b\\") catch return AnsiError.WriteFailed; + } + + /// Start a hyperlink with a unique ID and the given URI + pub fn hyperlinkStartWithIdOutput(writer: anytype, id: u16, uri: []const u8) AnsiError!void { + // Format: ESC ] 8 ; id= ; ESC \ + std.fmt.format(writer, "\x1b]8;id={d};{s}\x1b\\", .{ id, uri }) catch return AnsiError.WriteFailed; + } + + /// End the current hyperlink + pub fn hyperlinkEndOutput(writer: anytype) AnsiError!void { + // Format: ESC ] 8 ; ; ESC \ + writer.writeAll("\x1b]8;;\x1b\\") catch return AnsiError.WriteFailed; + } }; pub const TextAttributes = struct { diff --git a/packages/core/src/zig/buffer.zig b/packages/core/src/zig/buffer.zig index 3ee4194c..52a85105 100644 --- a/packages/core/src/zig/buffer.zig +++ b/packages/core/src/zig/buffer.zig @@ -86,6 +86,7 @@ pub const Cell = struct { fg: RGBA, bg: RGBA, attributes: u8, + link_id: u16 = 0, // 0 = no link, >0 = index into link registry }; fn isRGBAWithAlpha(color: RGBA) bool { @@ -583,6 +584,8 @@ pub const OptimizedBuffer = struct { } const finalAttributes = if (preserveChar) destCell.attributes else overlayCell.attributes; + // Prefer overlay link_id if set, otherwise preserve destination + const finalLinkId = if (overlayCell.link_id != 0) overlayCell.link_id else destCell.link_id; // When overlay background is fully transparent, preserve destination background alpha const finalBgAlpha = if (overlayCell.bg[3] == 0.0) destCell.bg[3] else overlayCell.bg[3]; @@ -592,6 +595,7 @@ pub const OptimizedBuffer = struct { .fg = finalFg, .bg = .{ blendedBgRgb[0], blendedBgRgb[1], blendedBgRgb[2], finalBgAlpha }, .attributes = finalAttributes, + .link_id = finalLinkId, }; } @@ -606,6 +610,19 @@ pub const OptimizedBuffer = struct { fg: RGBA, bg: RGBA, attributes: u8, + ) !void { + return self.setCellWithAlphaBlendingAndLink(x, y, char, fg, bg, attributes, 0); + } + + pub fn setCellWithAlphaBlendingAndLink( + self: *OptimizedBuffer, + x: u32, + y: u32, + char: u32, + fg: RGBA, + bg: RGBA, + attributes: u8, + link_id: u16, ) !void { if (!self.isPointInScissor(@intCast(x), @intCast(y))) return; @@ -614,7 +631,7 @@ pub const OptimizedBuffer = struct { const effectiveFg = RGBA{ fg[0], fg[1], fg[2], fg[3] * opacity }; const effectiveBg = RGBA{ bg[0], bg[1], bg[2], bg[3] * opacity }; - const overlayCell = Cell{ .char = char, .fg = effectiveFg, .bg = effectiveBg, .attributes = attributes }; + const overlayCell = Cell{ .char = char, .fg = effectiveFg, .bg = effectiveBg, .attributes = attributes, .link_id = link_id }; if (self.get(x, y)) |destCell| { const blendedCell = blendCells(overlayCell, destCell); @@ -1036,6 +1053,7 @@ pub const OptimizedBuffer = struct { var lineFg = text_buffer.default_fg orelse RGBA{ 1.0, 1.0, 1.0, 1.0 }; var lineBg = text_buffer.default_bg orelse RGBA{ 0.0, 0.0, 0.0, 0.0 }; var lineAttributes = text_buffer.default_attributes orelse 0; + var lineLinkId: u16 = 0; // Current link ID for hyperlinks // Find the span that contains the starting render position (col_offset + horizontal_offset) const start_col = col_offset + horizontal_offset; @@ -1049,12 +1067,15 @@ pub const OptimizedBuffer = struct { std.math.maxInt(u32); // Apply the style at the starting position - if (span_idx < spans.len and spans[span_idx].col <= start_col and spans[span_idx].style_id != 0) { - if (text_buffer.getSyntaxStyle()) |style| { - if (style.resolveById(spans[span_idx].style_id)) |resolved_style| { - if (resolved_style.fg) |fg| lineFg = fg; - if (resolved_style.bg) |bg| lineBg = bg; - lineAttributes |= resolved_style.attributes; + if (span_idx < spans.len and spans[span_idx].col <= start_col) { + lineLinkId = spans[span_idx].link_id; + if (spans[span_idx].style_id != 0) { + if (text_buffer.getSyntaxStyle()) |style| { + if (style.resolveById(spans[span_idx].style_id)) |resolved_style| { + if (resolved_style.fg) |fg| lineFg = fg; + if (resolved_style.bg) |bg| lineBg = bg; + lineAttributes |= resolved_style.attributes; + } } } } @@ -1158,6 +1179,7 @@ pub const OptimizedBuffer = struct { lineFg = text_buffer.default_fg orelse RGBA{ 1.0, 1.0, 1.0, 1.0 }; lineBg = text_buffer.default_bg orelse RGBA{ 0.0, 0.0, 0.0, 0.0 }; lineAttributes = text_buffer.default_attributes orelse 0; + lineLinkId = new_span.link_id; if (text_buffer.getSyntaxStyle()) |style| { if (new_span.style_id != 0) { @@ -1223,13 +1245,14 @@ pub const OptimizedBuffer = struct { const char = if (tab_col == 0 and tab_indicator != null) tab_indicator.? else DEFAULT_SPACE_CHAR; const fg = if (tab_col == 0 and tab_indicator_color != null) tab_indicator_color.? else drawFg; - try self.setCellWithAlphaBlending( + try self.setCellWithAlphaBlendingAndLink( @intCast(currentX + @as(i32, @intCast(tab_col))), @intCast(currentY), char, fg, drawBg, drawAttributes, + lineLinkId, ); } } else { @@ -1247,13 +1270,14 @@ pub const OptimizedBuffer = struct { encoded_char = gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, g_width); } - try self.setCellWithAlphaBlending( + try self.setCellWithAlphaBlendingAndLink( @intCast(currentX), @intCast(currentY), encoded_char, drawFg, drawBg, drawAttributes, + lineLinkId, ); } diff --git a/packages/core/src/zig/lib.zig b/packages/core/src/zig/lib.zig index 58cd6b6a..1f31bf49 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -398,6 +398,26 @@ export fn dumpStdoutBuffer(rendererPtr: *renderer.CliRenderer, timestamp: i64) v rendererPtr.dumpStdoutBuffer(timestamp); } +// Link registry functions for hyperlinks +export fn registerLink(rendererPtr: *renderer.CliRenderer, uriPtr: [*]const u8, uriLen: usize) u16 { + if (uriLen == 0) return 0; + const uri = uriPtr[0..uriLen]; + return rendererPtr.registerLink(uri); +} + +export fn getLink(rendererPtr: *renderer.CliRenderer, linkId: u16, outPtr: [*]u8, maxLen: usize) usize { + if (rendererPtr.getLink(linkId)) |uri| { + const copyLen = @min(uri.len, maxLen); + @memcpy(outPtr[0..copyLen], uri[0..copyLen]); + return copyLen; + } + return 0; +} + +export fn clearLinks(rendererPtr: *renderer.CliRenderer) void { + rendererPtr.clearLinks(); +} + export fn enableMouse(rendererPtr: *renderer.CliRenderer, enableMovement: bool) void { rendererPtr.enableMouse(enableMovement); } @@ -671,6 +691,10 @@ export fn textBufferViewGetPlainText(view: *text_buffer_view.UnifiedTextBufferVi return view.getPlainTextIntoBuffer(outBuffer); } +export fn textBufferViewGetLinkIdAtPosition(view: *text_buffer_view.UnifiedTextBufferView, x: u32, y: u32) u16 { + return view.getLinkIdAtPosition(x, y); +} + export fn textBufferViewSetTabIndicator(view: *text_buffer_view.UnifiedTextBufferView, indicator: u32) void { view.setTabIndicator(indicator); } diff --git a/packages/core/src/zig/link-registry.zig b/packages/core/src/zig/link-registry.zig new file mode 100644 index 00000000..c1ca92b3 --- /dev/null +++ b/packages/core/src/zig/link-registry.zig @@ -0,0 +1,147 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +pub const LinkRegistryError = error{ + OutOfMemory, + InvalidLinkId, +}; + +/// A link entry in the registry +pub const LinkEntry = struct { + uri: []const u8, + active: bool, +}; + +/// Registry for hyperlink URLs +/// Uses u16 IDs (0 = no link, 1-65535 = valid link IDs) +pub const LinkRegistry = struct { + links: std.ArrayListUnmanaged(LinkEntry), + free_slots: std.ArrayListUnmanaged(u16), + allocator: Allocator, + /// Deduplication map: URI hash -> link_id + uri_to_id: std.StringHashMapUnmanaged(u16), + + pub fn init(allocator: Allocator) LinkRegistry { + return .{ + .links = .{}, + .free_slots = .{}, + .allocator = allocator, + .uri_to_id = .{}, + }; + } + + pub fn deinit(self: *LinkRegistry) void { + for (self.links.items) |entry| { + if (entry.active) { + self.allocator.free(entry.uri); + } + } + self.links.deinit(self.allocator); + self.free_slots.deinit(self.allocator); + self.uri_to_id.deinit(self.allocator); + } + + /// Register a URI and return its link ID (1-based) + /// Deduplicates URIs - returns existing ID if URI already registered + pub fn register(self: *LinkRegistry, uri: []const u8) LinkRegistryError!u16 { + // Check for existing registration (deduplication) + if (self.uri_to_id.get(uri)) |existing_id| { + return existing_id; + } + + // Copy the URI to owned memory + const owned_uri = self.allocator.dupe(u8, uri) catch return LinkRegistryError.OutOfMemory; + errdefer self.allocator.free(owned_uri); + + var id: u16 = undefined; + + // Try to reuse a free slot first + if (self.free_slots.items.len > 0) { + id = self.free_slots.items[self.free_slots.items.len - 1]; + _ = self.free_slots.pop(); + self.links.items[id - 1] = LinkEntry{ + .uri = owned_uri, + .active = true, + }; + } else { + // No free slots, allocate a new one + if (self.links.items.len >= 65534) { + self.allocator.free(owned_uri); + return LinkRegistryError.OutOfMemory; + } + id = @intCast(self.links.items.len + 1); // 1-based + self.links.append(self.allocator, LinkEntry{ + .uri = owned_uri, + .active = true, + }) catch { + self.allocator.free(owned_uri); + return LinkRegistryError.OutOfMemory; + }; + } + + // Add to dedup map + self.uri_to_id.put(self.allocator, owned_uri, id) catch { + // Rollback: mark slot as inactive + self.links.items[id - 1].active = false; + self.free_slots.append(self.allocator, id) catch {}; + self.allocator.free(owned_uri); + return LinkRegistryError.OutOfMemory; + }; + + return id; + } + + /// Get URI for a link ID (1-based, 0 returns null) + pub fn get(self: *const LinkRegistry, id: u16) ?[]const u8 { + if (id == 0) return null; + const idx = id - 1; + if (idx >= self.links.items.len) return null; + const entry = self.links.items[idx]; + if (!entry.active) return null; + return entry.uri; + } + + /// Unregister a link by ID + pub fn unregister(self: *LinkRegistry, id: u16) LinkRegistryError!void { + if (id == 0) return LinkRegistryError.InvalidLinkId; + const idx = id - 1; + if (idx >= self.links.items.len) return LinkRegistryError.InvalidLinkId; + + var entry = &self.links.items[idx]; + if (!entry.active) return LinkRegistryError.InvalidLinkId; + + // Remove from dedup map + _ = self.uri_to_id.remove(entry.uri); + + // Free the URI + self.allocator.free(entry.uri); + + // Mark slot as inactive + entry.active = false; + entry.uri = &[_]u8{}; + + // Add to free slots list + self.free_slots.append(self.allocator, id) catch return LinkRegistryError.OutOfMemory; + } + + /// Clear all links + pub fn clear(self: *LinkRegistry) void { + for (self.links.items) |entry| { + if (entry.active) { + self.allocator.free(entry.uri); + } + } + self.links.clearRetainingCapacity(); + self.free_slots.clearRetainingCapacity(); + self.uri_to_id.clearRetainingCapacity(); + } + + /// Get number of active links + pub fn getActiveCount(self: *const LinkRegistry) usize { + var count: usize = 0; + for (self.links.items) |entry| { + if (entry.active) count += 1; + } + return count; + } +}; diff --git a/packages/core/src/zig/renderer.zig b/packages/core/src/zig/renderer.zig index 0f98e914..eee5894a 100644 --- a/packages/core/src/zig/renderer.zig +++ b/packages/core/src/zig/renderer.zig @@ -5,10 +5,12 @@ const buf = @import("buffer.zig"); const gp = @import("grapheme.zig"); const Terminal = @import("terminal.zig"); const logger = @import("logger.zig"); +const link_registry = @import("link-registry.zig"); pub const RGBA = ansi.RGBA; pub const OptimizedBuffer = buf.OptimizedBuffer; pub const TextAttributes = ansi.TextAttributes; +pub const LinkRegistry = link_registry.LinkRegistry; pub const CursorStyle = Terminal.CursorStyle; const CLEAR_CHAR = '\u{0a00}'; @@ -106,6 +108,9 @@ pub const CliRenderer = struct { lastCursorBlinking: ?bool = null, lastCursorColorRGB: ?[3]u8 = null, + // Link registry for hyperlinks + link_registry: LinkRegistry, + // Preallocated output buffer var outputBuffer: [OUTPUT_BUFFER_SIZE]u8 = undefined; var outputBufferLen: usize = 0; @@ -189,6 +194,7 @@ pub const CliRenderer = struct { .lastCursorStyleTag = null, .lastCursorBlinking = null, .lastCursorColorRGB = null, + .link_registry = LinkRegistry.init(allocator), .renderStats = .{ .lastFrameTime = 0, @@ -261,6 +267,8 @@ pub const CliRenderer = struct { self.allocator.free(self.currentHitGrid); self.allocator.free(self.nextHitGrid); + self.link_registry.deinit(); + self.allocator.destroy(self); } @@ -555,9 +563,11 @@ pub const CliRenderer = struct { var currentFg: ?RGBA = null; var currentBg: ?RGBA = null; var currentAttributes: i16 = -1; + var currentLinkId: u16 = 0; var utf8Buf: [4]u8 = undefined; const colorEpsilon: f32 = COLOR_EPSILON_DEFAULT; + const hyperlinksEnabled = self.terminal.getCapabilities().hyperlinks; for (0..self.height) |uy| { const y = @as(u32, @intCast(uy)); @@ -575,8 +585,9 @@ pub const CliRenderer = struct { if (!force) { const charEqual = currentCell.?.char == nextCell.?.char; const attrEqual = currentCell.?.attributes == nextCell.?.attributes; + const linkEqual = currentCell.?.link_id == nextCell.?.link_id; - if (charEqual and attrEqual and + if (charEqual and attrEqual and linkEqual and buf.rgbaEqual(currentCell.?.fg, nextCell.?.fg, colorEpsilon) and buf.rgbaEqual(currentCell.?.bg, nextCell.?.bg, colorEpsilon)) { @@ -630,6 +641,21 @@ pub const CliRenderer = struct { ansi.TextAttributes.applyAttributesOutputWriter(writer, cell.attributes) catch {}; } + // Handle hyperlinks - emit OSC 8 when link_id changes + if (hyperlinksEnabled and cell.link_id != currentLinkId) { + // End previous hyperlink if there was one + if (currentLinkId != 0) { + ansi.ANSI.hyperlinkEndOutput(writer) catch {}; + } + // Start new hyperlink if this cell has one + if (cell.link_id != 0) { + if (self.link_registry.get(cell.link_id)) |uri| { + ansi.ANSI.hyperlinkStartWithIdOutput(writer, cell.link_id, uri) catch {}; + } + } + currentLinkId = cell.link_id; + } + // Handle grapheme characters if (gp.isGraphemeChar(cell.char)) { const gid: u32 = gp.graphemeIdFromChar(cell.char); @@ -673,6 +699,11 @@ pub const CliRenderer = struct { } } + // Close any open hyperlink before resetting + if (hyperlinksEnabled and currentLinkId != 0) { + ansi.ANSI.hyperlinkEndOutput(writer) catch {}; + } + writer.writeAll(ansi.ANSI.reset) catch {}; const cursorPos = self.terminal.getCursorPosition(); @@ -810,6 +841,19 @@ pub const CliRenderer = struct { } } + // Link registry methods for hyperlinks + pub fn registerLink(self: *CliRenderer, uri: []const u8) u16 { + return self.link_registry.register(uri) catch 0; + } + + pub fn getLink(self: *const CliRenderer, link_id: u16) ?[]const u8 { + return self.link_registry.get(link_id); + } + + pub fn clearLinks(self: *CliRenderer) void { + self.link_registry.clear(); + } + fn dumpSingleBuffer(self: *CliRenderer, buffer: *OptimizedBuffer, buffer_name: []const u8, timestamp: i64) void { std.fs.cwd().makeDir("buffer_dump") catch |err| switch (err) { error.PathAlreadyExists => {}, diff --git a/packages/core/src/zig/terminal.zig b/packages/core/src/zig/terminal.zig index 65bd5ec4..d04a6d1b 100644 --- a/packages/core/src/zig/terminal.zig +++ b/packages/core/src/zig/terminal.zig @@ -259,6 +259,15 @@ fn checkEnvironmentOverrides(self: *Terminal) void { self.caps.unicode = .unicode; } else if (std.mem.eql(u8, prog, "Apple_Terminal")) { self.caps.unicode = .wcwidth; + } else if (std.mem.eql(u8, prog, "ghostty")) { + // Ghostty supports OSC 8 hyperlinks + self.caps.hyperlinks = true; + } else if (std.mem.eql(u8, prog, "iTerm.app")) { + // iTerm2 supports OSC 8 hyperlinks + self.caps.hyperlinks = true; + } else if (std.mem.eql(u8, prog, "WezTerm")) { + // WezTerm supports OSC 8 hyperlinks + self.caps.hyperlinks = true; } } @@ -411,6 +420,15 @@ pub fn processCapabilityResponse(self: *Terminal, response: []const u8) void { self.caps.hyperlinks = true; } + // Ghostty detection - supports OSC 8 hyperlinks + if (std.mem.indexOf(u8, response, "ghostty")) |_| { + self.caps.kitty_keyboard = true; + self.caps.unicode = .unicode; + self.caps.rgb = true; + self.caps.bracketed_paste = true; + self.caps.hyperlinks = true; + } + // Kitty keyboard protocol detection via CSI ? u response // Terminals supporting the protocol respond to CSI ? u with CSI ? u // Examples: \x1b[?0u (ghostty, alacritty), \x1b[?1u, etc. diff --git a/packages/core/src/zig/text-buffer-segment.zig b/packages/core/src/zig/text-buffer-segment.zig index d5913774..bec90cd6 100644 --- a/packages/core/src/zig/text-buffer-segment.zig +++ b/packages/core/src/zig/text-buffer-segment.zig @@ -140,6 +140,7 @@ pub const Highlight = struct { style_id: u32, priority: u8, hl_ref: u16 = 0, + link_id: u16 = 0, // 0 = no link, >0 = link registry ID }; /// Pre-computed style span for efficient rendering @@ -148,6 +149,7 @@ pub const StyleSpan = struct { col: u32, style_id: u32, next_col: u32, + link_id: u16 = 0, // 0 = no link, >0 = link registry ID }; /// A segment in the unified rope - either text content or a line break marker diff --git a/packages/core/src/zig/text-buffer-view.zig b/packages/core/src/zig/text-buffer-view.zig index 633e48fa..f6729b2c 100644 --- a/packages/core/src/zig/text-buffer-view.zig +++ b/packages/core/src/zig/text-buffer-view.zig @@ -685,6 +685,42 @@ pub const UnifiedTextBufferView = struct { return self.tab_indicator_color; } + /// Get the link_id at a given screen position (relative to the text buffer view) + /// Returns 0 if no link at that position, or the link_id if there is one + /// x, y are in viewport coordinates (0-based, relative to the visible area) + pub fn getLinkIdAtPosition(self: *const Self, screen_x: u32, screen_y: u32) u16 { + // Convert screen position to virtual line index (accounting for viewport scroll) + const vline_idx: usize = if (self.viewport) |vp| + vp.y + screen_y + else + screen_y; + + if (vline_idx >= self.virtual_lines.items.len) { + return 0; + } + + // Get the virtual line and its spans + const vline = &self.virtual_lines.items[vline_idx]; + const spans = self.text_buffer.getLineSpans(vline.source_line); + + if (spans.len == 0) { + return 0; + } + + // Calculate the column in the source line (accounting for viewport scroll and wrap offset) + const horizontal_offset: u32 = if (self.viewport) |vp| vp.x else 0; + const source_col = vline.source_col_offset + horizontal_offset + screen_x; + + // Find the span that contains this column + for (spans) |span| { + if (source_col >= span.col and source_col < span.next_col) { + return span.link_id; + } + } + + return 0; + } + /// Measure dimensions for given width/height WITHOUT modifying virtual lines cache /// This is useful for Yoga measure functions that need to know dimensions without committing changes /// Special case: width=0 means "measure intrinsic/max-content width" (no wrapping) diff --git a/packages/core/src/zig/text-buffer.zig b/packages/core/src/zig/text-buffer.zig index 11088093..71f2e180 100644 --- a/packages/core/src/zig/text-buffer.zig +++ b/packages/core/src/zig/text-buffer.zig @@ -37,6 +37,8 @@ pub const StyledChunk = extern struct { fg_ptr: ?[*]const f32, bg_ptr: ?[*]const f32, attributes: u8, + _pad1: u8 = 0, // Padding for alignment + link_id: u16 = 0, // 0 = no link, >0 = link registry ID }; pub const UnifiedTextBuffer = struct { @@ -582,6 +584,19 @@ pub const UnifiedTextBuffer = struct { style_id: u32, priority: u8, hl_ref: u16, + ) TextBufferError!void { + return self.addHighlightWithLink(line_idx, col_start, col_end, style_id, priority, hl_ref, 0); + } + + pub fn addHighlightWithLink( + self: *Self, + line_idx: usize, + col_start: u32, + col_end: u32, + style_id: u32, + priority: u8, + hl_ref: u16, + link_id: u16, ) TextBufferError!void { const line_count = self.getLineCount(); if (line_idx >= line_count) { @@ -600,6 +615,7 @@ pub const UnifiedTextBuffer = struct { .style_id = style_id, .priority = priority, .hl_ref = hl_ref, + .link_id = link_id, }; try self.line_highlights.items[line_idx].append(self.global_allocator, hl); @@ -671,9 +687,10 @@ pub const UnifiedTextBuffer = struct { var current_col: u32 = 0; for (events.items) |event| { - // Find current highest priority style before processing event + // Find current highest priority style and link before processing event var current_priority: i16 = -1; var current_style: u32 = 0; + var current_link: u16 = 0; var it = active.keyIterator(); while (it.next()) |hl_idx| { const hl = highlights[hl_idx.*]; @@ -681,6 +698,10 @@ pub const UnifiedTextBuffer = struct { current_priority = @intCast(hl.priority); current_style = hl.style_id; } + // Use highest priority link_id (non-zero takes precedence) + if (hl.link_id != 0 and current_link == 0) { + current_link = hl.link_id; + } } // Emit span for the segment leading up to this event @@ -689,6 +710,7 @@ pub const UnifiedTextBuffer = struct { .col = current_col, .style_id = current_style, .next_col = event.col, + .link_id = current_link, }); current_col = event.col; } @@ -798,6 +820,77 @@ pub const UnifiedTextBuffer = struct { iter_mod.walkLines(&self.rope, &ctx, Context.callback, false); } + /// Add highlight by character range with link ID + pub fn addHighlightByCharRangeWithLink( + self: *Self, + char_start: u32, + char_end: u32, + style_id: u32, + priority: u8, + hl_ref: u16, + link_id: u16, + ) TextBufferError!void { + const line_count = self.getLineCount(); + if (char_start >= char_end or line_count == 0) { + return; + } + + // Walk lines to find which lines this highlight affects + const Context = struct { + buffer: *Self, + char_start: u32, + char_end: u32, + style_id: u32, + priority: u8, + hl_ref: u16, + link_id: u16, + start_line_idx: ?usize = null, + + fn callback(ctx_ptr: *anyopaque, line_info: LineInfo) void { + const ctx = @as(*@This(), @ptrCast(@alignCast(ctx_ptr))); + const line_start_char = line_info.char_offset; + const line_end_char = line_info.char_offset + line_info.width; + + // Skip lines before the highlight + if (line_end_char <= ctx.char_start) return; + // Stop after the highlight ends + if (line_start_char >= ctx.char_end) return; + + // This line overlaps with the highlight + const col_start = if (ctx.char_start > line_start_char) + ctx.char_start - line_start_char + else + 0; + + const col_end = if (ctx.char_end < line_end_char) + ctx.char_end - line_start_char + else + line_info.width; + + ctx.buffer.addHighlightWithLink( + line_info.line_idx, + col_start, + col_end, + ctx.style_id, + ctx.priority, + ctx.hl_ref, + ctx.link_id, + ) catch {}; + } + }; + + var ctx = Context{ + .buffer = self, + .char_start = char_start, + .char_end = char_end, + .style_id = style_id, + .priority = priority, + .hl_ref = hl_ref, + .link_id = link_id, + }; + iter_mod.walkLines(&self.rope, &ctx, Context.callback, false); + } + /// Remove all highlights with a specific reference ID pub fn removeHighlightsByRef(self: *Self, hl_ref: u16) void { for (self.line_highlights.items, 0..) |*hl_list, line_idx| { @@ -936,7 +1029,7 @@ pub const UnifiedTextBuffer = struct { const style_name = std.fmt.bufPrint(&style_name_buf, "chunk{d}", .{i}) catch continue; const style_id = (@constCast(style)).registerStyle(style_name, fg, bg, chunk.attributes) catch continue; - self.addHighlightByCharRange(char_pos, char_pos + chunk_len, style_id, 1, 0) catch {}; + self.addHighlightByCharRangeWithLink(char_pos, char_pos + chunk_len, style_id, 1, 0, chunk.link_id) catch {}; } char_pos += chunk_len;