mirror of
https://github.com/aphrody-labs/opentui.git
synced 2026-10-13 21:08:53 +00:00
markdown: fix hyperlink rendering and detection (#1442)
Make named and bare links clickable without exposing their destinations when the terminal supports OSC 8. Preserve visible URL fallbacks elsewhere and keep links intact across formatting, streaming, wrapping, and late terminal capability detection. Fix #1439
This commit is contained in:
1 parent
9c55da3a63
commit
7581976f4d
16 files changed
+1941
-99
No files matched your search
@@ -10,20 +10,22 @@ function chunk(text: string): TextChunk {
|
||||
|
||||
describe("detectLinks", () => {
|
||||
test("should set link on markup.link.url chunks", () => {
|
||||
const content = "[Click here](https://example.com)"
|
||||
const destination = "custom+v1://example.test/path"
|
||||
const content = `[Click here](${destination})`
|
||||
const start = content.indexOf(destination)
|
||||
const highlights: SimpleHighlight[] = [
|
||||
[0, 1, "markup.link"],
|
||||
[1, 11, "markup.link.label"],
|
||||
[11, 13, "markup.link"],
|
||||
[13, 32, "markup.link.url"],
|
||||
[32, 33, "markup.link"],
|
||||
[11, start, "markup.link"],
|
||||
[start, start + destination.length, "markup.link.url"],
|
||||
[start + destination.length, content.length, "markup.link"],
|
||||
]
|
||||
const chunks = [chunk("["), chunk("Click here"), chunk("]("), chunk("https://example.com"), chunk(")")]
|
||||
const chunks = [chunk("["), chunk("Click here"), chunk("]("), chunk(destination), chunk(")")]
|
||||
|
||||
const result = detectLinks(chunks, { content, highlights })
|
||||
|
||||
expect(result.find((c) => c.text === "https://example.com")!.link).toEqual({ url: "https://example.com" })
|
||||
expect(result.find((c) => c.text === "Click here")!.link).toEqual({ url: "https://example.com" })
|
||||
expect(result.find((c) => c.text === destination)!.link).toEqual({ url: destination })
|
||||
expect(result.find((c) => c.text === "Click here")!.link).toEqual({ url: destination })
|
||||
})
|
||||
|
||||
test("should set link on string.special.url chunks", () => {
|
||||
@@ -56,9 +58,15 @@ describe("detectLinks", () => {
|
||||
})
|
||||
|
||||
test("should return chunks unchanged when no URL scopes exist", () => {
|
||||
const content = "hello world"
|
||||
const highlights: SimpleHighlight[] = [[0, 5, "keyword"]]
|
||||
const chunks = [chunk("hello"), chunk(" world")]
|
||||
const content = "**label** and `code`"
|
||||
const highlights: SimpleHighlight[] = [
|
||||
[0, 9, "markup.strong"],
|
||||
[0, 2, "conceal", { conceal: "" }],
|
||||
[2, 7, "markup.link.label"],
|
||||
[7, 9, "conceal", { conceal: "" }],
|
||||
[14, 20, "markup.raw"],
|
||||
]
|
||||
const chunks = [chunk("label"), chunk(" and "), chunk("code")]
|
||||
|
||||
const result = detectLinks(chunks, { content, highlights })
|
||||
|
||||
@@ -95,4 +103,341 @@ describe("detectLinks", () => {
|
||||
// The label chunk should also get the link
|
||||
expect(result.find((c) => c.text === "Click here")!.link).toEqual({ url: "https://example.com" })
|
||||
})
|
||||
|
||||
test("links formatted and source-replaced label chunks across intervening conceal captures", () => {
|
||||
const content = "[a **A&B** z](https://example.com)"
|
||||
const labelEnd = content.indexOf("]")
|
||||
const entityStart = content.indexOf("&")
|
||||
const urlStart = content.indexOf("https://")
|
||||
const highlights: SimpleHighlight[] = [
|
||||
[1, labelEnd, "markup.link.label"],
|
||||
[3, labelEnd - 2, "markup.strong"],
|
||||
[3, 5, "conceal", { conceal: "" }],
|
||||
[entityStart, entityStart + 5, "character.special", { conceal: "&" }],
|
||||
[labelEnd - 4, labelEnd - 2, "conceal", { conceal: "" }],
|
||||
[labelEnd, labelEnd + 1, "conceal", { conceal: " " }],
|
||||
[urlStart, content.length - 1, "markup.link.url"],
|
||||
]
|
||||
const chunks = ["a ", "A", "&", "B", " z", " ", "(", "https://example.com"].map(chunk)
|
||||
const sourceRanges = [
|
||||
{ start: 1, end: 3 },
|
||||
{ start: 5, end: entityStart },
|
||||
{ start: entityStart, end: entityStart + 5 },
|
||||
{ start: entityStart + 5, end: entityStart + 6 },
|
||||
{ start: labelEnd - 2, end: labelEnd },
|
||||
{ start: labelEnd, end: labelEnd + 1 },
|
||||
{ start: labelEnd + 1, end: labelEnd + 2 },
|
||||
{ start: urlStart, end: content.length - 1 },
|
||||
]
|
||||
|
||||
const result = detectLinks(chunks, { content, highlights, sourceRanges })
|
||||
|
||||
for (const label of result.slice(0, 5)) {
|
||||
expect(label.link).toEqual({ url: "https://example.com" })
|
||||
}
|
||||
expect(result[5].link).toBeUndefined()
|
||||
expect(result[7].link).toEqual({ url: "https://example.com" })
|
||||
})
|
||||
|
||||
test("normalizes escaped named destinations while preserving literal autolink backslashes", () => {
|
||||
const destination = String.raw`custom://example.test/a\(b\)/c\\d\*e`
|
||||
const autolink = String.raw`https://example.test/a\*b`
|
||||
const content = `[label](${destination}) <${autolink}>`
|
||||
const start = content.indexOf(destination)
|
||||
const autoStart = content.indexOf(autolink)
|
||||
const highlights: SimpleHighlight[] = [
|
||||
[1, 6, "markup.link.label"],
|
||||
[start, start + destination.length, "markup.link.url"],
|
||||
[autoStart, autoStart + autolink.length, "markup.link.url"],
|
||||
]
|
||||
|
||||
const result = detectLinks([chunk("label"), chunk(destination), chunk(autolink)], {
|
||||
content,
|
||||
highlights,
|
||||
sourceRanges: [
|
||||
{ start: 1, end: 6 },
|
||||
{ start, end: start + destination.length },
|
||||
{ start: autoStart, end: autoStart + autolink.length },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.map((item) => item.link?.url)).toEqual([
|
||||
String.raw`custom://example.test/a(b)/c\d*e`,
|
||||
String.raw`custom://example.test/a(b)/c\d*e`,
|
||||
autolink,
|
||||
])
|
||||
})
|
||||
|
||||
test("associates each named label with its destination, never an unrelated autolink or bare URL", () => {
|
||||
const content =
|
||||
"[orphan] [other] <https://auto.test> https://bare.test [a](custom+v1://target.test) [b](https://second.test)"
|
||||
const orphanStart = content.indexOf("orphan")
|
||||
const otherStart = content.indexOf("other")
|
||||
const autoStart = content.indexOf("https://auto.test")
|
||||
const bareStart = content.indexOf("https://bare.test")
|
||||
const labelStart = content.indexOf("a](")
|
||||
const targetStart = content.indexOf("custom+v1://target.test")
|
||||
const secondLabel = content.indexOf("b](")
|
||||
const secondTarget = content.indexOf("https://second.test")
|
||||
const highlights: SimpleHighlight[] = [
|
||||
[orphanStart, orphanStart + "orphan".length, "markup.link.label"],
|
||||
[otherStart, otherStart + "other".length, "markup.link.label"],
|
||||
[autoStart, autoStart + "https://auto.test".length, "markup.link.url"],
|
||||
[labelStart, labelStart + 1, "markup.link.label"],
|
||||
[targetStart, targetStart + "custom+v1://target.test".length, "markup.link.url"],
|
||||
[secondLabel, secondLabel + 1, "markup.link.label"],
|
||||
[secondTarget, secondTarget + "https://second.test".length, "markup.link.url"],
|
||||
]
|
||||
const sourceRanges = [
|
||||
{ start: orphanStart, end: orphanStart + "orphan".length },
|
||||
{ start: otherStart, end: otherStart + "other".length },
|
||||
{ start: autoStart, end: autoStart + "https://auto.test".length },
|
||||
{ start: bareStart, end: bareStart + "https://bare.test".length },
|
||||
{ start: labelStart, end: labelStart + 1 },
|
||||
{ start: targetStart, end: targetStart + "custom+v1://target.test".length },
|
||||
{ start: secondLabel, end: secondLabel + 1 },
|
||||
{ start: secondTarget, end: secondTarget + "https://second.test".length },
|
||||
]
|
||||
const chunks = sourceRanges.map(({ start, end }) => chunk(content.slice(start, end)))
|
||||
|
||||
const result = detectLinks(chunks, { content, highlights, sourceRanges })
|
||||
|
||||
expect(result.map((item) => item.link?.url)).toEqual([
|
||||
undefined,
|
||||
undefined,
|
||||
"https://auto.test",
|
||||
"https://bare.test",
|
||||
"custom+v1://target.test",
|
||||
"custom+v1://target.test",
|
||||
"https://second.test",
|
||||
"https://second.test",
|
||||
])
|
||||
})
|
||||
|
||||
test("prioritizes the outer formatted label and explicit destination over visible URL text", () => {
|
||||
const content = "[**before https://visible.test after**](custom+v1://target.test)"
|
||||
const labelStart = 1
|
||||
const labelEnd = content.indexOf("]")
|
||||
const visibleStart = content.indexOf("https://visible.test")
|
||||
const targetStart = content.indexOf("custom+v1://target.test")
|
||||
const highlights: SimpleHighlight[] = [
|
||||
[labelStart, labelEnd, "markup.link.label"],
|
||||
[labelStart + 2, labelEnd, "markup.link.label"],
|
||||
[visibleStart, visibleStart + "https://visible.test".length, "string.special.url"],
|
||||
[targetStart, targetStart + "custom+v1://target.test".length, "markup.link.url"],
|
||||
]
|
||||
const chunks = [chunk("before "), chunk("https://visible.test"), chunk(" after"), chunk("custom+v1://target.test")]
|
||||
const sourceRanges = [
|
||||
{ start: labelStart + 2, end: visibleStart },
|
||||
{ start: visibleStart, end: visibleStart + "https://visible.test".length },
|
||||
{ start: visibleStart + "https://visible.test".length, end: labelEnd - 2 },
|
||||
{ start: targetStart, end: targetStart + "custom+v1://target.test".length },
|
||||
]
|
||||
|
||||
const result = detectLinks(chunks, { content, highlights, sourceRanges })
|
||||
|
||||
expect(result.map((item) => item.link?.url)).toEqual(Array(4).fill("custom+v1://target.test"))
|
||||
})
|
||||
|
||||
test("prioritizes explicit destinations when a visible URL spans the entire label", () => {
|
||||
const content = "[https://visible.test](custom://target.test)"
|
||||
const visibleStart = 1
|
||||
const visibleEnd = content.indexOf("]")
|
||||
const targetStart = content.indexOf("custom://target.test")
|
||||
const highlights: SimpleHighlight[] = [
|
||||
[visibleStart, visibleEnd, "markup.link.label"],
|
||||
[visibleStart, visibleEnd, "string.special.url"],
|
||||
[targetStart, content.length - 1, "markup.link.url"],
|
||||
]
|
||||
|
||||
const result = detectLinks([chunk("https://visible.test"), chunk("custom://target.test")], {
|
||||
content,
|
||||
highlights,
|
||||
sourceRanges: [
|
||||
{ start: visibleStart, end: visibleEnd },
|
||||
{ start: targetStart, end: content.length - 1 },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.map((item) => item.link?.url)).toEqual(["custom://target.test", "custom://target.test"])
|
||||
})
|
||||
|
||||
test("processes dense raw markup, named links, bare URLs, and chunks", () => {
|
||||
const count = 8_000
|
||||
const parts: string[] = []
|
||||
const highlights: SimpleHighlight[] = []
|
||||
const chunks: TextChunk[] = []
|
||||
const sourceRanges: Array<{ start: number; end: number }> = []
|
||||
let offset = 0
|
||||
|
||||
for (let index = 0; index < count; index++) {
|
||||
const raw = `\`https://hidden${index}.test\``
|
||||
const label = `label${index}`
|
||||
const target = `custom://target${index}.test`
|
||||
const visible = `https://visible${index}.test`
|
||||
const part = `${raw} [${label}](${target}) ${visible} `
|
||||
const labelStart = offset + raw.length + 2
|
||||
const targetStart = labelStart + label.length + 2
|
||||
const visibleStart = targetStart + target.length + 2
|
||||
|
||||
parts.push(part)
|
||||
highlights.push([offset, offset + raw.length, "markup.raw"])
|
||||
highlights.push([labelStart, labelStart + label.length, "markup.link.label"])
|
||||
highlights.push([targetStart, targetStart + target.length, "markup.link.url"])
|
||||
chunks.push(chunk(label), chunk(target), chunk(visible))
|
||||
sourceRanges.push(
|
||||
{ start: labelStart, end: labelStart + label.length },
|
||||
{ start: targetStart, end: targetStart + target.length },
|
||||
{ start: visibleStart, end: visibleStart + visible.length },
|
||||
)
|
||||
offset += part.length
|
||||
}
|
||||
|
||||
const result = detectLinks(chunks, { content: parts.join(""), highlights, sourceRanges })
|
||||
|
||||
expect(result).toHaveLength(count * 3)
|
||||
expect(result[0].link?.url).toBe("custom://target0.test")
|
||||
expect(result[1].link?.url).toBe("custom://target0.test")
|
||||
expect(result[2].link?.url).toBe("https://visible0.test")
|
||||
expect(result.at(-3)?.link?.url).toBe(`custom://target${count - 1}.test`)
|
||||
expect(result.at(-2)?.link?.url).toBe(`custom://target${count - 1}.test`)
|
||||
expect(result.at(-1)?.link?.url).toBe(`https://visible${count - 1}.test`)
|
||||
}, 10_000)
|
||||
|
||||
test("splits only bare HTTP URLs from surrounding prose and punctuation", () => {
|
||||
const content = "ftp://ignored.test mailto:user@test See HtTpS://one.test/path, then (http://two.test/a_(b))."
|
||||
const chunks = [chunk(content)]
|
||||
|
||||
const result = detectLinks(chunks, { content, highlights: [] })
|
||||
|
||||
expect(result.map((item) => [item.text, item.link?.url])).toEqual([
|
||||
["ftp://ignored.test mailto:user@test See ", undefined],
|
||||
["HtTpS://one.test/path", "HtTpS://one.test/path"],
|
||||
[", then (", undefined],
|
||||
["http://two.test/a_(b)", "http://two.test/a_(b)"],
|
||||
[").", undefined],
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves interior URL apostrophes while excluding surrounding quotes", () => {
|
||||
const content = "Visit https://example.test/it's-valid or 'https://example.test/quoted'."
|
||||
|
||||
const result = detectLinks([chunk(content)], { content, highlights: [] })
|
||||
|
||||
expect(result.filter((item) => item.link).map((item) => item.link?.url)).toEqual([
|
||||
"https://example.test/it's-valid",
|
||||
"https://example.test/quoted",
|
||||
])
|
||||
})
|
||||
|
||||
test("excludes concealed formatting markers without stripping literal URL characters", () => {
|
||||
for (const marker of ["*", "**", "_", "__", "~~"]) {
|
||||
const url = "https://example.test/path_*~"
|
||||
const content = `${marker}${url}${marker},`
|
||||
const end = marker.length + url.length
|
||||
const highlights: SimpleHighlight[] = []
|
||||
for (let index = 0; index < marker.length; index++) {
|
||||
highlights.push([end + index, end + index + 1, "conceal", { conceal: "" }])
|
||||
}
|
||||
|
||||
const result = detectLinks([chunk(url)], {
|
||||
content,
|
||||
highlights,
|
||||
sourceRanges: [{ start: marker.length, end }],
|
||||
})
|
||||
|
||||
expect(result[0].link?.url).toBe(url)
|
||||
}
|
||||
})
|
||||
|
||||
test("does not link bare URLs inside inline or block raw markup", () => {
|
||||
const content = "`https://inline.test` https://visible.test ```https://block.test```"
|
||||
const inlineStart = content.indexOf("https://inline.test")
|
||||
const blockStart = content.indexOf("https://block.test")
|
||||
const highlights: SimpleHighlight[] = [
|
||||
[inlineStart - 1, inlineStart + "https://inline.test".length + 1, "markup.raw"],
|
||||
[blockStart - 3, blockStart + "https://block.test".length + 3, "markup.raw.block"],
|
||||
]
|
||||
|
||||
const result = detectLinks([chunk(content)], { content, highlights })
|
||||
|
||||
expect(result.map((item) => [item.text, item.link?.url])).toEqual([
|
||||
["`https://inline.test` ", undefined],
|
||||
["https://visible.test", "https://visible.test"],
|
||||
[" ```https://block.test```", undefined],
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves styles and existing links when splitting URL chunks", () => {
|
||||
const content = "prefix https://new.test suffix"
|
||||
const fg = RGBA.fromInts(10, 20, 30, 255)
|
||||
const bg = RGBA.fromInts(40, 50, 60, 255)
|
||||
const styled = { ...chunk(content), fg, bg, attributes: 7 }
|
||||
const existing = { ...chunk("existing"), link: { url: "https://existing.test" } }
|
||||
|
||||
const result = detectLinks([styled, existing], {
|
||||
content: `${content}existing`,
|
||||
highlights: [],
|
||||
sourceRanges: [
|
||||
{ start: 0, end: content.length },
|
||||
{ start: content.length, end: content.length + existing.text.length },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.map((item) => item.text)).toEqual(["prefix ", "https://new.test", " suffix", "existing"])
|
||||
expect(result[1].link).toEqual({ url: "https://new.test" })
|
||||
expect(result[3].link).toEqual({ url: "https://existing.test" })
|
||||
for (const item of result.slice(0, 3)) {
|
||||
expect(item.fg).toBe(fg)
|
||||
expect(item.bg).toBe(bg)
|
||||
expect(item.attributes).toBe(7)
|
||||
}
|
||||
})
|
||||
|
||||
test("uses exact source ranges for conceal replacements and repeated visible text", () => {
|
||||
const content = "xx [xx](https://example.com) xx"
|
||||
const labelStart = content.indexOf("xx", 3)
|
||||
const urlStart = content.indexOf("https://")
|
||||
const highlights: SimpleHighlight[] = [
|
||||
[labelStart, labelStart + 2, "markup.link.label"],
|
||||
[labelStart + 2, labelStart + 3, "conceal", { conceal: "xx" }],
|
||||
[urlStart, urlStart + "https://example.com".length, "markup.link.url"],
|
||||
]
|
||||
const chunks = [chunk("xx "), chunk("xx"), chunk("xx"), chunk("("), chunk("https://example.com"), chunk(" xx")]
|
||||
const sourceRanges = [
|
||||
{ start: 0, end: 3 },
|
||||
{ start: labelStart, end: labelStart + 2 },
|
||||
{ start: labelStart + 2, end: labelStart + 3 },
|
||||
{ start: labelStart + 3, end: labelStart + 4 },
|
||||
{ start: urlStart, end: urlStart + "https://example.com".length },
|
||||
{ start: content.length - 3, end: content.length },
|
||||
]
|
||||
|
||||
const result = detectLinks(chunks, { content, highlights, sourceRanges })
|
||||
|
||||
expect(result[0].link).toBeUndefined()
|
||||
expect(result[1].link).toEqual({ url: "https://example.com" })
|
||||
expect(result[2].link).toBeUndefined()
|
||||
expect(result[4].link).toEqual({ url: "https://example.com" })
|
||||
expect(result[5].link).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not split replacement text using unrelated source URL offsets", () => {
|
||||
const content = "https://hidden.test https://visible.test"
|
||||
const firstEnd = content.indexOf(" ")
|
||||
const chunks = [chunk("replacement"), chunk(" "), chunk("https://visible.test")]
|
||||
const sourceRanges = [
|
||||
{ start: 0, end: firstEnd },
|
||||
{ start: firstEnd, end: firstEnd + 1 },
|
||||
{ start: firstEnd + 1, end: content.length },
|
||||
]
|
||||
|
||||
const result = detectLinks(chunks, { content, highlights: [], sourceRanges })
|
||||
|
||||
expect(result.map((item) => [item.text, item.link?.url])).toEqual([
|
||||
["replacement", undefined],
|
||||
[" ", undefined],
|
||||
["https://visible.test", "https://visible.test"],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -2,55 +2,166 @@ import type { TextChunk } from "../text-buffer.js"
|
||||
import type { SimpleHighlight } from "./tree-sitter/types.js"
|
||||
|
||||
const URL_SCOPES = ["markup.link.url", "string.special.url"]
|
||||
const HTTP_URL_PREFIX = /https?:\/\//i
|
||||
const BARE_URL = /https?:\/\/[^\s<>"`]+/gi
|
||||
|
||||
export function normalizeMarkdownLinkTarget(destination: string): string {
|
||||
return destination.replace(/\\([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g, "$1")
|
||||
}
|
||||
|
||||
export function detectLinks(
|
||||
chunks: TextChunk[],
|
||||
context: { content: string; highlights: SimpleHighlight[] },
|
||||
context: { content: string; highlights: SimpleHighlight[]; sourceRanges?: Array<{ start: number; end: number }> },
|
||||
): TextChunk[] {
|
||||
const content = context.content
|
||||
const highlights = context.highlights
|
||||
const hasBareUrl = HTTP_URL_PREFIX.test(content)
|
||||
if (!hasBareUrl && !highlights.some(([, , group]) => URL_SCOPES.includes(group))) return chunks
|
||||
|
||||
const ranges: Array<{ start: number; end: number; url: string }> = []
|
||||
const ranges: Array<{ start: number; end: number; url: string; label?: boolean }> = []
|
||||
const rawRanges: Array<{ start: number; end: number }> = []
|
||||
const labels = new Map<number, { start: number; end: number }>()
|
||||
const concealedDelimiters = new Map<number, number>()
|
||||
|
||||
for (let i = 0; i < highlights.length; i++) {
|
||||
const [start, end, group] = highlights[i]
|
||||
const [start, end, group, meta] = highlights[i]
|
||||
if (group === "conceal" && meta?.conceal === "" && /^[*_~]+$/.test(content.slice(start, end))) {
|
||||
concealedDelimiters.set(end, start)
|
||||
}
|
||||
if (group === "markup.raw" || group.startsWith("markup.raw.")) {
|
||||
rawRanges.push({ start, end })
|
||||
}
|
||||
if (group === "markup.link.label") {
|
||||
const existing = labels.get(end)
|
||||
if (!existing || start < existing.start) labels.set(end, { start, end })
|
||||
}
|
||||
if (!URL_SCOPES.includes(group)) continue
|
||||
|
||||
const url = content.slice(start, end)
|
||||
if (!url) continue
|
||||
ranges.push({ start, end, url })
|
||||
}
|
||||
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
const [labelStart, labelEnd, prev] = highlights[j]
|
||||
if (prev === "markup.link.label") {
|
||||
ranges.push({ start: labelStart, end: labelEnd, url })
|
||||
break
|
||||
const highlightedCount = ranges.length
|
||||
for (let index = 0; index < highlightedCount; index++) {
|
||||
const range = ranges[index]
|
||||
let labelEnd = range.start
|
||||
if (content[labelEnd - 1] === "<") labelEnd--
|
||||
while (labelEnd > 0 && /\s/.test(content[labelEnd - 1])) labelEnd--
|
||||
if (content[labelEnd - 1] !== "(" || content[labelEnd - 2] !== "]") continue
|
||||
|
||||
const label = labels.get(labelEnd - 2)
|
||||
if (!label) continue
|
||||
range.url = normalizeMarkdownLinkTarget(range.url)
|
||||
ranges.push({ ...label, url: range.url, label: true })
|
||||
}
|
||||
|
||||
if (hasBareUrl) {
|
||||
rawRanges.sort((a, b) => a.start - b.start || b.end - a.end)
|
||||
ranges.sort((a, b) => a.start - b.start || b.end - a.end)
|
||||
let rawIndex = 0
|
||||
let highlightedIndex = 0
|
||||
|
||||
for (const match of content.matchAll(BARE_URL)) {
|
||||
const start = match.index
|
||||
let url = match[0]
|
||||
let openParens = 0
|
||||
let closeParens = 0
|
||||
for (const character of url) {
|
||||
if (character === "(") openParens++
|
||||
if (character === ")") closeParens++
|
||||
}
|
||||
if (!prev.startsWith("markup.link")) break
|
||||
|
||||
while (url.length > 0) {
|
||||
const delimiterStart = concealedDelimiters.get(start + url.length)
|
||||
if (delimiterStart !== undefined && delimiterStart >= start) {
|
||||
url = url.slice(0, delimiterStart - start)
|
||||
continue
|
||||
}
|
||||
const last = url[url.length - 1]
|
||||
if (last === ")" && closeParens > openParens) {
|
||||
closeParens--
|
||||
} else if (!".,!?;:]}'".includes(last)) {
|
||||
break
|
||||
}
|
||||
url = url.slice(0, -1)
|
||||
}
|
||||
|
||||
const end = start + url.length
|
||||
if (!url) continue
|
||||
|
||||
while (rawIndex < rawRanges.length && rawRanges[rawIndex].end <= start) rawIndex++
|
||||
if (rawIndex < rawRanges.length && rawRanges[rawIndex].start < end) continue
|
||||
|
||||
while (highlightedIndex < ranges.length && ranges[highlightedIndex].end <= start) highlightedIndex++
|
||||
if (highlightedIndex < ranges.length && ranges[highlightedIndex].start < end) continue
|
||||
|
||||
ranges.push({ start, end, url })
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.length === 0) return chunks
|
||||
ranges.sort((a, b) => a.start - b.start || b.end - a.end || Number(b.label ?? false) - Number(a.label ?? false))
|
||||
|
||||
// Use content.indexOf to find each chunk's position in the original content.
|
||||
// This handles concealed text correctly because concealed chunks are either
|
||||
// empty (length 0, skipped) or single-char replacements (length 1, skipped).
|
||||
// Non-concealed chunks with length > 1 are exact substrings of content in order.
|
||||
const linkedChunks: TextChunk[] = []
|
||||
let contentPos = 0
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.text.length <= 1) continue
|
||||
|
||||
const idx = content.indexOf(chunk.text, contentPos)
|
||||
if (idx < 0) continue
|
||||
|
||||
for (const range of ranges) {
|
||||
if (idx < range.end && idx + chunk.text.length > range.start) {
|
||||
chunk.link = { url: range.url }
|
||||
break
|
||||
}
|
||||
let rangeIndex = 0
|
||||
for (let index = 0; index < chunks.length; index++) {
|
||||
const chunk = chunks[index]
|
||||
if (!chunk.text || chunk.link) {
|
||||
linkedChunks.push(chunk)
|
||||
continue
|
||||
}
|
||||
|
||||
contentPos = idx + chunk.text.length
|
||||
const providedRange = context.sourceRanges?.[index]
|
||||
const start = providedRange?.start ?? content.indexOf(chunk.text, contentPos)
|
||||
if (start < 0) {
|
||||
linkedChunks.push(chunk)
|
||||
continue
|
||||
}
|
||||
const end = providedRange?.end ?? start + chunk.text.length
|
||||
contentPos = end
|
||||
while (rangeIndex < ranges.length && ranges[rangeIndex].end <= start) rangeIndex++
|
||||
|
||||
if (content.slice(start, end) !== chunk.text) {
|
||||
const range = ranges[rangeIndex]
|
||||
if (range?.label && range.start < end) chunk.link = { url: range.url }
|
||||
linkedChunks.push(chunk)
|
||||
continue
|
||||
}
|
||||
|
||||
let position = start
|
||||
for (; rangeIndex < ranges.length; rangeIndex++) {
|
||||
const range = ranges[rangeIndex]
|
||||
if (range.end <= position) continue
|
||||
if (range.start >= end) break
|
||||
|
||||
const linkStart = Math.max(position, range.start)
|
||||
const linkEnd = Math.min(end, range.end)
|
||||
if (linkStart > position) {
|
||||
linkedChunks.push({ ...chunk, text: chunk.text.slice(position - start, linkStart - start) })
|
||||
}
|
||||
|
||||
if (linkStart === start && linkEnd === end) {
|
||||
chunk.link = { url: range.url }
|
||||
linkedChunks.push(chunk)
|
||||
} else {
|
||||
linkedChunks.push({
|
||||
...chunk,
|
||||
text: chunk.text.slice(linkStart - start, linkEnd - start),
|
||||
link: { url: range.url },
|
||||
})
|
||||
}
|
||||
position = linkEnd
|
||||
if (position === end) break
|
||||
}
|
||||
|
||||
if (position === start) {
|
||||
linkedChunks.push(chunk)
|
||||
} else if (position < end) {
|
||||
linkedChunks.push({ ...chunk, text: chunk.text.slice(position - start) })
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
return linkedChunks
|
||||
}
|
||||
@@ -124,6 +124,31 @@ function add(a, b) {
|
||||
expect(reconstructed).toBe(originalCode)
|
||||
})
|
||||
|
||||
test("records exactly one original source range per emitted chunk, including conceal replacements", () => {
|
||||
const content = "xx [xx](https://example.com) xx"
|
||||
const labelStart = content.indexOf("xx", 3)
|
||||
const urlStart = content.indexOf("https://")
|
||||
const highlights: SimpleHighlight[] = [
|
||||
[3, 4, "conceal", { conceal: "" }],
|
||||
[labelStart, labelStart + 2, "markup.link.label"],
|
||||
[labelStart + 2, labelStart + 3, "conceal", { conceal: "xx" }],
|
||||
[urlStart, urlStart + "https://example.com".length, "markup.link.url"],
|
||||
]
|
||||
const ranges: Array<{ start: number; end: number }> = []
|
||||
|
||||
const chunks = treeSitterToTextChunks(content, highlights, syntaxStyle, { ranges })
|
||||
|
||||
expect(chunks.map((item) => item.text)).toEqual(["xx ", "xx", "xx", "(", "https://example.com", ") xx"])
|
||||
expect(ranges).toEqual([
|
||||
{ start: 0, end: 3 },
|
||||
{ start: 4, end: 6 },
|
||||
{ start: 6, end: 7 },
|
||||
{ start: 7, end: 8 },
|
||||
{ start: 8, end: 27 },
|
||||
{ start: 27, end: 31 },
|
||||
])
|
||||
})
|
||||
|
||||
test("should apply different styles to different syntax elements", async () => {
|
||||
const jsCode = "const number = 42; // comment"
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ registerEnvVar({ name: "OTUI_TS_STYLE_WARN", default: false, description: "Enabl
|
||||
interface TextChunkOptions {
|
||||
enabled?: boolean
|
||||
baseHighlight?: string
|
||||
ranges?: Array<{ start: number; end: number }>
|
||||
}
|
||||
|
||||
interface Boundary {
|
||||
@@ -43,6 +44,7 @@ export function treeSitterToTextChunks(
|
||||
options?: TextChunkOptions,
|
||||
): TextChunk[] {
|
||||
const chunks: TextChunk[] = []
|
||||
const ranges = options?.ranges
|
||||
const defaultStyle = syntaxStyle.getStyle("default")
|
||||
const concealEnabled = options?.enabled ?? true
|
||||
const baseStyle = options?.baseHighlight ? syntaxStyle.getStyle(options.baseHighlight) : undefined
|
||||
@@ -118,6 +120,7 @@ export function treeSitterToTextChunks(
|
||||
})
|
||||
: 0,
|
||||
})
|
||||
ranges?.push({ start: currentOffset, end: boundary.offset })
|
||||
}
|
||||
} else {
|
||||
const insideInjectionContainer = injectionContainerRanges.some(
|
||||
@@ -198,6 +201,7 @@ export function treeSitterToTextChunks(
|
||||
})
|
||||
: 0,
|
||||
})
|
||||
ranges?.push({ start: currentOffset, end: boundary.offset })
|
||||
}
|
||||
} else if (currentOffset < boundary.offset) {
|
||||
const text = content.slice(currentOffset, boundary.offset)
|
||||
@@ -216,6 +220,7 @@ export function treeSitterToTextChunks(
|
||||
})
|
||||
: 0,
|
||||
})
|
||||
ranges?.push({ start: currentOffset, end: boundary.offset })
|
||||
}
|
||||
|
||||
if (boundary.type === "start") {
|
||||
@@ -274,6 +279,7 @@ export function treeSitterToTextChunks(
|
||||
})
|
||||
: 0,
|
||||
})
|
||||
ranges?.push({ start: currentOffset, end: content.length })
|
||||
}
|
||||
|
||||
return chunks
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ManualClock } from "../testing/manual-clock.js"
|
||||
import type { SimpleHighlight } from "../lib/tree-sitter/types.js"
|
||||
import { BoxRenderable } from "./Box.js"
|
||||
import { TextAttributes, type CapturedFrame } from "../types.js"
|
||||
import { StyledText } from "../lib/styled-text.js"
|
||||
|
||||
let currentRenderer: TestRenderer
|
||||
let renderOnce: () => Promise<void>
|
||||
@@ -517,6 +518,34 @@ test("CodeRenderable - empty content does not trigger highlighting", async () =>
|
||||
|
||||
expect(mockClient.isHighlighting()).toBe(false)
|
||||
expect(codeRenderable.content).toBe("")
|
||||
|
||||
codeRenderable.initialStyledText = new StyledText([{ __isChunk: true, text: "first\nsecond" }])
|
||||
codeRenderable.streaming = true
|
||||
codeRenderable.content = "first\nsecond"
|
||||
await renderOnce()
|
||||
|
||||
expect(codeRenderable.isHighlighting).toBe(true)
|
||||
expect(codeRenderable.lineCount).toBe(2)
|
||||
expect(captureFrame()).toContain("first")
|
||||
|
||||
const changes: Array<{ source: string; visible: string }> = []
|
||||
codeRenderable.on("line-info-change", () => {
|
||||
changes.push({ source: codeRenderable.content, visible: codeRenderable.plainText })
|
||||
})
|
||||
|
||||
codeRenderable.content = ""
|
||||
|
||||
expect(changes).toEqual([{ source: "", visible: "" }])
|
||||
expect(codeRenderable.plainText).toBe("")
|
||||
expect(codeRenderable.lineCount).toBe(1)
|
||||
expect(codeRenderable.lineInfo.lineSources).toEqual([0])
|
||||
expect(codeRenderable.isDirty).toBe(true)
|
||||
|
||||
await renderOnce()
|
||||
|
||||
expect(codeRenderable.isHighlighting).toBe(false)
|
||||
expect(mockClient.isHighlighting()).toBe(true)
|
||||
expect(captureFrame()).not.toContain("first")
|
||||
})
|
||||
|
||||
test("CodeRenderable - text renders immediately before highlighting completes", async () => {
|
||||
@@ -1304,6 +1333,45 @@ test("CodeRenderable - streaming mode respects drawUnstyledText only for initial
|
||||
expect(codeRenderable.content).toBe("const updated = 'world';")
|
||||
})
|
||||
|
||||
test("CodeRenderable - updating initial styled text refreshes an unresolved streaming preview", async () => {
|
||||
const mockClient = new MockTreeSitterClient()
|
||||
const codeRenderable = new CodeRenderable(currentRenderer, {
|
||||
id: "test-code-streaming-styled-preview",
|
||||
content: "[Label](https://example.com)",
|
||||
filetype: "markdown",
|
||||
syntaxStyle: SyntaxStyle.create(),
|
||||
treeSitterClient: mockClient,
|
||||
streaming: true,
|
||||
drawUnstyledText: true,
|
||||
initialStyledText: new StyledText([
|
||||
{ __isChunk: true, text: "Label (https://example.com)", link: { url: "https://example.com/old" } },
|
||||
]),
|
||||
})
|
||||
|
||||
currentRenderer.root.add(codeRenderable)
|
||||
await renderOnce()
|
||||
|
||||
expect(mockClient.isHighlighting()).toBe(true)
|
||||
expect(codeRenderable.plainText).toBe("Label (https://example.com)")
|
||||
|
||||
const preview = new StyledText([{ __isChunk: true, text: "Label", link: { url: "https://example.com/new" } }])
|
||||
codeRenderable.initialStyledText = preview
|
||||
expect(codeRenderable.plainText).toBe("Label")
|
||||
await renderOnce()
|
||||
|
||||
expect(mockClient.isHighlighting()).toBe(true)
|
||||
expect(currentRenderer.getLinkAt(codeRenderable.x, codeRenderable.y)).toBe("https://example.com/new")
|
||||
|
||||
preview.chunks[0]!.text = "Changed"
|
||||
preview.chunks[0]!.link = { url: "https://example.com/changed" }
|
||||
codeRenderable.content = "[Changed](https://example.com/changed)"
|
||||
expect(codeRenderable.plainText).toBe("Changed")
|
||||
await renderOnce()
|
||||
|
||||
expect(mockClient.isHighlighting()).toBe(true)
|
||||
expect(currentRenderer.getLinkAt(codeRenderable.x, codeRenderable.y)).toBe("https://example.com/changed")
|
||||
})
|
||||
|
||||
test("CodeRenderable - streaming mode with drawUnstyledText=false waits for new highlights", async () => {
|
||||
const syntaxStyle = SyntaxStyle.fromStyles({
|
||||
default: { fg: RGBA.fromValues(1, 1, 1, 1) },
|
||||
@@ -1380,6 +1448,45 @@ test("CodeRenderable - onChunks callback can transform chunks when highlights ar
|
||||
expect(codeRenderable.plainText).toBe("HELLO")
|
||||
})
|
||||
|
||||
test("CodeRenderable - onChunks receives exact source ranges for concealed replacement chunks", async () => {
|
||||
const content = "[x](https://example.com)"
|
||||
const highlights: SimpleHighlight[] = [
|
||||
[0, 1, "conceal", { conceal: "" }],
|
||||
[1, 2, "markup.link.label"],
|
||||
[2, 3, "conceal", { conceal: "replacement" }],
|
||||
[4, 23, "markup.link.url"],
|
||||
]
|
||||
const mockClient = new MockTreeSitterClient()
|
||||
mockClient.setMockResult({ highlights })
|
||||
let observedRanges: Array<{ start: number; end: number }> | undefined
|
||||
let observedChunks: string[] | undefined
|
||||
|
||||
const codeRenderable = new CodeRenderable(currentRenderer, {
|
||||
id: "test-code-source-ranges",
|
||||
content,
|
||||
filetype: "markdown",
|
||||
syntaxStyle: SyntaxStyle.create(),
|
||||
treeSitterClient: mockClient,
|
||||
onChunks: (chunks, context) => {
|
||||
observedChunks = chunks.map((item) => item.text)
|
||||
observedRanges = context.sourceRanges
|
||||
return chunks
|
||||
},
|
||||
})
|
||||
|
||||
currentRenderer.root.add(codeRenderable)
|
||||
await resolveMockHighlights(codeRenderable, mockClient)
|
||||
|
||||
expect(observedChunks).toEqual(["x", "replacement", "(", "https://example.com", ")"])
|
||||
expect(observedRanges).toEqual([
|
||||
{ start: 1, end: 2 },
|
||||
{ start: 2, end: 3 },
|
||||
{ start: 3, end: 4 },
|
||||
{ start: 4, end: 23 },
|
||||
{ start: 23, end: 24 },
|
||||
])
|
||||
})
|
||||
|
||||
test("CodeRenderable - baseHighlight applies a style when parser highlights are empty", async () => {
|
||||
const quoteColor = RGBA.fromValues(0.25, 0.5, 0.75, 1)
|
||||
const syntaxStyle = SyntaxStyle.fromStyles({
|
||||
|
||||
@@ -21,6 +21,7 @@ export type OnHighlightCallback = (
|
||||
|
||||
export interface ChunkRenderContext extends HighlightContext {
|
||||
highlights: SimpleHighlight[]
|
||||
sourceRanges?: Array<{ start: number; end: number }>
|
||||
}
|
||||
|
||||
export type OnChunksCallback = (
|
||||
@@ -124,7 +125,7 @@ export class CodeRenderable extends TextBufferRenderable {
|
||||
return
|
||||
}
|
||||
|
||||
if (this._initialStyledText && this._drawUnstyledText) {
|
||||
if (value && this._initialStyledText && this._drawUnstyledText) {
|
||||
this.textBuffer.setStyledText(this._initialStyledText)
|
||||
} else {
|
||||
this.textBuffer.setText(value)
|
||||
@@ -134,6 +135,15 @@ export class CodeRenderable extends TextBufferRenderable {
|
||||
}
|
||||
}
|
||||
|
||||
public updateStreamingPreview(content: string, initialStyledText: StyledText): void {
|
||||
this._content = content
|
||||
this._initialStyledText = initialStyledText
|
||||
this.invalidateHighlights()
|
||||
this.textBuffer.setStyledText(initialStyledText)
|
||||
this.setRenderedLineSources(undefined)
|
||||
this.updateTextInfo()
|
||||
}
|
||||
|
||||
public override get lineInfo(): LineInfo {
|
||||
if (!this._renderedLineSources) return super.lineInfo
|
||||
if (this._mappedLineInfo) return this._mappedLineInfo
|
||||
@@ -229,6 +239,11 @@ export class CodeRenderable extends TextBufferRenderable {
|
||||
|
||||
set initialStyledText(value: StyledText | undefined) {
|
||||
if (this._initialStyledText !== value) {
|
||||
if (value && this._streaming && this._drawUnstyledText && this._isHighlighting) {
|
||||
this.updateStreamingPreview(this._content, value)
|
||||
return
|
||||
}
|
||||
|
||||
this._initialStyledText = value
|
||||
this.invalidateHighlights()
|
||||
}
|
||||
@@ -382,16 +397,19 @@ export class CodeRenderable extends TextBufferRenderable {
|
||||
}
|
||||
|
||||
if (highlights.length > 0 || this._onChunks || this._baseHighlight) {
|
||||
const sourceRanges: Array<{ start: number; end: number }> | undefined = this._onChunks ? [] : undefined
|
||||
const context: ChunkRenderContext = {
|
||||
content,
|
||||
filetype,
|
||||
syntaxStyle: this._syntaxStyle,
|
||||
highlights,
|
||||
sourceRanges,
|
||||
}
|
||||
|
||||
let chunks = treeSitterToTextChunks(content, highlights, this._syntaxStyle, {
|
||||
enabled: this._conceal,
|
||||
baseHighlight: this._baseHighlight,
|
||||
ranges: sourceRanges,
|
||||
})
|
||||
// onChunks may rewrite text arbitrarily, so the conceal-only source map would be invalid.
|
||||
const renderedLineSources = this._onChunks ? undefined : this.getConcealLinesSourceMap(content, highlights)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Renderable, type RenderableOptions } from "../Renderable.js"
|
||||
import { type RenderContext } from "../types.js"
|
||||
import { type RenderContext, type TerminalCapabilities } from "../types.js"
|
||||
import { SyntaxStyle, type StyleDefinition } from "../syntax-style.js"
|
||||
import type { TextChunk } from "../text-buffer.js"
|
||||
import { createTextAttributes } from "../utils.js"
|
||||
import type { BorderStyle } from "../lib/border.js"
|
||||
import { RGBA, parseColor, type ColorInput } from "../lib/RGBA.js"
|
||||
import { Lexer, type MarkedToken, type Token, type Tokens } from "marked"
|
||||
import { CodeRenderable, type OnChunksCallback } from "./Code.js"
|
||||
import { CodeRenderable, type OnChunksCallback, type OnHighlightCallback } from "./Code.js"
|
||||
import { BoxRenderable } from "./Box.js"
|
||||
import { StyledText } from "../lib/styled-text.js"
|
||||
import { TextRenderable } from "./Text.js"
|
||||
@@ -21,7 +21,9 @@ import type { TreeSitterClient } from "../lib/tree-sitter/index.js"
|
||||
import { infoStringToFiletype } from "../lib/tree-sitter/resolve-ft.js"
|
||||
import { parseMarkdownIncremental, type ParseState } from "./markdown-parser.js"
|
||||
import type { OptimizedBuffer } from "../buffer.js"
|
||||
import { detectLinks } from "../lib/detect-links.js"
|
||||
import { detectLinks, normalizeMarkdownLinkTarget } from "../lib/detect-links.js"
|
||||
import type { SimpleHighlight } from "../lib/tree-sitter/types.js"
|
||||
import { MAX_LINK_URL_BYTES } from "../zig.js"
|
||||
|
||||
export type MarkdownTableStyle = "grid" | "columns"
|
||||
|
||||
@@ -226,6 +228,12 @@ interface ResolvedTableRenderableOptions {
|
||||
|
||||
const TRAILING_MARKDOWN_BLOCK_BREAKS_RE = /(?:\r?\n){2,}$/
|
||||
const TRAILING_MARKDOWN_BLOCK_NEWLINES_RE = /(?:\r?\n)+$/
|
||||
const markdownLinkEncoder = new TextEncoder()
|
||||
|
||||
function isSupportedLinkTarget(url: string): boolean {
|
||||
if (url.length > MAX_LINK_URL_BYTES) return false
|
||||
return markdownLinkEncoder.encode(url).byteLength <= MAX_LINK_URL_BYTES
|
||||
}
|
||||
|
||||
function colorsEqual(left?: RGBA, right?: RGBA): boolean {
|
||||
if (!left || !right) return left === right
|
||||
@@ -259,6 +267,10 @@ interface ListItemRenderInput {
|
||||
}
|
||||
|
||||
export class MarkdownRenderable extends Renderable {
|
||||
private static _capabilitySubscriptions = new WeakMap<
|
||||
RenderContext,
|
||||
{ renderables: Set<MarkdownRenderable>; listener: (capabilities: TerminalCapabilities) => void }
|
||||
>()
|
||||
private _content: string = ""
|
||||
private _syntaxStyle: SyntaxStyle
|
||||
private _fg?: RGBA
|
||||
@@ -275,11 +287,67 @@ export class MarkdownRenderable extends Renderable {
|
||||
_blockStates: BlockState[] = []
|
||||
_stableBlockCount = 0
|
||||
private _styleDirty: boolean = false
|
||||
private _linkifyMarkdownChunks: OnChunksCallback = (chunks, context) =>
|
||||
detectLinks(chunks, {
|
||||
content: context.content,
|
||||
highlights: context.highlights,
|
||||
})
|
||||
private _highlightMarkdownLinks: OnHighlightCallback = (highlights, context) =>
|
||||
this.addMarkdownLinkHighlights(highlights, context.content)
|
||||
private _linkifyMarkdownChunks: OnChunksCallback = detectLinks
|
||||
private _ownedStructuredRenderables = new WeakSet<Renderable>()
|
||||
|
||||
private handleCapabilities(): void {
|
||||
if (!this._renderNode && !this._content.includes("]")) return
|
||||
|
||||
const previousHighlight = this._highlightMarkdownLinks
|
||||
this._highlightMarkdownLinks = (highlights, context) => this.addMarkdownLinkHighlights(highlights, context.content)
|
||||
|
||||
for (const state of this._blockStates) {
|
||||
const renderables: Array<[Renderable, MarkedToken?]> = [[state.renderable, state.token]]
|
||||
while (renderables.length > 0) {
|
||||
const [renderable, token] = renderables.pop()!
|
||||
if (
|
||||
renderable instanceof TextTableRenderable &&
|
||||
token?.type === "table" &&
|
||||
this._ownedStructuredRenderables.has(renderable)
|
||||
) {
|
||||
const { cache } = this.buildTableContentCache(
|
||||
token as Tokens.Table,
|
||||
renderable === state.renderable ? state.tableContentCache : undefined,
|
||||
true,
|
||||
)
|
||||
if (cache) {
|
||||
renderable.content = cache.content
|
||||
if (renderable === state.renderable) state.tableContentCache = cache
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
renderable instanceof CodeRenderable &&
|
||||
renderable.filetype === "markdown" &&
|
||||
renderable.onChunks === this._linkifyMarkdownChunks &&
|
||||
renderable.onHighlight === previousHighlight
|
||||
) {
|
||||
if (this._streaming && token) renderable.initialStyledText = this.createInitialStyledText(token)
|
||||
renderable.onHighlight = this._highlightMarkdownLinks
|
||||
}
|
||||
|
||||
if (token?.type === "list" && this._ownedStructuredRenderables.has(renderable)) {
|
||||
const items = (token as Tokens.List).items
|
||||
const rows = renderable.getChildren()
|
||||
for (let index = 0; index < items.length; index++) {
|
||||
const children = rows[index]?.getChildren()[1]?.getChildren() ?? []
|
||||
const tokens = this.getRenderableListItemTokens(items[index]!)
|
||||
for (let childIndex = 0; childIndex < children.length; childIndex++) {
|
||||
renderables.push([children[childIndex]!, tokens[childIndex]])
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for (const child of renderable.getChildren()) renderables.push([child, token])
|
||||
}
|
||||
}
|
||||
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
protected _contentDefaultOptions = {
|
||||
content: "",
|
||||
@@ -309,6 +377,23 @@ export class MarkdownRenderable extends Renderable {
|
||||
this._internalBlockMode = options.internalBlockMode ?? this._contentDefaultOptions.internalBlockMode
|
||||
|
||||
this.updateBlocks()
|
||||
|
||||
let subscription = MarkdownRenderable._capabilitySubscriptions.get(ctx)
|
||||
if (!subscription) {
|
||||
const renderables = new Set<MarkdownRenderable>()
|
||||
let hyperlinksSupported = ctx.capabilities?.hyperlinks === true
|
||||
subscription = {
|
||||
renderables,
|
||||
listener: (capabilities) => {
|
||||
if (hyperlinksSupported === (capabilities.hyperlinks === true)) return
|
||||
hyperlinksSupported = capabilities.hyperlinks === true
|
||||
for (const renderable of renderables) renderable.handleCapabilities()
|
||||
},
|
||||
}
|
||||
ctx.on("capabilities", subscription.listener)
|
||||
MarkdownRenderable._capabilitySubscriptions.set(ctx, subscription)
|
||||
}
|
||||
subscription.renderables.add(this)
|
||||
}
|
||||
|
||||
get content(): string {
|
||||
@@ -461,8 +546,8 @@ export class MarkdownRenderable extends Renderable {
|
||||
}
|
||||
}
|
||||
|
||||
private createDefaultChunk(text: string): TextChunk {
|
||||
return this.createChunk(text, "default")
|
||||
private createDefaultChunk(text: string, link?: { url: string }): TextChunk {
|
||||
return this.createChunk(text, "default", link)
|
||||
}
|
||||
|
||||
private createInitialStyledText(token: MarkedToken): StyledText | undefined {
|
||||
@@ -480,20 +565,20 @@ export class MarkdownRenderable extends Renderable {
|
||||
return chunks.length > 0 ? new StyledText(chunks) : undefined
|
||||
}
|
||||
|
||||
private renderInlineContent(tokens: Token[], chunks: TextChunk[]): void {
|
||||
private renderInlineContent(tokens: Token[], chunks: TextChunk[], link?: { url: string }): void {
|
||||
for (const token of tokens) {
|
||||
this.renderInlineToken(token as MarkedToken, chunks)
|
||||
this.renderInlineToken(token as MarkedToken, chunks, link)
|
||||
}
|
||||
}
|
||||
|
||||
private renderInlineToken(token: MarkedToken, chunks: TextChunk[]): void {
|
||||
private renderInlineToken(token: MarkedToken, chunks: TextChunk[], link?: { url: string }): void {
|
||||
switch (token.type) {
|
||||
case "text":
|
||||
chunks.push(this.createDefaultChunk(token.text))
|
||||
chunks.push(this.createDefaultChunk(token.text, link))
|
||||
break
|
||||
|
||||
case "escape":
|
||||
chunks.push(this.createDefaultChunk(token.text))
|
||||
chunks.push(this.createDefaultChunk(token.text, link))
|
||||
break
|
||||
|
||||
case "codespan":
|
||||
@@ -508,37 +593,37 @@ export class MarkdownRenderable extends Renderable {
|
||||
|
||||
case "strong":
|
||||
if (!this._conceal) {
|
||||
chunks.push(this.createChunk("**", "markup.strong"))
|
||||
chunks.push(this.createChunk("**", "markup.strong", link))
|
||||
}
|
||||
for (const child of token.tokens) {
|
||||
this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.strong")
|
||||
this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.strong", link)
|
||||
}
|
||||
if (!this._conceal) {
|
||||
chunks.push(this.createChunk("**", "markup.strong"))
|
||||
chunks.push(this.createChunk("**", "markup.strong", link))
|
||||
}
|
||||
break
|
||||
|
||||
case "em":
|
||||
if (!this._conceal) {
|
||||
chunks.push(this.createChunk("*", "markup.italic"))
|
||||
chunks.push(this.createChunk("*", "markup.italic", link))
|
||||
}
|
||||
for (const child of token.tokens) {
|
||||
this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.italic")
|
||||
this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.italic", link)
|
||||
}
|
||||
if (!this._conceal) {
|
||||
chunks.push(this.createChunk("*", "markup.italic"))
|
||||
chunks.push(this.createChunk("*", "markup.italic", link))
|
||||
}
|
||||
break
|
||||
|
||||
case "del":
|
||||
if (!this._conceal) {
|
||||
chunks.push(this.createChunk("~~", "markup.strikethrough"))
|
||||
chunks.push(this.createChunk("~~", "markup.strikethrough", link))
|
||||
}
|
||||
for (const child of token.tokens) {
|
||||
this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.strikethrough")
|
||||
this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.strikethrough", link)
|
||||
}
|
||||
if (!this._conceal) {
|
||||
chunks.push(this.createChunk("~~", "markup.strikethrough"))
|
||||
chunks.push(this.createChunk("~~", "markup.strikethrough", link))
|
||||
}
|
||||
break
|
||||
|
||||
@@ -548,9 +633,22 @@ export class MarkdownRenderable extends Renderable {
|
||||
for (const child of token.tokens) {
|
||||
this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.link.label", linkHref)
|
||||
}
|
||||
chunks.push(this.createChunk(" (", "markup.link", linkHref))
|
||||
chunks.push(this.createChunk(token.href, "markup.link.url", linkHref))
|
||||
chunks.push(this.createChunk(")", "markup.link", linkHref))
|
||||
if (
|
||||
(this.ctx.capabilities?.hyperlinks !== true || !isSupportedLinkTarget(token.href)) &&
|
||||
token.text !== token.href &&
|
||||
token.raw !== token.text &&
|
||||
token.raw !== `<${token.text}>`
|
||||
) {
|
||||
chunks.push(this.createChunk(" (", "markup.link", linkHref))
|
||||
chunks.push(this.createChunk(token.href, "markup.link.url", linkHref))
|
||||
chunks.push(this.createChunk(")", "markup.link", linkHref))
|
||||
}
|
||||
} else if (token.raw === token.text || token.raw === `<${token.text}>`) {
|
||||
if (token.raw.startsWith("<")) chunks.push(this.createChunk("<", "markup.link", linkHref))
|
||||
for (const child of token.tokens) {
|
||||
this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.link.label", linkHref)
|
||||
}
|
||||
if (token.raw.startsWith("<")) chunks.push(this.createChunk(">", "markup.link", linkHref))
|
||||
} else {
|
||||
chunks.push(this.createChunk("[", "markup.link", linkHref))
|
||||
for (const child of token.tokens) {
|
||||
@@ -564,7 +662,7 @@ export class MarkdownRenderable extends Renderable {
|
||||
}
|
||||
|
||||
case "image": {
|
||||
const imageHref = { url: token.href }
|
||||
const imageHref = link ?? { url: token.href }
|
||||
if (this._conceal) {
|
||||
chunks.push(this.createChunk(token.text || "image", "markup.link.label", imageHref))
|
||||
} else {
|
||||
@@ -578,14 +676,14 @@ export class MarkdownRenderable extends Renderable {
|
||||
}
|
||||
|
||||
case "br":
|
||||
chunks.push(this.createDefaultChunk("\n"))
|
||||
chunks.push(this.createDefaultChunk("\n", link))
|
||||
break
|
||||
|
||||
default:
|
||||
if ("tokens" in token && Array.isArray(token.tokens)) {
|
||||
this.renderInlineContent(token.tokens, chunks)
|
||||
this.renderInlineContent(token.tokens, chunks, link)
|
||||
} else if ("text" in token && typeof token.text === "string") {
|
||||
chunks.push(this.createDefaultChunk(token.text))
|
||||
chunks.push(this.createDefaultChunk(token.text, link))
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -617,11 +715,79 @@ export class MarkdownRenderable extends Renderable {
|
||||
break
|
||||
|
||||
default:
|
||||
this.renderInlineToken(token, chunks)
|
||||
this.renderInlineToken(token, chunks, link)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private addMarkdownLinkHighlights(highlights: SimpleHighlight[], content: string): SimpleHighlight[] {
|
||||
if (!highlights.some(([, , group]) => group === "markup.link.url" || group === "string.special.url")) {
|
||||
return highlights
|
||||
}
|
||||
|
||||
const modified = [...highlights]
|
||||
const labels = new Map<number, SimpleHighlight>()
|
||||
const bracketConceals = new Map<number, number>()
|
||||
const closings: SimpleHighlight[] = []
|
||||
|
||||
for (let index = 0; index < highlights.length; index++) {
|
||||
const highlight = highlights[index]!
|
||||
const [start, end, group, meta] = highlight
|
||||
if (group === "markup.link.label" && start < end && !labels.has(end)) labels.set(end, highlight)
|
||||
if (end === start + 1 && meta?.conceal === " " && !bracketConceals.has(start)) {
|
||||
bracketConceals.set(start, index)
|
||||
}
|
||||
if (group === "markup.link" && content.slice(start, end) === ")") closings.push(highlight)
|
||||
}
|
||||
|
||||
let closingIndex = 0
|
||||
for (let index = 0; index < highlights.length; index++) {
|
||||
const [start, end, group, meta] = highlights[index]!
|
||||
if (group !== "markup.link.url" && group !== "string.special.url") continue
|
||||
|
||||
let urlStart = start
|
||||
let urlEnd = end
|
||||
if (content[start] === "<" && content[end - 1] === ">") {
|
||||
urlStart++
|
||||
urlEnd--
|
||||
modified[index] = [urlStart, urlEnd, group, meta]
|
||||
if (this._conceal) {
|
||||
modified.push([start, start + 1, "conceal", { conceal: "", isInjection: true }])
|
||||
modified.push([end - 1, end, "conceal", { conceal: "", isInjection: true }])
|
||||
}
|
||||
}
|
||||
|
||||
let destinationStart = start
|
||||
while (destinationStart > 0 && /\s/.test(content[destinationStart - 1]!)) destinationStart--
|
||||
if (!this._conceal || content[destinationStart - 1] !== "(" || content[destinationStart - 2] !== "]") continue
|
||||
|
||||
const label = labels.get(destinationStart - 2)
|
||||
if (!label) continue
|
||||
const destination = content.slice(urlStart, urlEnd)
|
||||
const url = group === "markup.link.url" ? normalizeMarkdownLinkTarget(destination) : destination
|
||||
if (
|
||||
(this.ctx.capabilities?.hyperlinks !== true || !isSupportedLinkTarget(url)) &&
|
||||
content.slice(label[0], label[1]) !== url
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
while (closingIndex < closings.length && closings[closingIndex]![0] < end) closingIndex++
|
||||
const close = closings[closingIndex]
|
||||
if (!close) continue
|
||||
|
||||
const bracketConcealIndex = bracketConceals.get(destinationStart - 2)
|
||||
if (bracketConcealIndex !== undefined) {
|
||||
const [bracketStart, bracketEnd, bracketGroup, bracketMeta] = modified[bracketConcealIndex]!
|
||||
modified[bracketConcealIndex] = [bracketStart, bracketEnd, bracketGroup, { ...bracketMeta, conceal: "" }]
|
||||
}
|
||||
|
||||
modified.push([destinationStart - 1, close[1], "conceal", { conceal: "", isInjection: true }])
|
||||
}
|
||||
|
||||
return modified
|
||||
}
|
||||
|
||||
private applyMargins(renderable: Renderable, marginTop: number, marginBottom: number): void {
|
||||
renderable.marginTop = marginTop
|
||||
renderable.marginBottom = marginBottom
|
||||
@@ -647,6 +813,7 @@ export class MarkdownRenderable extends Renderable {
|
||||
streaming: true,
|
||||
initialStyledText,
|
||||
baseHighlight,
|
||||
onHighlight: this._highlightMarkdownLinks,
|
||||
onChunks,
|
||||
treeSitterClient: this._treeSitterClient,
|
||||
width: "100%",
|
||||
@@ -694,6 +861,7 @@ export class MarkdownRenderable extends Renderable {
|
||||
flexShrink: 0,
|
||||
marginBottom,
|
||||
})
|
||||
this._ownedStructuredRenderables.add(list)
|
||||
|
||||
for (const item of this.getListItemInputs(token, id)) {
|
||||
list.add(this.createListItemRenderable(item))
|
||||
@@ -975,7 +1143,11 @@ export class MarkdownRenderable extends Renderable {
|
||||
baseHighlight?: string,
|
||||
initialStyledText?: StyledText,
|
||||
): void {
|
||||
renderable.initialStyledText = initialStyledText
|
||||
if (initialStyledText && renderable.streaming && renderable.drawUnstyledText && renderable.isHighlighting) {
|
||||
renderable.updateStreamingPreview(content, initialStyledText)
|
||||
} else {
|
||||
renderable.initialStyledText = initialStyledText
|
||||
}
|
||||
renderable.filetype = "markdown"
|
||||
renderable.syntaxStyle = this._syntaxStyle
|
||||
renderable.fg = this._fg
|
||||
@@ -984,6 +1156,7 @@ export class MarkdownRenderable extends Renderable {
|
||||
renderable.drawUnstyledText = initialStyledText !== undefined
|
||||
renderable.streaming = true
|
||||
renderable.baseHighlight = baseHighlight
|
||||
renderable.onHighlight = this._highlightMarkdownLinks
|
||||
renderable.content = content
|
||||
renderable.marginBottom = marginBottom
|
||||
}
|
||||
@@ -1385,7 +1558,7 @@ export class MarkdownRenderable extends Renderable {
|
||||
marginBottom: number = 0,
|
||||
): TextTableRenderable {
|
||||
const options = this.resolveTableRenderableOptions()
|
||||
return new TextTableRenderable(this.ctx, {
|
||||
const table = new TextTableRenderable(this.ctx, {
|
||||
id,
|
||||
content,
|
||||
width: "100%",
|
||||
@@ -1404,6 +1577,8 @@ export class MarkdownRenderable extends Renderable {
|
||||
borderColor: options.borderColor,
|
||||
selectable: options.selectable,
|
||||
})
|
||||
this._ownedStructuredRenderables.add(table)
|
||||
return table
|
||||
}
|
||||
|
||||
private createTableBlock(
|
||||
@@ -2140,4 +2315,16 @@ export class MarkdownRenderable extends Renderable {
|
||||
}
|
||||
super.renderSelf(buffer, deltaTime)
|
||||
}
|
||||
|
||||
protected destroySelf(): void {
|
||||
const subscription = MarkdownRenderable._capabilitySubscriptions.get(this.ctx)
|
||||
if (subscription) {
|
||||
subscription.renderables.delete(this)
|
||||
if (subscription.renderables.size === 0) {
|
||||
this.ctx.off("capabilities", subscription.listener)
|
||||
MarkdownRenderable._capabilitySubscriptions.delete(this.ctx)
|
||||
}
|
||||
}
|
||||
super.destroySelf()
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,13 @@ import {
|
||||
type MockMouse,
|
||||
type TestRenderer,
|
||||
MockTreeSitterClient,
|
||||
setRendererCapabilities,
|
||||
TestRecorder,
|
||||
} from "../../testing.js"
|
||||
import { ManualClock } from "../../testing/manual-clock.js"
|
||||
import { TextAttributes, type CapturedFrame } from "../../types.js"
|
||||
import type { SimpleHighlight } from "../../lib/tree-sitter/types.js"
|
||||
import { stringWidth } from "../../platform/runtime.js"
|
||||
|
||||
let renderer: TestRenderer
|
||||
let mockMouse: MockMouse
|
||||
@@ -172,6 +175,13 @@ function findSpanContaining(frame: CapturedFrame, text: string) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function findRenderedText(text: string): { x: number; y: number } {
|
||||
const lines = captureFrame().split("\n")
|
||||
const y = lines.findIndex((line) => line.includes(text))
|
||||
expect(y).toBeGreaterThanOrEqual(0)
|
||||
return { x: stringWidth(lines[y]!.slice(0, lines[y]!.indexOf(text))), y }
|
||||
}
|
||||
|
||||
function getMarginBottom(renderable: { getLayoutNode(): { getMargin(edge: Edge): unknown } }): number {
|
||||
const margin = renderable.getLayoutNode().getMargin(Edge.Bottom) as unknown
|
||||
if (typeof margin === "number") return margin
|
||||
@@ -1217,6 +1227,7 @@ test("code block concealment is disabled by default", async () => {
|
||||
})
|
||||
|
||||
test("code block concealment can be enabled with concealCode", async () => {
|
||||
setRendererCapabilities(renderer, { hyperlinks: false })
|
||||
const mockTreeSitterClient = createMockTreeSitterClient()
|
||||
mockTreeSitterClient.setMockResult({
|
||||
highlights: [[0, 1, "conceal", { conceal: "" }]],
|
||||
@@ -1224,11 +1235,12 @@ test("code block concealment can be enabled with concealCode", async () => {
|
||||
|
||||
const md = createMarkdownRenderable({
|
||||
id: "markdown-code-conceal-enabled",
|
||||
content: "```markdown\n# Hidden heading\n```",
|
||||
content: "```markdown\n# Hidden heading\n```\n\nPlain **prose**\n\n| Name |\n|---|\n| Value |",
|
||||
syntaxStyle,
|
||||
conceal: true,
|
||||
concealCode: true,
|
||||
treeSitterClient: mockTreeSitterClient,
|
||||
tableOptions: { widthMode: "content" },
|
||||
})
|
||||
|
||||
renderer.root.add(md)
|
||||
@@ -1236,13 +1248,29 @@ test("code block concealment can be enabled with concealCode", async () => {
|
||||
expect(mockTreeSitterClient.isHighlighting()).toBe(true)
|
||||
|
||||
const codeBlock = md._blockStates[0]?.renderable as CodeRenderable
|
||||
const prose = md._blockStates[1]!.renderable as CodeRenderable
|
||||
const table = md._blockStates[2]!.renderable as TextTableRenderable
|
||||
mockTreeSitterClient.resolveAllHighlightOnce()
|
||||
await waitForHighlight(codeBlock)
|
||||
await Promise.all([waitForHighlight(codeBlock), waitForHighlight(prose)])
|
||||
await renderer.idle()
|
||||
|
||||
const onHighlight = prose.onHighlight
|
||||
const tableContent = table.content
|
||||
const frame = captureFrame()
|
||||
expect(frame).not.toContain("# Hidden heading")
|
||||
expect(frame).toContain("Hidden heading")
|
||||
|
||||
for (const hyperlinks of [true, false]) {
|
||||
renderer.emit("capabilities", setRendererCapabilities(renderer, { hyperlinks }))
|
||||
await renderOnce()
|
||||
|
||||
expect(codeBlock.onHighlight).toBeUndefined()
|
||||
expect(prose.onHighlight).toBe(onHighlight)
|
||||
expect(md._blockStates[2]!.renderable).toBe(table)
|
||||
expect(table.content).toBe(tableContent)
|
||||
expect(mockTreeSitterClient.isHighlighting()).toBe(false)
|
||||
expect(captureFrame()).toBe(frame)
|
||||
}
|
||||
})
|
||||
|
||||
test("toggling concealCode updates existing code block renderables", async () => {
|
||||
@@ -1960,6 +1988,553 @@ test("inline formatting with conceal=false", async () => {
|
||||
|
||||
// Link tests
|
||||
|
||||
for (const hyperlinks of [false, true]) {
|
||||
for (const named of [true, false]) {
|
||||
test(`${named ? "named" : "bare"} Unicode links after multibyte text preserve hyperlink metadata with hyperlinks=${hyperlinks}`, async () => {
|
||||
setRendererCapabilities(renderer, { hyperlinks })
|
||||
resizeRenderer(120, 10)
|
||||
const prefix = "caf\u00e9 \u4e2d\u6587 \u{1f680} e\u0301 "
|
||||
const url = "https://example.com/caf\u00e9"
|
||||
const label = named ? "R\u00e9f\u{1f680}" : url
|
||||
const md = createMarkdownRenderable({
|
||||
id: "markdown-unicode-links",
|
||||
content: `${prefix}${named ? `[${label}](${url})` : url} after`,
|
||||
syntaxStyle,
|
||||
})
|
||||
|
||||
renderer.root.add(md)
|
||||
await renderMarkdownRenderable(md)
|
||||
|
||||
const visibleLink = named && !hyperlinks ? `${label} (${url})` : label
|
||||
expect(captureFrame()).toContain(`${prefix}${visibleLink} after`)
|
||||
const position = findRenderedText(label)
|
||||
expect(position.x).toBe(stringWidth(prefix))
|
||||
for (let offset = 0; offset < stringWidth(label); offset++) {
|
||||
expect(renderer.getLinkAt(position.x + offset, position.y)).toBe(url)
|
||||
}
|
||||
expect(renderer.getLinkAt(position.x - 1, position.y)).toBeNull()
|
||||
expect(renderer.getLinkAt(position.x + stringWidth(visibleLink), position.y)).toBeNull()
|
||||
})
|
||||
}
|
||||
|
||||
test(`named and bare prose and table links preserve hyperlink metadata with hyperlinks=${hyperlinks}`, async () => {
|
||||
setRendererCapabilities(renderer, { hyperlinks })
|
||||
resizeRenderer(260, 40)
|
||||
const md = createMarkdownRenderable({
|
||||
id: "markdown-link-capabilities",
|
||||
content:
|
||||
"Before [Prose](https://example.com/named-prose) after https://example.com/prose " +
|
||||
"and <https://example.com/auto-prose> **https://example.com/bold-prose** " +
|
||||
"https://example.com/it's-prose " +
|
||||
"[https://example.com/identical-prose](https://example.com/identical-prose)\n\n" +
|
||||
"| Named |\n|---|\n| [Table](https://example.com/named-table) |\n\n" +
|
||||
"| Link |\n|---|\n| https://example.com/table |\n| <https://example.com/auto-table> |\n" +
|
||||
"| https://example.com/it's-table |\n" +
|
||||
"| [https://example.com/identical-table](https://example.com/identical-table) |",
|
||||
syntaxStyle,
|
||||
tableOptions: { widthMode: "content" },
|
||||
})
|
||||
|
||||
renderer.root.add(md)
|
||||
await renderMarkdownRenderable(md)
|
||||
|
||||
const frame = captureFrame()
|
||||
expect(frame).toContain(hyperlinks ? "Before Prose after" : "Before Prose (https://example.com/named-prose) after")
|
||||
expect(frame).toContain(hyperlinks ? "│Table│" : "│Table (https://example.com/named-table)│")
|
||||
expect(frame).not.toContain("<https://")
|
||||
|
||||
for (const [text, url] of [
|
||||
["Prose", "https://example.com/named-prose"],
|
||||
["Table", "https://example.com/named-table"],
|
||||
] as const) {
|
||||
if (hyperlinks) expect(frame).not.toContain(url)
|
||||
const position = findRenderedText(text)
|
||||
for (let offset = 0; offset < text.length; offset++) {
|
||||
expect(renderer.getLinkAt(position.x + offset, position.y)).toBe(url)
|
||||
}
|
||||
}
|
||||
|
||||
for (const url of [
|
||||
"https://example.com/prose",
|
||||
"https://example.com/table",
|
||||
"https://example.com/auto-prose",
|
||||
"https://example.com/auto-table",
|
||||
"https://example.com/bold-prose",
|
||||
"https://example.com/it's-prose",
|
||||
"https://example.com/it's-table",
|
||||
"https://example.com/identical-prose",
|
||||
"https://example.com/identical-table",
|
||||
]) {
|
||||
expect(frame.split(url)).toHaveLength(2)
|
||||
const position = findRenderedText(url)
|
||||
expect(renderer.getLinkAt(position.x, position.y)).toBe(url)
|
||||
expect(renderer.getLinkAt(position.x + url.length - 1, position.y)).toBe(url)
|
||||
}
|
||||
})
|
||||
|
||||
test(`streaming Markdown links are clickable before highlighting with hyperlinks=${hyperlinks}`, async () => {
|
||||
setRendererCapabilities(renderer, { hyperlinks })
|
||||
resizeRenderer(160, 10)
|
||||
const client = createMockTreeSitterClient()
|
||||
const md = createMarkdownRenderable({
|
||||
id: "markdown-streaming-link-preview",
|
||||
content:
|
||||
"Read [**OpenTUI**](https://example.com/docs) and https://example.com/bare " +
|
||||
"with [](https://example.com/outer)",
|
||||
syntaxStyle,
|
||||
streaming: true,
|
||||
treeSitterClient: client,
|
||||
})
|
||||
|
||||
renderer.root.add(md)
|
||||
await renderOnce()
|
||||
|
||||
expect(client.isHighlighting()).toBe(true)
|
||||
const frame = captureFrame()
|
||||
expect(frame).toContain(hyperlinks ? "Read OpenTUI and" : "Read OpenTUI (https://example.com/docs) and")
|
||||
expect(frame.match(/https:\/\/example\.com\/bare/g)).toHaveLength(1)
|
||||
|
||||
const label = findRenderedText("OpenTUI")
|
||||
const bare = findRenderedText("https://example.com/bare")
|
||||
const image = findRenderedText("Logo")
|
||||
expect(renderer.getLinkAt(label.x, label.y)).toBe("https://example.com/docs")
|
||||
expect(renderer.getLinkAt(bare.x, bare.y)).toBe("https://example.com/bare")
|
||||
expect(renderer.getLinkAt(image.x, image.y)).toBe("https://example.com/outer")
|
||||
|
||||
renderer.emit("capabilities", setRendererCapabilities(renderer, { hyperlinks: !hyperlinks }))
|
||||
await renderOnce()
|
||||
|
||||
expect(client.isHighlighting()).toBe(true)
|
||||
expect(captureFrame()).toContain(hyperlinks ? "Read OpenTUI (https://example.com/docs) and" : "Read OpenTUI and")
|
||||
const refreshedLabel = findRenderedText("OpenTUI")
|
||||
const refreshedImage = findRenderedText("Logo")
|
||||
expect(renderer.getLinkAt(refreshedLabel.x, refreshedLabel.y)).toBe("https://example.com/docs")
|
||||
expect(renderer.getLinkAt(refreshedImage.x, refreshedImage.y)).toBe("https://example.com/outer")
|
||||
|
||||
const code = md._blockStates[0]!.renderable as CodeRenderable
|
||||
const changes: Array<{ source: string; visible: string }> = []
|
||||
code.on("line-info-change", () => changes.push({ source: code.content, visible: code.plainText }))
|
||||
|
||||
md.content = "[Updated](custom://target)"
|
||||
|
||||
expect(changes).toEqual([
|
||||
{ source: "[Updated](custom://target)", visible: hyperlinks ? "Updated (custom://target)" : "Updated" },
|
||||
])
|
||||
await renderOnce()
|
||||
|
||||
const updated = findRenderedText("Updated")
|
||||
expect(renderer.getLinkAt(updated.x, updated.y)).toBe("custom://target")
|
||||
})
|
||||
}
|
||||
|
||||
test("one-character and formatted prose and table labels preserve hyperlinks", async () => {
|
||||
setRendererCapabilities(renderer, { hyperlinks: true })
|
||||
const md = createMarkdownRenderable({
|
||||
id: "markdown-formatted-links",
|
||||
content:
|
||||
"[x](https://example.com/one) and [**bold *nested*** *em* `code`](https://example.com/prose)\n\n" +
|
||||
"| Link |\n|---|\n| [y](https://example.com/two) |\n" +
|
||||
"| [**strong *inner*** *italic* `cell`](https://example.com/table) |\n" +
|
||||
"| [](https://example.com/outer) |",
|
||||
syntaxStyle,
|
||||
tableOptions: { widthMode: "content" },
|
||||
})
|
||||
|
||||
renderer.root.add(md)
|
||||
await renderMarkdownRenderable(md)
|
||||
|
||||
for (const [text, url] of [
|
||||
["x", "https://example.com/one"],
|
||||
["bold", "https://example.com/prose"],
|
||||
["nested", "https://example.com/prose"],
|
||||
["em", "https://example.com/prose"],
|
||||
["code", "https://example.com/prose"],
|
||||
["y", "https://example.com/two"],
|
||||
["strong", "https://example.com/table"],
|
||||
["inner", "https://example.com/table"],
|
||||
["italic", "https://example.com/table"],
|
||||
["cell", "https://example.com/table"],
|
||||
["Logo", "https://example.com/outer"],
|
||||
] as const) {
|
||||
const position = findRenderedText(text)
|
||||
for (let offset = 0; offset < text.length; offset++) {
|
||||
expect(renderer.getLinkAt(position.x + offset, position.y)).toBe(url)
|
||||
}
|
||||
}
|
||||
|
||||
expect(captureFrame()).not.toContain("https://example.com/")
|
||||
})
|
||||
|
||||
test("compact prose links conceal titled, spaced, angle-bracketed, nested, and escaped destinations", async () => {
|
||||
setRendererCapabilities(renderer, { hyperlinks: true })
|
||||
const md = createMarkdownRenderable({
|
||||
id: "markdown-complex-link-destinations",
|
||||
content:
|
||||
'[Titled](https://example.com/title "tip")\n\n' +
|
||||
"[Angle](<https://example.com/angle>)\n\n" +
|
||||
'[Spaced]( https://example.com/spaced "spaced tip")\n\n' +
|
||||
'[Spaced angle]( <https://example.com/spaced-angle> "angle tip")\n\n' +
|
||||
"[Nested](https://example.com/a_(b))\n\n" +
|
||||
"[Escaped](https://example.com/a\\_b\\(c\\))",
|
||||
syntaxStyle,
|
||||
})
|
||||
|
||||
renderer.root.add(md)
|
||||
await renderMarkdownRenderable(md)
|
||||
|
||||
for (const [text, url] of [
|
||||
["Titled", "https://example.com/title"],
|
||||
["Angle", "https://example.com/angle"],
|
||||
["Spaced", "https://example.com/spaced"],
|
||||
["Spaced angle", "https://example.com/spaced-angle"],
|
||||
["Nested", "https://example.com/a_(b)"],
|
||||
["Escaped", "https://example.com/a_b(c)"],
|
||||
] as const) {
|
||||
const position = findRenderedText(text)
|
||||
expect(renderer.getLinkAt(position.x, position.y)).toBe(url)
|
||||
}
|
||||
|
||||
expect(captureFrame()).not.toContain("https://example.com/")
|
||||
expect(captureFrame()).not.toContain("tip")
|
||||
})
|
||||
|
||||
for (const [label, ending, supported] of [
|
||||
["maximum ASCII", "a", true],
|
||||
["oversized ASCII", "aa", false],
|
||||
["maximum UTF-8", "\u00e9", true],
|
||||
["oversized UTF-8", "\u00e9a", false],
|
||||
["maximum escaped ASCII", "\\_", true],
|
||||
["maximum custom scheme", "a", true],
|
||||
] as const) {
|
||||
test(`${label} Markdown destinations respect the native link-pool UTF-8 capacity`, async () => {
|
||||
setRendererCapabilities(renderer, { hyperlinks: true })
|
||||
resizeRenderer(560, 12)
|
||||
|
||||
const custom = label === "maximum custom scheme"
|
||||
const prefix = custom ? "custom+v1://example.test/" : "https://example.com/"
|
||||
const destination = prefix + "a".repeat(511 - prefix.length - (ending.startsWith("\u00e9") ? 1 : 0)) + ending
|
||||
const target = destination.replace("\\_", "_")
|
||||
const md = createMarkdownRenderable({
|
||||
id: "markdown-link-native-capacity",
|
||||
content: `[Prose](${destination})\n\n| Link |\n|---|\n| [Table](${destination}) |`,
|
||||
syntaxStyle,
|
||||
tableOptions: { widthMode: "content" },
|
||||
})
|
||||
|
||||
renderer.root.add(md)
|
||||
await renderMarkdownRenderable(md)
|
||||
|
||||
const frame = captureFrame()
|
||||
const prose = findRenderedText("Prose")
|
||||
const table = findRenderedText("Table")
|
||||
expect(new TextEncoder().encode(target)).toHaveLength(supported ? 512 : 513)
|
||||
if (ending === "\\_") expect(new TextEncoder().encode(destination)).toHaveLength(513)
|
||||
if (custom) expect(md.content).not.toContain("http")
|
||||
if (supported) {
|
||||
expect(frame).not.toContain(target)
|
||||
expect(renderer.getLinkAt(prose.x, prose.y)).toBe(target)
|
||||
expect(renderer.getLinkAt(table.x, table.y)).toBe(target)
|
||||
} else {
|
||||
expect(frame).toContain(`Prose (${destination})`)
|
||||
expect(frame).toContain(`Table (${destination})`)
|
||||
expect(renderer.getLinkAt(prose.x, prose.y)).toBeNull()
|
||||
expect(renderer.getLinkAt(table.x, table.y)).toBeNull()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test("bare URLs inside inline and fenced code are not hyperlinks", async () => {
|
||||
setRendererCapabilities(renderer, { hyperlinks: true })
|
||||
const md = createMarkdownRenderable({
|
||||
id: "markdown-code-links",
|
||||
content:
|
||||
"Inline `https://example.com/inline`\n\n```text\nhttps://example.com/fenced\n```\n\n" +
|
||||
"| Link |\n|---|\n| `https://example.com/cell` |",
|
||||
syntaxStyle,
|
||||
tableOptions: { widthMode: "content" },
|
||||
})
|
||||
|
||||
renderer.root.add(md)
|
||||
await renderMarkdownRenderable(md)
|
||||
|
||||
for (const url of ["https://example.com/inline", "https://example.com/fenced", "https://example.com/cell"]) {
|
||||
const position = findRenderedText(url)
|
||||
expect(renderer.getLinkAt(position.x, position.y)).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
test("wrapped markdown link labels preserve hyperlink metadata on every row", async () => {
|
||||
setRendererCapabilities(renderer, { hyperlinks: true })
|
||||
resizeRenderer(18, 20)
|
||||
const md = createMarkdownRenderable({
|
||||
id: "markdown-wrapped-link",
|
||||
content: "Visit [first second third](https://example.com/wrapped)",
|
||||
syntaxStyle,
|
||||
})
|
||||
|
||||
renderer.root.add(md)
|
||||
await renderMarkdownRenderable(md)
|
||||
|
||||
for (const text of ["first", "second", "third"]) {
|
||||
const position = findRenderedText(text)
|
||||
expect(renderer.getLinkAt(position.x, position.y)).toBe("https://example.com/wrapped")
|
||||
}
|
||||
|
||||
expect(captureFrame()).not.toContain("https://")
|
||||
})
|
||||
|
||||
for (const [internalBlockMode, wrapped] of [
|
||||
["coalesced", false],
|
||||
["top-level", false],
|
||||
["coalesced", true],
|
||||
] as const) {
|
||||
test(`hyperlink capability changes refresh existing ${internalBlockMode}${wrapped ? " wrapped" : ""} prose and table renderables`, async () => {
|
||||
setRendererCapabilities(renderer, { hyperlinks: false })
|
||||
const md = createMarkdownRenderable({
|
||||
id: "markdown-capability-transition",
|
||||
content: "[Prose](https://example.com/prose)\n\n| Link |\n|---|\n| [Table](https://example.com/table) |",
|
||||
syntaxStyle,
|
||||
internalBlockMode,
|
||||
tableOptions: { widthMode: "content" },
|
||||
renderNode: wrapped
|
||||
? (_token, context) => {
|
||||
const child = context.defaultRender()
|
||||
if (!child) return undefined
|
||||
const wrapper = new BoxRenderable(renderer, { width: "100%", flexDirection: "column" })
|
||||
wrapper.add(child)
|
||||
return wrapper
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
renderer.root.add(md)
|
||||
await renderMarkdownRenderable(md)
|
||||
|
||||
const prose = md._blockStates[0]!.renderable
|
||||
const table = md._blockStates[1]!.renderable
|
||||
expect(captureFrame()).toContain("Prose (https://example.com/prose)")
|
||||
expect(captureFrame()).toContain("Table (https://example.com/table)")
|
||||
|
||||
const recorder = new TestRecorder(renderer)
|
||||
recorder.rec()
|
||||
renderer.emit("capabilities", setRendererCapabilities(renderer, { hyperlinks: true }))
|
||||
await renderMarkdownRenderable(md)
|
||||
recorder.stop()
|
||||
|
||||
expect(md._blockStates[0]!.renderable).toBe(prose)
|
||||
expect(md._blockStates[1]!.renderable).toBe(table)
|
||||
expect(captureFrame()).toContain("Prose")
|
||||
expect(captureFrame()).toContain("Table")
|
||||
expect(captureFrame()).not.toContain("https://example.com/")
|
||||
for (const recorded of recorder.recordedFrames) {
|
||||
expect(recorded.frame).toContain("Prose")
|
||||
expect(recorded.frame).toContain("Table")
|
||||
}
|
||||
|
||||
renderer.emit("capabilities", setRendererCapabilities(renderer, { hyperlinks: false }))
|
||||
await renderMarkdownRenderable(md)
|
||||
|
||||
expect(md._blockStates[0]!.renderable).toBe(prose)
|
||||
expect(md._blockStates[1]!.renderable).toBe(table)
|
||||
expect(captureFrame()).toContain("Prose (https://example.com/prose)")
|
||||
expect(captureFrame()).toContain("Table (https://example.com/table)")
|
||||
})
|
||||
}
|
||||
|
||||
test("hyperlink capability changes preserve custom Markdown code callbacks", async () => {
|
||||
setRendererCapabilities(renderer, { hyperlinks: false })
|
||||
const onHighlight = (highlights: SimpleHighlight[]) => highlights
|
||||
const md = createMarkdownRenderable({
|
||||
id: "markdown-capability-custom-code",
|
||||
content: "Custom\n\nDelegated\n\nplain",
|
||||
syntaxStyle,
|
||||
renderNode: (token, context) => {
|
||||
if (token.raw.startsWith("Custom")) {
|
||||
return new CodeRenderable(renderer, {
|
||||
id: "custom-markdown-code",
|
||||
content: token.raw,
|
||||
filetype: "markdown",
|
||||
syntaxStyle,
|
||||
treeSitterClient: markdownTreeSitterClient,
|
||||
onHighlight,
|
||||
})
|
||||
}
|
||||
|
||||
const renderable = context.defaultRender() as CodeRenderable
|
||||
if (token.raw.startsWith("Delegated")) renderable.onHighlight = onHighlight
|
||||
else renderable.content = "[Injected](custom://target)"
|
||||
return renderable
|
||||
},
|
||||
})
|
||||
|
||||
renderer.root.add(md)
|
||||
await renderMarkdownRenderable(md)
|
||||
|
||||
const custom = md._blockStates[0]!.renderable as CodeRenderable
|
||||
const delegated = md._blockStates[1]!.renderable as CodeRenderable
|
||||
const injected = md._blockStates[2]!.renderable as CodeRenderable
|
||||
expect(captureFrame()).toContain("Injected (custom://target)")
|
||||
let label = findRenderedText("Injected")
|
||||
expect(renderer.getLinkAt(label.x, label.y)).toBe("custom://target")
|
||||
|
||||
renderer.emit("capabilities", setRendererCapabilities(renderer, { hyperlinks: true }))
|
||||
await renderMarkdownRenderable(md)
|
||||
|
||||
expect(custom.onHighlight).toBe(onHighlight)
|
||||
expect(delegated.onHighlight).toBe(onHighlight)
|
||||
expect(md._blockStates[2]!.renderable).toBe(injected)
|
||||
expect(captureFrame()).toContain("Injected")
|
||||
expect(captureFrame()).not.toContain("custom://target")
|
||||
label = findRenderedText("Injected")
|
||||
expect(renderer.getLinkAt(label.x, label.y)).toBe("custom://target")
|
||||
})
|
||||
|
||||
test("hyperlink capability changes preserve custom table content", async () => {
|
||||
setRendererCapabilities(renderer, { hyperlinks: false })
|
||||
const md = createMarkdownRenderable({
|
||||
id: "markdown-capability-custom-table",
|
||||
content: "| Link |\n|---|\n| [Markdown](https://example.com/markdown) |",
|
||||
syntaxStyle,
|
||||
renderNode: (token) =>
|
||||
token.type === "table"
|
||||
? new TextTableRenderable(renderer, {
|
||||
content: [[[{ __isChunk: true, text: "Application-owned content", attributes: 0 }]]],
|
||||
columnWidthMode: "content",
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
|
||||
renderer.root.add(md)
|
||||
await renderMarkdownRenderable(md)
|
||||
|
||||
const table = md._blockStates[0]!.renderable as TextTableRenderable
|
||||
const content = table.content
|
||||
expect(captureFrame()).toContain("Application-owned content")
|
||||
|
||||
for (const hyperlinks of [true, false]) {
|
||||
renderer.emit("capabilities", setRendererCapabilities(renderer, { hyperlinks }))
|
||||
await renderMarkdownRenderable(md)
|
||||
|
||||
expect(md._blockStates[0]!.renderable).toBe(table)
|
||||
expect(table.content).toBe(content)
|
||||
expect(captureFrame()).toContain("Application-owned content")
|
||||
expect(captureFrame()).not.toContain("Markdown")
|
||||
}
|
||||
})
|
||||
|
||||
for (const wrapped of [false, true]) {
|
||||
test(`hyperlink capability changes refresh nested prose and tables in ${wrapped ? "wrapped " : ""}top-level lists`, async () => {
|
||||
setRendererCapabilities(renderer, { hyperlinks: false })
|
||||
const md = createMarkdownRenderable({
|
||||
id: "markdown-capability-nested-table",
|
||||
content:
|
||||
"- [Before](https://example.com/prose)\n\n | Link |\n |---|\n | [Nested](https://example.com/nested) |",
|
||||
syntaxStyle,
|
||||
internalBlockMode: "top-level",
|
||||
tableOptions: { widthMode: "content" },
|
||||
renderNode: wrapped
|
||||
? (token, context) => {
|
||||
if (token.type !== "list") return undefined
|
||||
const wrapper = new BoxRenderable(renderer, { width: "100%", flexDirection: "column" })
|
||||
wrapper.add(context.defaultRender()!)
|
||||
return wrapper
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
renderer.root.add(md)
|
||||
await renderMarkdownRenderable(md)
|
||||
|
||||
const block = md._blockStates[0]!.renderable
|
||||
const list = wrapped ? block.getChildren()[0]! : block
|
||||
const content = list.getChildren()[0]!.getChildren()[1]!
|
||||
const prose = content.getChildren()[0]!
|
||||
const table = content.getChildren()[1]!
|
||||
expect(prose).toBeInstanceOf(CodeRenderable)
|
||||
expect(table).toBeInstanceOf(TextTableRenderable)
|
||||
expect(captureFrame()).toContain("Before (https://example.com/prose)")
|
||||
expect(captureFrame()).toContain("Nested (https://example.com/nested)")
|
||||
|
||||
renderer.emit("capabilities", setRendererCapabilities(renderer, { hyperlinks: true }))
|
||||
await renderMarkdownRenderable(md)
|
||||
|
||||
expect(md._blockStates[0]!.renderable).toBe(block)
|
||||
expect(wrapped ? block.getChildren()[0] : block).toBe(list)
|
||||
expect(list.getChildren()[0]!.getChildren()[1]).toBe(content)
|
||||
expect(content.getChildren()[0]).toBe(prose)
|
||||
expect(content.getChildren()[1]).toBe(table)
|
||||
expect(captureFrame()).not.toContain("https://example.com/")
|
||||
for (const [text, url] of [
|
||||
["Before", "https://example.com/prose"],
|
||||
["Nested", "https://example.com/nested"],
|
||||
] as const) {
|
||||
const compact = findRenderedText(text)
|
||||
expect(renderer.getLinkAt(compact.x, compact.y)).toBe(url)
|
||||
}
|
||||
|
||||
renderer.emit("capabilities", setRendererCapabilities(renderer, { hyperlinks: false }))
|
||||
await renderMarkdownRenderable(md)
|
||||
|
||||
expect(md._blockStates[0]!.renderable).toBe(block)
|
||||
expect(wrapped ? block.getChildren()[0] : block).toBe(list)
|
||||
expect(list.getChildren()[0]!.getChildren()[1]).toBe(content)
|
||||
expect(content.getChildren()[0]).toBe(prose)
|
||||
expect(content.getChildren()[1]).toBe(table)
|
||||
expect(captureFrame()).toContain("Before (https://example.com/prose)")
|
||||
expect(captureFrame()).toContain("Nested (https://example.com/nested)")
|
||||
})
|
||||
}
|
||||
|
||||
test("dense Markdown link highlights conceal every destination", () => {
|
||||
setRendererCapabilities(renderer, { hyperlinks: true })
|
||||
const md = createMarkdownRenderable({ id: "markdown-dense-link-highlights", syntaxStyle })
|
||||
const addMarkdownLinkHighlights = (
|
||||
md as unknown as { addMarkdownLinkHighlights: (items: SimpleHighlight[], source: string) => SimpleHighlight[] }
|
||||
).addMarkdownLinkHighlights.bind(md)
|
||||
const highlights: SimpleHighlight[] = [
|
||||
[0, 9, "markup.strong"],
|
||||
[0, 2, "conceal", { conceal: "" }],
|
||||
[2, 7, "markup.link.label"],
|
||||
[7, 9, "conceal", { conceal: "" }],
|
||||
[14, 20, "markup.raw"],
|
||||
]
|
||||
|
||||
expect(addMarkdownLinkHighlights(highlights, "**label** and `code`")).toBe(highlights)
|
||||
highlights.length = 0
|
||||
let content = ""
|
||||
|
||||
for (let index = 0; index < 1024; index++) {
|
||||
const start = content.length
|
||||
const destination = `https://example.com/${index}`
|
||||
content += `[x](${destination}) `
|
||||
highlights.push(
|
||||
[start + 1, start + 2, "markup.link.label"],
|
||||
[start + 2, start + 3, "conceal", { conceal: " " }],
|
||||
[start + 4, start + 4 + destination.length, "markup.link.url"],
|
||||
[start + 4 + destination.length, start + 5 + destination.length, "markup.link"],
|
||||
)
|
||||
}
|
||||
|
||||
const modified = addMarkdownLinkHighlights(highlights, content)
|
||||
|
||||
expect(modified.filter(([, , group, meta]) => group === "conceal" && meta?.isInjection)).toHaveLength(1024)
|
||||
expect(modified.filter(([, , group, meta]) => group === "conceal" && meta?.conceal === " ")).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("multiple markdown renderables share and clean up one renderer capability listener", () => {
|
||||
const listenersBefore = renderer.listenerCount("capabilities")
|
||||
const renderables = Array.from({ length: 12 }, (_, index) =>
|
||||
createMarkdownRenderable({
|
||||
id: `markdown-shared-capability-listener-${index}`,
|
||||
syntaxStyle,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(renderer.listenerCount("capabilities")).toBe(listenersBefore + 1)
|
||||
for (const renderable of renderables) renderable.destroyRecursively()
|
||||
expect(renderer.listenerCount("capabilities")).toBe(listenersBefore)
|
||||
})
|
||||
|
||||
test("links with conceal mode", async () => {
|
||||
const markdown = `Check out [OpenTUI](https://github.com/sst/opentui) for more.`
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ import { type Clock, type TimerHandle, SystemClock } from "./lib/clock.js"
|
||||
import { StdinParser, type StdinEvent, type StdinParserProtocolContext } from "./lib/stdin-parser.js"
|
||||
import { matchesKeyBinding } from "./lib/keybinding.internal.js"
|
||||
import { RendererThemeMode } from "./renderer-theme-mode.js"
|
||||
import { getLinkId } from "./utils.js"
|
||||
|
||||
registerEnvVar({
|
||||
name: "OTUI_DUMP_CAPTURES",
|
||||
@@ -2052,6 +2053,10 @@ export class CliRenderer extends EventEmitter implements RenderContext {
|
||||
const destroyListener = (): void => {
|
||||
destroySurface()
|
||||
}
|
||||
const capabilitiesListener = (capabilities: TerminalCapabilities): void => {
|
||||
snapshotContext.capabilities = capabilities
|
||||
renderContext.emit(CliRenderEvents.CAPABILITIES, capabilities)
|
||||
}
|
||||
|
||||
const assertNotDestroyed = (): void => {
|
||||
if (surfaceDestroyed) {
|
||||
@@ -2278,6 +2283,7 @@ export class CliRenderer extends EventEmitter implements RenderContext {
|
||||
|
||||
surfaceDestroyed = true
|
||||
renderer.off(CliRenderEvents.DESTROY, destroyListener)
|
||||
renderer.off(CliRenderEvents.CAPABILITIES, capabilitiesListener)
|
||||
|
||||
let destroyError: unknown = null
|
||||
|
||||
@@ -2304,6 +2310,7 @@ export class CliRenderer extends EventEmitter implements RenderContext {
|
||||
}
|
||||
|
||||
renderer.on(CliRenderEvents.DESTROY, destroyListener)
|
||||
renderer.on(CliRenderEvents.CAPABILITIES, capabilitiesListener)
|
||||
|
||||
return {
|
||||
get renderContext(): RenderContext {
|
||||
@@ -3862,6 +3869,20 @@ export class CliRenderer extends EventEmitter implements RenderContext {
|
||||
return this.lib.checkHit(this.rendererPtr, x, y)
|
||||
}
|
||||
|
||||
public getLinkIdAt(x: number, y: number): number {
|
||||
if (this._isDestroyed || !Number.isInteger(x) || !Number.isInteger(y)) return 0
|
||||
|
||||
const buffer = this.currentRenderBuffer
|
||||
if (x < 0 || y < 0 || x >= buffer.width || y >= buffer.height) return 0
|
||||
|
||||
return getLinkId(buffer.buffers.attributes[y * buffer.width + x])
|
||||
}
|
||||
|
||||
public getLinkAt(x: number, y: number): string | null {
|
||||
const linkId = this.getLinkIdAt(x, y)
|
||||
return linkId === 0 ? null : this.currentRenderBuffer.lib.linkGetUrl(linkId) || null
|
||||
}
|
||||
|
||||
private takeMemorySnapshot(): void {
|
||||
if (this._isDestroyed) return
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Writable } from "stream"
|
||||
import { createCliRenderer, CliRenderer, CliRenderEvents } from "../renderer.js"
|
||||
import { BoxRenderable } from "../renderables/Box.js"
|
||||
import { ImageRenderable } from "../renderables/Image.js"
|
||||
import { MarkdownRenderable } from "../renderables/Markdown.js"
|
||||
import { SyntaxStyle } from "../syntax-style.js"
|
||||
import { ManualClock } from "../testing/manual-clock.js"
|
||||
import { createTestStdin, TestWriteStream } from "../testing/test-streams.js"
|
||||
|
||||
@@ -169,6 +171,89 @@ test("non-process stdout: rendered bytes flow to the custom Writable", async ()
|
||||
expect(received.includes(0x1b)).toBe(true)
|
||||
})
|
||||
|
||||
test("late Ghostty capability detection emits OSC 8 for compact Markdown links", async () => {
|
||||
const stdin = createTestStdin()
|
||||
const stdout = createCollectingStdout(100, 8)
|
||||
const renderer = await createCliRenderer({ stdin, stdout, useMouse: true })
|
||||
destroyFns.push(() => renderer.destroy())
|
||||
|
||||
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } })
|
||||
destroyFns.push(() => syntaxStyle.destroy())
|
||||
|
||||
renderer.root.add(
|
||||
new MarkdownRenderable(renderer, {
|
||||
content: "| Link |\n| --- |\n| [OpenTUI](https://github.com/anomalyco/opentui) |\n| https://example.com/path |",
|
||||
syntaxStyle,
|
||||
tableOptions: { style: "columns", widthMode: "content" },
|
||||
}),
|
||||
)
|
||||
await renderer.idle()
|
||||
await flushWritable(stdout)
|
||||
|
||||
const initialFrame = new TextDecoder().decode(renderer.currentRenderBuffer.getRealCharBytes(true))
|
||||
expect(initialFrame).toContain("OpenTUI (https://github.com/anomalyco/opentui)")
|
||||
expect(initialFrame.match(/https:\/\/example\.com\/path/g)).toHaveLength(1)
|
||||
expect(stdout.getWrittenBytes().toString("binary")).not.toContain("\x1b]8;id=")
|
||||
|
||||
stdout.clearWrites()
|
||||
stdin.emit("data", Buffer.from("\x1bP>|ghostty 1.1.3\x1b\\"))
|
||||
await renderer.idle()
|
||||
await flushWritable(stdout)
|
||||
|
||||
const finalFrame = new TextDecoder().decode(renderer.currentRenderBuffer.getRealCharBytes(true))
|
||||
const output = stdout.getWrittenBytes().toString("binary")
|
||||
expect(renderer.capabilities?.hyperlinks).toBe(true)
|
||||
expect(finalFrame).toContain("OpenTUI")
|
||||
expect(finalFrame).not.toContain("github.com/anomalyco/opentui")
|
||||
expect(finalFrame.match(/https:\/\/example\.com\/path/g)).toHaveLength(1)
|
||||
expect(output).toContain(";https://github.com/anomalyco/opentui\x1b\\")
|
||||
expect(output).toContain(";https://example.com/path\x1b\\")
|
||||
expect(output).toContain("\x1b]8;;\x1b\\")
|
||||
})
|
||||
|
||||
test("unidentified truecolor terminals preserve the visible Markdown link fallback", async () => {
|
||||
const previousTerm = process.env.TERM
|
||||
const previousColorTerm = process.env.COLORTERM
|
||||
process.env.TERM = "xterm-256color"
|
||||
process.env.COLORTERM = "truecolor"
|
||||
|
||||
try {
|
||||
const stdout = createCollectingStdout(80, 6)
|
||||
const renderer = await createCliRenderer({
|
||||
stdin: createTestStdin(),
|
||||
stdout,
|
||||
remote: false,
|
||||
forwardEnvKeys: ["TERM", "COLORTERM"],
|
||||
})
|
||||
destroyFns.push(() => renderer.destroy())
|
||||
|
||||
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } })
|
||||
destroyFns.push(() => syntaxStyle.destroy())
|
||||
renderer.root.add(
|
||||
new MarkdownRenderable(renderer, {
|
||||
content: "| Link |\n|---|\n| [OpenTUI](https://example.com/docs) |",
|
||||
syntaxStyle,
|
||||
tableOptions: { style: "columns", widthMode: "content" },
|
||||
}),
|
||||
)
|
||||
|
||||
await renderer.idle()
|
||||
await flushWritable(stdout)
|
||||
|
||||
expect(renderer.capabilities?.rgb).toBe(true)
|
||||
expect(renderer.capabilities?.hyperlinks).toBe(false)
|
||||
expect(new TextDecoder().decode(renderer.currentRenderBuffer.getRealCharBytes(true))).toContain(
|
||||
"OpenTUI (https://example.com/docs)",
|
||||
)
|
||||
expect(stdout.getWrittenBytes().toString("binary")).not.toContain("\x1b]8;id=")
|
||||
} finally {
|
||||
if (previousTerm === undefined) delete process.env.TERM
|
||||
else process.env.TERM = previousTerm
|
||||
if (previousColorTerm === undefined) delete process.env.COLORTERM
|
||||
else process.env.COLORTERM = previousColorTerm
|
||||
}
|
||||
})
|
||||
|
||||
test("auto images use detected Kitty graphics and delete cleared placements", async () => {
|
||||
const stdin = createTestStdin()
|
||||
const stdout = createCollectingStdout(8, 4)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
|
||||
import { createTestRenderer, MouseButtons, type MockMouse, type TestRenderer } from "../testing.js"
|
||||
import { Renderable, type RenderableOptions } from "../Renderable.js"
|
||||
import { link, t } from "../lib/styled-text.js"
|
||||
import { TextRenderable } from "../renderables/Text.js"
|
||||
import type { MouseEvent } from "../renderer.js"
|
||||
import type { RenderContext } from "../types.js"
|
||||
import { TextAttributes, type RenderContext } from "../types.js"
|
||||
import type { Selection } from "../lib/selection.js"
|
||||
import { getLinkId } from "../utils.js"
|
||||
|
||||
class TestRenderable extends Renderable {
|
||||
public selectionActive = false
|
||||
@@ -22,6 +25,149 @@ class TestRenderable extends Renderable {
|
||||
}
|
||||
}
|
||||
|
||||
describe("renderer getLinkAt", () => {
|
||||
let renderer: TestRenderer
|
||||
let mockMouse: MockMouse
|
||||
let renderOnce: () => Promise<void>
|
||||
|
||||
beforeEach(async () => {
|
||||
;({ renderer, mockMouse, renderOnce } = await createTestRenderer({ width: 12, height: 5, useMouse: true }))
|
||||
})
|
||||
|
||||
afterEach(() => renderer.destroy())
|
||||
|
||||
test("resolves rendered and clicked links while rejecting invalid coordinates and destroyed renderers", async () => {
|
||||
const url = "https://example.com/rendered"
|
||||
const content = t`A ${link(url)("link")} ${link("https://example.com/second")("two")}`
|
||||
const text = new TextRenderable(renderer, {
|
||||
id: "linked-text",
|
||||
position: "absolute",
|
||||
left: 2,
|
||||
top: 1,
|
||||
width: 10,
|
||||
height: 1,
|
||||
content,
|
||||
})
|
||||
const clicked: { url: string | null } = { url: null }
|
||||
text.onMouseDown = (event) => {
|
||||
clicked.url = renderer.getLinkAt(event.x, event.y)
|
||||
}
|
||||
renderer.root.add(text)
|
||||
await renderOnce()
|
||||
|
||||
const linkX = text.x + 2
|
||||
const linkId = renderer.getLinkIdAt(linkX, text.y)
|
||||
expect(linkId).toBeGreaterThan(0)
|
||||
expect(renderer.getLinkIdAt(linkX + 3, text.y)).toBe(linkId)
|
||||
expect(renderer.getLinkAt(linkX, text.y)).toBe(url)
|
||||
expect(renderer.getLinkAt(linkX + 3, text.y)).toBe(url)
|
||||
expect(renderer.getLinkIdAt(text.x, text.y)).toBe(0)
|
||||
expect(renderer.getLinkAt(text.x, text.y)).toBeNull()
|
||||
expect(renderer.getLinkIdAt(renderer.width - 1, renderer.height - 1)).toBe(0)
|
||||
expect(renderer.getLinkAt(renderer.width - 1, renderer.height - 1)).toBeNull()
|
||||
|
||||
const lib = renderer.currentRenderBuffer.lib
|
||||
const urlLookups = spyOn(lib, "linkGetUrl")
|
||||
const styledTextUpdates = spyOn(lib, "textBufferSetStyledText")
|
||||
|
||||
try {
|
||||
let hoveredLinkId = 0
|
||||
text.onMouseMove = (event) => {
|
||||
hoveredLinkId = renderer.getLinkIdAt(event.x, event.y)
|
||||
renderer.setMousePointer(hoveredLinkId ? "pointer" : "default")
|
||||
renderer.requestRender()
|
||||
}
|
||||
renderer.addPostProcessFn((buffer) => {
|
||||
if (!hoveredLinkId) return
|
||||
const attributes = buffer.buffers.attributes
|
||||
for (let index = 0; index < attributes.length; index++) {
|
||||
if (getLinkId(attributes[index]!) === hoveredLinkId) attributes[index] |= TextAttributes.UNDERLINE
|
||||
}
|
||||
})
|
||||
|
||||
for (const [x, first, second] of [
|
||||
[linkX, true, false],
|
||||
[linkX + 5, false, true],
|
||||
[linkX + 4, false, false],
|
||||
] as const) {
|
||||
await mockMouse.moveTo(x, text.y)
|
||||
await renderOnce()
|
||||
const start = text.y * renderer.width + text.x
|
||||
expect(
|
||||
Array.from(
|
||||
renderer.currentRenderBuffer.buffers.attributes.subarray(start, start + text.width),
|
||||
(attributes) => Boolean(attributes & TextAttributes.UNDERLINE),
|
||||
),
|
||||
).toEqual([false, false, first, first, first, first, false, second, second, second])
|
||||
}
|
||||
|
||||
expect(text.content).toBe(content)
|
||||
expect(styledTextUpdates).toHaveBeenCalledTimes(0)
|
||||
expect(urlLookups).toHaveBeenCalledTimes(0)
|
||||
|
||||
await mockMouse.pressDown(linkX, text.y)
|
||||
expect(clicked.url).toBe(url)
|
||||
expect(urlLookups).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
styledTextUpdates.mockRestore()
|
||||
urlLookups.mockRestore()
|
||||
}
|
||||
|
||||
renderer.useMouse = false
|
||||
expect(renderer.getLinkIdAt(linkX, text.y)).toBe(linkId)
|
||||
expect(renderer.getLinkAt(linkX, text.y)).toBe(url)
|
||||
|
||||
for (const [x, y] of [
|
||||
[-1, 0],
|
||||
[0, -1],
|
||||
[renderer.width, 0],
|
||||
[0, renderer.height],
|
||||
[Number.NaN, 0],
|
||||
[0, Number.NaN],
|
||||
[Number.POSITIVE_INFINITY, 0],
|
||||
[0, Number.NEGATIVE_INFINITY],
|
||||
[0.5, 0],
|
||||
[0, 0.5],
|
||||
]) {
|
||||
expect(renderer.getLinkIdAt(x, y)).toBe(0)
|
||||
expect(renderer.getLinkAt(x, y)).toBeNull()
|
||||
}
|
||||
|
||||
renderer.destroy()
|
||||
expect(renderer.getLinkIdAt(0, 0)).toBe(0)
|
||||
expect(renderer.getLinkAt(0, 0)).toBeNull()
|
||||
})
|
||||
|
||||
test("resolves linked cells after wrapping and across wide-character cells", async () => {
|
||||
const url = "https://example.com/wrapped"
|
||||
const text = new TextRenderable(renderer, {
|
||||
id: "wrapped-link",
|
||||
position: "absolute",
|
||||
left: 1,
|
||||
top: 1,
|
||||
width: 4,
|
||||
height: 2,
|
||||
wrapMode: "char",
|
||||
content: t`${link(url)("ab\u754cde")}`,
|
||||
})
|
||||
renderer.root.add(text)
|
||||
await renderOnce()
|
||||
|
||||
const linkId = renderer.getLinkIdAt(text.x, text.y)
|
||||
expect(linkId).toBeGreaterThan(0)
|
||||
for (const [x, y] of [
|
||||
[text.x, text.y],
|
||||
[text.x + 2, text.y],
|
||||
[text.x + 3, text.y],
|
||||
[text.x, text.y + 1],
|
||||
[text.x + 1, text.y + 1],
|
||||
]) {
|
||||
expect(renderer.getLinkIdAt(x, y)).toBe(linkId)
|
||||
expect(renderer.getLinkAt(x, y)).toBe(url)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("renderer handleMouseData", () => {
|
||||
let renderer: TestRenderer
|
||||
let mockMouse: MockMouse
|
||||
@@ -1255,6 +1401,35 @@ describe("renderer handleMouseData split height", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("split-footer mouse handlers resolve links using renderer-relative coordinates", async () => {
|
||||
try {
|
||||
const url = "https://example.com/split-footer"
|
||||
const text = new TextRenderable(renderer, {
|
||||
id: "split-footer-link",
|
||||
position: "absolute",
|
||||
left: 2,
|
||||
top: 1,
|
||||
width: 6,
|
||||
height: 1,
|
||||
content: t`${link(url)("click")}`,
|
||||
})
|
||||
const clicked: { url: string | null } = { url: null }
|
||||
text.onMouseDown = (event) => {
|
||||
clicked.url = renderer.getLinkAt(event.x, event.y)
|
||||
}
|
||||
renderer.root.add(text)
|
||||
await renderOnce()
|
||||
|
||||
const screenY = baseHeight - splitHeight + text.y
|
||||
await mockMouse.pressDown(text.x + 1, screenY)
|
||||
|
||||
expect(clicked.url).toBe(url)
|
||||
expect(renderer.getLinkAt(text.x + 1, screenY)).toBeNull()
|
||||
} finally {
|
||||
renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("split height clamps to terminal height before applying mouse offsets", async () => {
|
||||
const requestedFooterHeight = 8
|
||||
const terminalHeight = 5
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { readFile } from "node:fs/promises"
|
||||
|
||||
import type { OptimizedBuffer } from "../buffer.js"
|
||||
import { RGBA } from "../lib/RGBA.js"
|
||||
import { Renderable, type RenderableOptions } from "../Renderable.js"
|
||||
import { BoxRenderable } from "../renderables/Box.js"
|
||||
@@ -9,16 +10,12 @@ import { ImageRenderable } from "../renderables/Image.js"
|
||||
import { MarkdownRenderable } from "../renderables/Markdown.js"
|
||||
import { TextRenderable } from "../renderables/Text.js"
|
||||
import { SyntaxStyle } from "../syntax-style.js"
|
||||
import { createTestRenderer, MockTreeSitterClient, type TestRenderer } from "../testing.js"
|
||||
import { createTestRenderer, MockTreeSitterClient, setRendererCapabilities, type TestRenderer } from "../testing.js"
|
||||
import type { RenderContext } from "../types.js"
|
||||
import { getLinkId } from "../utils.js"
|
||||
|
||||
type ClaimedCommit = {
|
||||
snapshot: {
|
||||
height: number
|
||||
buffers: { char: Uint32Array }
|
||||
getRealCharBytes(addLineBreaks?: boolean): Uint8Array
|
||||
destroy(): void
|
||||
}
|
||||
snapshot: OptimizedBuffer
|
||||
rowColumns: number
|
||||
startOnNewLine: boolean
|
||||
trailingNewline: boolean
|
||||
@@ -297,6 +294,63 @@ test("ScrollbackSurface works with MarkdownRenderable top-level blocks", async (
|
||||
}
|
||||
})
|
||||
|
||||
test("ScrollbackSurface forwards hyperlink capability changes to existing Markdown tables", async () => {
|
||||
const { renderer } = await createSplitFooterRenderer()
|
||||
setRendererCapabilities(renderer, { hyperlinks: false })
|
||||
const capabilityListenerCount = renderer.listenerCount("capabilities")
|
||||
const surface = renderer.createScrollbackSurface({ startOnNewLine: true })
|
||||
const mockTreeSitterClient = createMockTreeSitterClient({ autoResolveTimeout: 0 })
|
||||
mockTreeSitterClient.setMockResult({ highlights: [] })
|
||||
const url = "https://example.com/table"
|
||||
const markdown = new MarkdownRenderable(surface.renderContext, {
|
||||
id: "surface-markdown-capability-transition",
|
||||
content: `| Link |\n|---|\n| [OpenTUI](${url}) |`,
|
||||
syntaxStyle,
|
||||
tableOptions: { widthMode: "content" },
|
||||
treeSitterClient: mockTreeSitterClient,
|
||||
})
|
||||
|
||||
surface.root.add(markdown)
|
||||
await surface.settle()
|
||||
const table = markdown._blockStates[0]!.renderable
|
||||
surface.commitRows(0, surface.height)
|
||||
|
||||
for (const hyperlinks of [true, false]) {
|
||||
const capabilities = setRendererCapabilities(renderer, { hyperlinks })
|
||||
renderer.emit("capabilities", capabilities)
|
||||
expect(surface.renderContext.capabilities).toBe(capabilities)
|
||||
await surface.settle()
|
||||
expect(markdown._blockStates[0]!.renderable).toBe(table)
|
||||
surface.commitRows(0, surface.height)
|
||||
}
|
||||
|
||||
const commits = claimCommits(renderer)
|
||||
|
||||
try {
|
||||
expect(commits).toHaveLength(3)
|
||||
const frames = commits.map((commit) => decoder.decode(commit.snapshot.getRealCharBytes(true)))
|
||||
expect(frames[0]).toContain(`OpenTUI (${url})`)
|
||||
expect(frames[1]).toContain("\u2502OpenTUI\u2502")
|
||||
expect(frames[1]).not.toContain(url)
|
||||
expect(frames[2]).toContain(`OpenTUI (${url})`)
|
||||
|
||||
for (const [index, commit] of commits.entries()) {
|
||||
const lines = frames[index]!.split("\n")
|
||||
const y = lines.findIndex((line) => line.includes("OpenTUI"))
|
||||
expect(y).toBeGreaterThanOrEqual(0)
|
||||
const x = lines[y]!.indexOf("OpenTUI")
|
||||
|
||||
const attributes = commit.snapshot.buffers.attributes[y * commit.snapshot.width + x]!
|
||||
expect(commit.snapshot.lib.linkGetUrl(getLinkId(attributes))).toBe(url)
|
||||
}
|
||||
} finally {
|
||||
destroyClaimedCommits(commits)
|
||||
}
|
||||
|
||||
surface.destroy()
|
||||
expect(renderer.listenerCount("capabilities")).toBe(capabilityListenerCount)
|
||||
})
|
||||
|
||||
test("ScrollbackSurface commitRows respects top-level block margins from custom renderNode blocks", async () => {
|
||||
const { renderer } = await createSplitFooterRenderer()
|
||||
const surface = renderer.createScrollbackSurface({ startOnNewLine: true })
|
||||
|
||||
@@ -102,6 +102,8 @@ import { isBunfsPath } from "./lib/bunfs.js"
|
||||
import { resolveNativeLibraryPath } from "#opentui/runtime-assets"
|
||||
import { allocStruct } from "bun-ffi-structs"
|
||||
|
||||
export const MAX_LINK_URL_BYTES = 512
|
||||
|
||||
// Struct outputs use `buffer` instead of `ptr`. A `buffer` call is about 3x cheaper on Bun 1.3 and 2.5x cheaper on
|
||||
// Bun 1.4. `buffer` rejects an argument that is not a view with a TypeError. `ptr` accepts a number as a raw address.
|
||||
// `buffer` also skips the Node pointer normalizer. Bun 1.3 rejects ArrayBuffer and DataView for `buffer`, so keep one
|
||||
@@ -2483,6 +2485,7 @@ export interface RenderLib extends AudioEngineLib {
|
||||
) => NativeRenderOperationResult
|
||||
getNextBuffer: (renderer: RendererHandle) => OptimizedBuffer
|
||||
getCurrentBuffer: (renderer: RendererHandle) => OptimizedBuffer
|
||||
linkGetUrl: (linkId: number, maxLen?: number) => string
|
||||
rendererSetPaletteState: (
|
||||
renderer: RendererHandle,
|
||||
palette: readonly RGBA[],
|
||||
@@ -4219,7 +4222,7 @@ class FFIRenderLib implements RenderLib {
|
||||
return this.opentui.symbols.linkAlloc(viewOrNull(urlBytes), urlBytes.byteLength)
|
||||
}
|
||||
|
||||
public linkGetUrl(linkId: number, maxLen: number = 512): string {
|
||||
public linkGetUrl(linkId: number, maxLen: number = MAX_LINK_URL_BYTES): string {
|
||||
const outBuffer = new Uint8Array(maxLen)
|
||||
const actualLen = this.opentui.symbols.linkGetUrl(linkId, viewOrNull(outBuffer), maxLen)
|
||||
return this.decoder.decode(outBuffer.slice(0, actualLen))
|
||||
|
||||
@@ -3,20 +3,24 @@ import {
|
||||
createCliRenderer,
|
||||
t,
|
||||
fg,
|
||||
underline,
|
||||
link,
|
||||
bold,
|
||||
italic,
|
||||
BoxRenderable,
|
||||
RGBA,
|
||||
RenderableEvents,
|
||||
TextAttributes,
|
||||
TextRenderable,
|
||||
getLinkId,
|
||||
type MouseEvent,
|
||||
type OptimizedBuffer,
|
||||
type RenderContext,
|
||||
type StyledText,
|
||||
} from "@opentui/core"
|
||||
import { spawn } from "node:child_process"
|
||||
import { setupCommonDemoKeys } from "./lib/standalone-keys.js"
|
||||
|
||||
let nextZIndex = 100
|
||||
let draggableBoxes: DraggableBox[] = []
|
||||
let dragModeEnabled = false
|
||||
|
||||
class DraggableBox extends BoxRenderable {
|
||||
@@ -94,10 +98,31 @@ export function run(renderer: CliRenderer): void {
|
||||
renderer.start()
|
||||
renderer.setBackgroundColor("#0f172a") // Deep slate blue background
|
||||
|
||||
let hoveredLinkId = 0
|
||||
const highlightHoveredLink = (buffer: OptimizedBuffer): void => {
|
||||
if (!hoveredLinkId) return
|
||||
|
||||
const attributes = buffer.buffers.attributes
|
||||
for (let index = 0; index < attributes.length; index++) {
|
||||
if (getLinkId(attributes[index]!) === hoveredLinkId) attributes[index] |= TextAttributes.UNDERLINE
|
||||
}
|
||||
}
|
||||
|
||||
const updateHoveredLink = (event: MouseEvent): void => {
|
||||
const nextLinkId = renderer.getLinkIdAt(event.x, event.y)
|
||||
if (nextLinkId === hoveredLinkId) return
|
||||
|
||||
if (nextLinkId === 0 || hoveredLinkId === 0) renderer.setMousePointer(nextLinkId ? "pointer" : "default")
|
||||
hoveredLinkId = nextLinkId
|
||||
renderer.requestRender()
|
||||
}
|
||||
|
||||
const container = new BoxRenderable(renderer, {
|
||||
id: "main-container",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
onMouseMove: updateHoveredLink,
|
||||
onMouseOver: updateHoveredLink,
|
||||
})
|
||||
renderer.root.add(container)
|
||||
|
||||
@@ -115,12 +140,18 @@ export function run(renderer: CliRenderer): void {
|
||||
container.add(header)
|
||||
|
||||
// Toggle drag mode with 'd' key
|
||||
renderer.keyInput.on("keypress", (event) => {
|
||||
const handleKeypress = (event: { name: string }): void => {
|
||||
if (event.name === "d") {
|
||||
dragModeEnabled = !dragModeEnabled
|
||||
header.content = getHeaderContent()
|
||||
}
|
||||
}
|
||||
container.once(RenderableEvents.DESTROYED, () => {
|
||||
renderer.removePostProcessFn(highlightHoveredLink)
|
||||
renderer.keyInput.off("keypress", handleKeypress)
|
||||
})
|
||||
renderer.addPostProcessFn(highlightHoveredLink)
|
||||
renderer.keyInput.on("keypress", handleKeypress)
|
||||
|
||||
// Card 1: Project Info
|
||||
createCard(
|
||||
@@ -134,9 +165,9 @@ export function run(renderer: CliRenderer): void {
|
||||
RGBA.fromHex("#1e293be6"), // Dark slate
|
||||
t`${bold(fg("#f472b6")("♥ Project Info"))}
|
||||
|
||||
${fg("#e2e8f0")("Source:")} ${link("https://github.com/anomalyco/opentui")(underline(fg("#38bdf8")("GitHub Repository")))}
|
||||
${fg("#e2e8f0")("Web:")} ${link("https://opentui.com")(underline(fg("#34d399")("Official Website")))}
|
||||
${fg("#e2e8f0")("License:")} ${link("https://github.com/anomalyco/opentui/blob/main/LICENSE")(underline(fg("#fbbf24")("MIT")))}`,
|
||||
${fg("#e2e8f0")("Source:")} ${link("https://github.com/anomalyco/opentui")(fg("#38bdf8")("GitHub Repository"))}
|
||||
${fg("#e2e8f0")("Web:")} ${link("https://opentui.com")(fg("#34d399")("Official Website"))}
|
||||
${fg("#e2e8f0")("License:")} ${link("https://github.com/anomalyco/opentui/blob/main/LICENSE")(fg("#fbbf24")("MIT"))}`,
|
||||
)
|
||||
|
||||
// Card 2: Documentation
|
||||
@@ -183,7 +214,7 @@ function createCard(
|
||||
width: number,
|
||||
height: number,
|
||||
bg: RGBA,
|
||||
content: any,
|
||||
content: StyledText,
|
||||
) {
|
||||
const card = new DraggableBox(renderer, id, x, y, width, height, bg)
|
||||
|
||||
@@ -192,21 +223,28 @@ function createCard(
|
||||
content: content,
|
||||
width: width - 2, // Account for padding
|
||||
height: height - 2,
|
||||
onMouseDown(event) {
|
||||
const url = event.button === 0 ? renderer.getLinkAt(event.x, event.y) : null
|
||||
if (!url) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const command =
|
||||
process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open"
|
||||
const child = spawn(command, [url], { detached: true, stdio: "ignore" })
|
||||
child.on("error", (error) => console.error("Failed to open link:", error))
|
||||
child.unref()
|
||||
},
|
||||
})
|
||||
|
||||
card.add(text)
|
||||
container.add(card)
|
||||
draggableBoxes.push(card)
|
||||
}
|
||||
|
||||
export function destroy(renderer: CliRenderer): void {
|
||||
for (const box of draggableBoxes) {
|
||||
renderer.root.remove(box)
|
||||
}
|
||||
draggableBoxes = []
|
||||
dragModeEnabled = false
|
||||
const mainContainer = renderer.root.getRenderable("main-container")
|
||||
if (mainContainer) renderer.root.remove(mainContainer)
|
||||
renderer.root.getRenderable("main-container")?.destroyRecursively()
|
||||
renderer.setMousePointer("default")
|
||||
renderer.setCursorPosition(0, 0, false)
|
||||
}
|
||||
|
||||
|
||||
@@ -675,7 +675,6 @@ fn checkEnvironmentOverrides(self: *Terminal) void {
|
||||
|
||||
if (self.caps.rgb) {
|
||||
self.caps.ansi256 = true;
|
||||
self.caps.hyperlinks = true;
|
||||
}
|
||||
|
||||
if (self.opts.remote_mode == .remote) {
|
||||
@@ -831,6 +830,9 @@ fn checkEnvironmentOverrides(self: *Terminal) void {
|
||||
if (env_map.get("WT_SESSION") != null) {
|
||||
self.caps.rgb = true;
|
||||
self.caps.ansi256 = true;
|
||||
if (builtin.os.tag == .windows and !self.term_info.from_xtversion and self.multiplexer == .none) {
|
||||
self.caps.hyperlinks = true;
|
||||
}
|
||||
self.setNotificationProtocol(.osc777, .heuristic);
|
||||
}
|
||||
|
||||
@@ -878,6 +880,10 @@ fn checkEnvironmentOverrides(self: *Terminal) void {
|
||||
}
|
||||
}
|
||||
|
||||
if (self.is_foot and self.multiplexer != .none) {
|
||||
self.caps.hyperlinks = false;
|
||||
}
|
||||
|
||||
if (!self.caps.hyperlinks and self.term_info.from_xtversion) {
|
||||
if (isHyperlinkTerm(self.getTerminalName())) {
|
||||
self.caps.hyperlinks = true;
|
||||
@@ -886,7 +892,7 @@ fn checkEnvironmentOverrides(self: *Terminal) void {
|
||||
|
||||
if (!self.caps.hyperlinks and !self.term_info.from_xtversion) {
|
||||
if (env_map.get("TERM")) |term| {
|
||||
if (isHyperlinkTerm(term)) {
|
||||
if (isHyperlinkTerm(term) and (!self.is_foot or self.multiplexer == .none)) {
|
||||
self.caps.hyperlinks = true;
|
||||
}
|
||||
}
|
||||
@@ -1420,6 +1426,7 @@ fn isHyperlinkTerm(value: []const u8) bool {
|
||||
std.ascii.findIgnoreCase(value, "kitty") != null or
|
||||
std.ascii.findIgnoreCase(value, "wezterm") != null or
|
||||
std.ascii.findIgnoreCase(value, "alacritty") != null or
|
||||
std.ascii.findIgnoreCase(value, "foot") != null or
|
||||
std.ascii.findIgnoreCase(value, "iterm") != null;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ test "parseXtversion - ghostty format" {
|
||||
try testing.expectEqualStrings("1.1.3", term.getTerminalVersion());
|
||||
try testing.expect(term.term_info.from_xtversion);
|
||||
try testing.expect(term.caps.osc52);
|
||||
try testing.expect(term.caps.hyperlinks);
|
||||
}
|
||||
|
||||
test "parseXtversion - tmux format" {
|
||||
@@ -668,6 +669,17 @@ test "setHostEnvVar applies env overrides in shared library mode" {
|
||||
try testing.expect(term.skip_graphics_query);
|
||||
}
|
||||
|
||||
test "environment overrides - direct Windows Terminal hyperlinks do not require TERM" {
|
||||
var env = std.process.Environ.Map.init(testing.allocator);
|
||||
defer env.deinit();
|
||||
try env.put("WT_SESSION", "test-session");
|
||||
|
||||
const term = Terminal.init(.{ .env_map = &env });
|
||||
|
||||
try testing.expect(term.caps.rgb);
|
||||
try testing.expectEqual(builtin.os.tag == .windows, term.caps.hyperlinks);
|
||||
}
|
||||
|
||||
test "environment overrides - enables hyperlinks for WSL Windows Terminal xterm" {
|
||||
var env = std.process.Environ.Map.init(testing.allocator);
|
||||
defer env.deinit();
|
||||
@@ -703,6 +715,78 @@ test "environment overrides - does not enable hyperlinks for WSL non-xterm terms
|
||||
try testing.expect(!term.caps.hyperlinks);
|
||||
}
|
||||
|
||||
test "environment overrides - foot hyperlinks require a direct terminal" {
|
||||
var env = std.process.Environ.Map.init(testing.allocator);
|
||||
defer env.deinit();
|
||||
try env.put("TERM", "foot");
|
||||
|
||||
const direct = Terminal.init(.{ .env_map = &env });
|
||||
try testing.expect(direct.caps.hyperlinks);
|
||||
|
||||
try env.put("STY", "1234.session");
|
||||
const inherited = Terminal.init(.{ .env_map = &env });
|
||||
try testing.expectEqual(Terminal.Multiplexer.screen, inherited.multiplexer);
|
||||
try testing.expect(!inherited.caps.hyperlinks);
|
||||
}
|
||||
|
||||
test "environment overrides - foot hyperlinks are revoked after forwarded multiplexer detection" {
|
||||
const multiplexers = [_]struct {
|
||||
key: []const u8,
|
||||
value: []const u8,
|
||||
kind: Terminal.Multiplexer,
|
||||
}{
|
||||
.{ .key = "STY", .value = "1234.session", .kind = .screen },
|
||||
.{ .key = "TERM_PROGRAM", .value = "tmux", .kind = .tmux },
|
||||
};
|
||||
|
||||
for (multiplexers) |multiplexer| {
|
||||
var term = Terminal.init(.{});
|
||||
defer term.deinit();
|
||||
|
||||
try term.setHostEnvVar(testing.allocator, "TERM", "foot");
|
||||
try testing.expect(term.caps.hyperlinks);
|
||||
|
||||
try term.setHostEnvVar(testing.allocator, multiplexer.key, multiplexer.value);
|
||||
try testing.expectEqual(multiplexer.kind, term.multiplexer);
|
||||
try testing.expect(!term.caps.hyperlinks);
|
||||
}
|
||||
}
|
||||
|
||||
test "environment overrides - rgb does not imply hyperlinks after recheck for unknown truecolor terminals" {
|
||||
var term = Terminal.init(.{});
|
||||
defer term.deinit();
|
||||
|
||||
try term.setHostEnvVar(testing.allocator, "TERM", "xterm-256color");
|
||||
try term.setHostEnvVar(testing.allocator, "COLORTERM", "truecolor");
|
||||
|
||||
try testing.expect(term.caps.rgb);
|
||||
try testing.expect(!term.caps.hyperlinks);
|
||||
|
||||
try term.setHostEnvVar(testing.allocator, "OPENTUI_FORCE_UNICODE", "1");
|
||||
|
||||
try testing.expect(term.caps.rgb);
|
||||
try testing.expect(!term.caps.hyperlinks);
|
||||
}
|
||||
|
||||
test "environment overrides - rgb does not imply hyperlinks after recheck for inherited Windows Terminal screen" {
|
||||
var env = std.process.Environ.Map.init(testing.allocator);
|
||||
defer env.deinit();
|
||||
try env.put("WSL_INTEROP", "/run/WSL/123_interop");
|
||||
try env.put("WT_SESSION", "test-session");
|
||||
try env.put("TERM", "screen");
|
||||
|
||||
var term = Terminal.init(.{ .env_map = &env });
|
||||
defer term.deinit();
|
||||
|
||||
try testing.expect(term.caps.rgb);
|
||||
try testing.expect(!term.caps.hyperlinks);
|
||||
|
||||
try term.setHostEnvVar(testing.allocator, "TERM", "screen");
|
||||
|
||||
try testing.expect(term.caps.rgb);
|
||||
try testing.expect(!term.caps.hyperlinks);
|
||||
}
|
||||
|
||||
test "setHostEnvVar detects ansi256 separately from rgb" {
|
||||
var env = std.process.Environ.Map.init(testing.allocator);
|
||||
defer env.deinit();
|
||||
@@ -1075,6 +1159,7 @@ test "processCapabilityResponse - foot applies osc52 heuristic without explicit
|
||||
try testing.expect(!term.caps.rgb);
|
||||
try testing.expect(!term.caps.ansi256);
|
||||
try testing.expect(term.caps.osc52);
|
||||
try testing.expect(term.caps.hyperlinks);
|
||||
try testing.expect(!term.caps.explicit_cursor_positioning);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user