diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index cef891041..6763921f7 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -1,12 +1,31 @@ import { LRUCache } from "lru-cache/raw"; import { Marked, marked, type Token, Tokenizer, type Tokens } from "marked"; import type { SymbolTheme } from "../symbols"; -import { TERMINAL } from "../terminal-capabilities"; +import { getTextSizing, TERMINAL } from "../terminal-capabilities"; import type { Component } from "../tui"; -import { applyBackgroundToLine, padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils"; +import { + applyBackgroundToLine, + encodeTextSized, + getSegmenter, + padding, + replaceTabs, + visibleWidth, + wrapTextWithAnsi, +} from "../utils"; const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/; +// OSC 66 (Kitty text-sizing) heading spans are emitted as a single indivisible +// unit by the H1 render path. Like image-protocol lines, they must bypass +// ANSI wrapping and width padding: re-wrapping splits/normalizes the sized span +// (recomputing the explicit `w=` cell count and hoisting SGR out of the OSC +// payload), and padding would append trailing cells past the doubled glyph. +const OSC66_LINE_PREFIX = "\x1b]66;"; + +function isOsc66Line(line: string): boolean { + return line.includes(OSC66_LINE_PREFIX); +} + class StrictStrikethroughTokenizer extends Tokenizer { override del(src: string): Tokens.Del | undefined { const match = STRICT_STRIKETHROUGH_REGEX.exec(src); @@ -130,6 +149,59 @@ function formatHyperlink(text: string, target: string): string { return `\x1b]8;;${safeTarget}\x07${text}\x1b]8;;\x07`; } +function isAsciiTextSizingPayload(text: string): boolean { + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code < 0x20 || code > 0x7e) return false; + } + return true; +} + +function encodeTextSizedHeading(text: string, scale: 1 | 2 | 3): string { + let out = ""; + let asciiRun = ""; + const flushAscii = () => { + if (asciiRun === "") return; + out += encodeTextSized(asciiRun, { scale }); + asciiRun = ""; + }; + + for (const { segment } of getSegmenter().segment(text)) { + if (isAsciiTextSizingPayload(segment)) { + asciiRun += segment; + continue; + } + flushAscii(); + out += encodeTextSized(segment, { scale, widthCells: visibleWidth(segment) }); + } + flushAscii(); + return out; +} + +function plainInlineTokens(tokens: Token[]): string { + let result = ""; + for (const token of tokens) { + switch (token.type) { + case "text": + result += token.tokens && token.tokens.length > 0 ? plainInlineTokens(token.tokens) : token.text; + break; + case "strong": + case "em": + case "del": + case "link": + result += plainInlineTokens(token.tokens || []); + break; + case "codespan": + result += token.text; + break; + default: + if ("text" in token && typeof token.text === "string") result += token.text; + break; + } + } + return result; +} + // --------------------------------------------------------------------------- // Inline hex-color swatches // --------------------------------------------------------------------------- @@ -285,7 +357,7 @@ export class Markdown implements Component { // by MarkdownTheme and is one of the most styling-sensitive entries. const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : ""; const headingProbe = this.#theme.heading(""); - const cacheKey = `${normalizedText}\x00${width}\x00${this.#paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}\x00${TERMINAL.imageProtocol ?? ""}\x00${TERMINAL.hyperlinks ? 1 : 0}\x00${bgColorProbe}\x00${headingProbe}`; + const cacheKey = `${normalizedText}\x00${width}\x00${this.#paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}\x00${TERMINAL.imageProtocol ?? ""}\x00${TERMINAL.hyperlinks ? 1 : 0}\x00${getTextSizing() ? 1 : 0}\x00${bgColorProbe}\x00${headingProbe}`; const cached = renderCache.get(cacheKey); if (cached !== undefined) { // Populate L1 so subsequent calls from this instance are O(1) map lookup. @@ -311,8 +383,9 @@ export class Markdown implements Component { // Wrap lines (NO padding, NO background yet) const wrappedLines: string[] = []; for (const line of renderedLines) { - // Skip wrapping for image protocol lines (would corrupt escape sequences) - if (TERMINAL.isImageLine(line)) { + // Skip wrapping for image protocol lines and OSC 66 sized headings + // (would corrupt escape sequences / split the indivisible sized span). + if (TERMINAL.isImageLine(line) || isOsc66Line(line)) { wrappedLines.push(line); } else { wrappedLines.push(...wrapTextWithAnsi(line, contentWidth)); @@ -325,13 +398,29 @@ export class Markdown implements Component { const bgFn = this.#defaultTextStyle?.bgColor; const contentLines: string[] = []; + let previousLineWasOsc66 = false; + for (const line of wrappedLines) { - // Image lines must be output raw - no margins or background - if (TERMINAL.isImageLine(line)) { - contentLines.push(line); + // The first empty row after a scale>1 OSC 66 heading is structural: + // it reserves the lower cells occupied by the multicell glyphs. Do + // not pad or background-fill it, because real spaces on that row can + // interact with Kitty's multicell overwrite rules during the first + // paint. Leave it as a cursor-only newline. + if (previousLineWasOsc66 && line === "") { + contentLines.push(""); + previousLineWasOsc66 = false; continue; } + // Image lines and OSC 66 sized headings must be output raw - no margins or background + if (TERMINAL.isImageLine(line) || isOsc66Line(line)) { + contentLines.push(line); + previousLineWasOsc66 = isOsc66Line(line); + continue; + } + + previousLineWasOsc66 = false; + const lineWithMargins = leftMargin + line + rightMargin; if (bgFn) { @@ -459,7 +548,22 @@ export class Markdown implements Component { const headingLevel = token.depth; const headingPrefix = `${"#".repeat(headingLevel)} `; const headingText = this.#renderInlineTokens(token.tokens || [], styleContext); + const headingPlainText = plainInlineTokens(token.tokens || []); let styledHeading: string; + if (headingLevel === 1 && getTextSizing()) { + const plainWidth = visibleWidth(headingPlainText); + if (plainWidth > 0 && 2 * plainWidth <= width) { + const sizedHeading = encodeTextSizedHeading(headingPlainText, 2); + lines.push(this.#theme.heading(this.#theme.bold(this.#theme.underline(sizedHeading)))); + if (nextTokenType) { + lines.push(""); // reserve the heading's second visual row + if (nextTokenType !== "space") { + lines.push(""); // Add spacing after headings (unless space token follows) + } + } + break; + } + } if (headingLevel === 1) { styledHeading = this.#theme.heading(this.#theme.bold(this.#theme.underline(headingText))); } else if (headingLevel === 2) { diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 5546d938f..87bc1f065 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -14,6 +14,66 @@ export { Ellipsis } from "@oh-my-pi/pi-natives"; export { getDefaultTabWidth, getIndentation } from "@oh-my-pi/pi-utils"; +export type TextSizingScale = 1 | 2 | 3; +export type TextSizingVerticalAlign = "top" | "bottom" | "center"; +export type TextSizingHorizontalAlign = "left" | "right" | "center"; + +export interface TextSizingOptions { + scale?: TextSizingScale; + widthCells?: number; + verticalAlign?: TextSizingVerticalAlign; + horizontalAlign?: TextSizingHorizontalAlign; +} + +const OSC66_UNSAFE = /[\x00-\x1f\x7f-\x9f]/u; +const OSC66_UNSAFE_GLOBAL = /[\x00-\x1f\x7f-\x9f]/gu; + +function textSizingVerticalAlignValue(align: TextSizingVerticalAlign | undefined): number | undefined { + switch (align) { + case "top": + return 0; + case "bottom": + return 1; + case "center": + return 2; + default: + return undefined; + } +} + +function textSizingHorizontalAlignValue(align: TextSizingHorizontalAlign | undefined): number | undefined { + switch (align) { + case "left": + return 0; + case "right": + return 1; + case "center": + return 2; + default: + return undefined; + } +} + +/** + * Encode a plain-text span using Kitty's OSC 66 text-sizing protocol. The TUI + * emits only safe UTF-8 payloads and ST terminators so its ANSI parser and the + * terminal agree on span boundaries. + */ +export function encodeTextSized(text: string, options: TextSizingOptions = {}): string { + const metadata: string[] = []; + if (options.scale !== undefined) metadata.push(`s=${options.scale}`); + if (options.widthCells !== undefined && Number.isFinite(options.widthCells)) { + metadata.push(`w=${Math.max(0, Math.trunc(options.widthCells))}`); + } + const verticalAlign = textSizingVerticalAlignValue(options.verticalAlign); + if (verticalAlign !== undefined) metadata.push(`v=${verticalAlign}`); + const horizontalAlign = textSizingHorizontalAlignValue(options.horizontalAlign); + if (horizontalAlign !== undefined) metadata.push(`h=${horizontalAlign}`); + + const safeText = OSC66_UNSAFE.test(text) ? text.replace(OSC66_UNSAFE_GLOBAL, " ") : text; + return `\x1b]66;${metadata.join(":")};${safeText}\x1b\\`; +} + export function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult { return nativeSliceWithWidth(line, startCol, length, strict ?? null, getDefaultTabWidth()); } diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 7bd8df401..b68f61d85 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -1,10 +1,11 @@ -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import { stripVTControlCharacters } from "node:util"; import type { Terminal as XtermTerminalType } from "@xterm/headless"; import { Chalk } from "chalk"; import { Markdown, renderInlineMarkdown } from "../src/components/markdown.js"; -import { TERMINAL } from "../src/terminal-capabilities.js"; +import { getTextSizing, setTextSizing, TERMINAL } from "../src/terminal-capabilities.js"; import { type Component, TUI } from "../src/tui.js"; +import { visibleWidth } from "../src/utils.js"; import { defaultMarkdownTheme } from "./test-themes.js"; import { VirtualTerminal } from "./virtual-terminal.js"; @@ -1247,3 +1248,68 @@ describe("Module-level LRU render cache", () => { expect(lines2).toEqual(lines1); }); }); + +describe("OSC 66 text-sizing headings", () => { + const OSC66_INTRO = "\x1b]66;"; + + afterEach(() => { + // The capability gate is process-global; never let it leak into other suites. + setTextSizing(false); + }); + + it("keeps H1 as plain ANSI when text-sizing is disabled (default)", () => { + expect(getTextSizing()).toBe(false); + const lines = new Markdown("# Hello", 0, 0, defaultMarkdownTheme).render(80); + expect(lines.some(line => line.includes(OSC66_INTRO))).toBe(false); + expect(lines.some(line => stripVTControlCharacters(line).includes("Hello"))).toBe(true); + }); + + it("emits a scale-2 OSC 66 span for H1 when text-sizing is enabled and width allows", () => { + setTextSizing(true); + const lines = new Markdown("# Hello", 0, 0, defaultMarkdownTheme).render(80); + + const oscLine = lines.find(line => line.includes(OSC66_INTRO)); + expect(oscLine).toBeTruthy(); + expect(oscLine!).toContain("s=2"); + // The heading text rides inside the OSC 66 payload, so it survives in the + // raw bytes (stripVTControlCharacters would drop the whole OSC span). + expect(oscLine!.includes("Hello")).toBe(true); + + // Native + emit agree: a scale-2 span measures exactly twice the plain + // heading width regardless of how the span is internally encoded. + expect(visibleWidth(oscLine!)).toBe(2 * visibleWidth("Hello")); + }); + + it("leaves the reserved row after a scale-2 H1 as a cursor-only blank", () => { + setTextSizing(true); + const lines = new Markdown("# Hello\n\nBody", 0, 0, defaultMarkdownTheme).render(80); + const oscIndex = lines.findIndex(line => line.includes(OSC66_INTRO)); + expect(oscIndex).toBeGreaterThanOrEqual(0); + expect(lines[oscIndex + 1]).toBe(""); + expect(lines.some(line => stripVTControlCharacters(line).includes("Body"))).toBe(true); + }); + + it("doubles the measured width for wide/emoji H1 glyphs", () => { + setTextSizing(true); + const lines = new Markdown("# 🚀 Hi", 0, 0, defaultMarkdownTheme).render(80); + + const oscLine = lines.find(line => line.includes(OSC66_INTRO)); + expect(oscLine).toBeTruthy(); + expect(visibleWidth(oscLine!)).toBe(2 * visibleWidth("🚀 Hi")); + }); + + it("falls back to ANSI when the doubled H1 width would overflow the render width", () => { + setTextSizing(true); + // "Hello" is 5 cells; 2*5 = 10 > 8 render columns, so the OSC path is skipped. + const lines = new Markdown("# Hello", 0, 0, defaultMarkdownTheme).render(8); + expect(lines.some(line => line.includes(OSC66_INTRO))).toBe(false); + expect(lines.some(line => stripVTControlCharacters(line).includes("Hello"))).toBe(true); + }); + + it("keeps H2 as plain ANSI even when text-sizing is enabled", () => { + setTextSizing(true); + const lines = new Markdown("## Sub", 0, 0, defaultMarkdownTheme).render(80); + expect(lines.some(line => line.includes(OSC66_INTRO))).toBe(false); + expect(lines.some(line => stripVTControlCharacters(line).includes("Sub"))).toBe(true); + }); +}); diff --git a/packages/tui/test/text-utils.test.ts b/packages/tui/test/text-utils.test.ts index a74197b68..7086153d6 100644 --- a/packages/tui/test/text-utils.test.ts +++ b/packages/tui/test/text-utils.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { extractSegments, sliceWithWidth, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui/utils"; +import { + encodeTextSized, + extractSegments, + sliceWithWidth, + truncateToWidth, + visibleWidth, +} from "@oh-my-pi/pi-tui/utils"; describe("text utils", () => { it("computes visible width for ANSI and tabs", () => { @@ -78,6 +84,18 @@ describe("text utils", () => { expect(result.afterWidth).toBeGreaterThan(0); }); + it("encodes OSC 66 text sizing spans with ST terminators", () => { + const encoded = encodeTextSized("Hi", { + scale: 2, + widthCells: 3, + verticalAlign: "center", + horizontalAlign: "right", + }); + expect(encoded).toBe("\x1b]66;s=2:w=3:v=2:h=1;Hi\x1b\\"); + expect(visibleWidth(encoded)).toBe(6); + expect(encodeTextSized("A\nB", { scale: 1 })).toBe("\x1b]66;s=1;A B\x1b\\"); + }); + it("counts OSC 66 text-sizing spans as visible text", () => { expect(visibleWidth("\x1b]66;s=2;Hi\x1b\\")).toBe(4); expect(visibleWidth("\x1b]66;w=5;Hi\x1b\\")).toBe(5);