From 116bffeb8893eb7663cd336599ce735b51284b5c Mon Sep 17 00:00:00 2001 From: Sviatoslav Abakumov Date: Fri, 8 May 2026 17:24:56 +0400 Subject: [PATCH] feat(tui): wrap list items with indent --- packages/tui/src/components/markdown.ts | 115 ++++++------------------ packages/tui/test/markdown.test.ts | 85 +++++++++++++++--- 2 files changed, 102 insertions(+), 98 deletions(-) diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 7bdde68f4..29d4ff134 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -331,6 +331,10 @@ export class Markdown implements Component { break; } + case "text": + lines.push(this.renderInlineTokens([token], styleContext)); + break; + case "code": { const indent = this.theme.codeBlockIndent ?? " "; lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); @@ -354,7 +358,7 @@ export class Markdown implements Component { } case "list": { - const listLines = this.renderList(token as any, 0, styleContext); + const listLines = this.renderList(token as Tokens.List, 0, width, styleContext); lines.push(...listLines); // Don't add spacing after lists if a space token follows // (the space token will handle it) @@ -362,7 +366,7 @@ export class Markdown implements Component { } case "table": { - const tableLines = this.renderTable(token as any, width, nextTokenType, styleContext); + const tableLines = this.renderTable(token as Tokens.Table, width, nextTokenType, styleContext); lines.push(...tableLines); break; } @@ -543,104 +547,39 @@ export class Markdown implements Component { /** * Render a list with proper nesting support */ - private renderList( - token: Token & { items: any[]; ordered: boolean; start?: number }, - depth: number, - styleContext?: InlineStyleContext, - ): string[] { + private renderList(token: Tokens.List, depth: number, width: number, styleContext?: InlineStyleContext): string[] { const lines: string[] = []; - const indent = " ".repeat(depth); + const indent = " ".repeat(depth); // Use the list's start property (defaults to 1 for ordered lists) - const startNumber = token.start ?? 1; + const startNumber = typeof token.start === "number" ? token.start : 1; for (let i = 0; i < token.items.length; i++) { const item = token.items[i]; const bullet = token.ordered ? `${startNumber + i}. ` : "- "; + const firstPrefix = indent + this.theme.listBullet(bullet); + const continuationPrefix = indent + " ".repeat(visibleWidth(bullet)); + const itemWidth = Math.max(1, width - visibleWidth(firstPrefix)); + let renderedAnyLine = false; - // Process item tokens to handle nested lists - const itemLines = this.renderListItem(item.tokens || [], depth, styleContext); - - if (itemLines.length > 0) { - // First line - check if it's a nested list - // A nested list will start with indent (spaces) followed by cyan bullet - const firstLine = itemLines[0]; - const isNestedList = /^\s+\x1b\[36m[-\d]/.test(firstLine); // starts with spaces + cyan + bullet char - - if (isNestedList) { - // This is a nested list, just add it as-is (already has full indent) - lines.push(firstLine); - } else { - // Regular text content - add indent and bullet - lines.push(indent + this.theme.listBullet(bullet) + firstLine); + for (const itemToken of item.tokens) { + if (itemToken.type === "list") { + lines.push(...this.renderList(itemToken as Tokens.List, depth + 1, width, styleContext)); + renderedAnyLine = true; + continue; } - // Rest of the lines - for (let j = 1; j < itemLines.length; j++) { - const line = itemLines[j]; - const isNestedListLine = /^\s+\x1b\[36m[-\d]/.test(line); // starts with spaces + cyan + bullet char - - if (isNestedListLine) { - // Nested list line - already has full indent - lines.push(line); - } else { - // Regular content - add parent indent + 2 spaces for continuation - lines.push(`${indent} ${line}`); + const itemLines = this.renderToken(itemToken, itemWidth, undefined, styleContext); + for (const line of itemLines) { + for (const wrappedLine of wrapTextWithAnsi(line, itemWidth)) { + const linePrefix = renderedAnyLine ? continuationPrefix : firstPrefix; + lines.push(linePrefix + wrappedLine); + renderedAnyLine = true; } } - } else { - lines.push(indent + this.theme.listBullet(bullet)); } - } - return lines; - } - - /** - * Render list item tokens, handling nested lists - * Returns lines WITHOUT the parent indent (renderList will add it) - */ - private renderListItem(tokens: Token[], parentDepth: number, styleContext?: InlineStyleContext): string[] { - const lines: string[] = []; - - for (const token of tokens) { - if (token.type === "list") { - // Nested list - render with one additional indent level - // These lines will have their own indent, so we just add them as-is - const nestedLines = this.renderList(token as any, parentDepth + 1, styleContext); - lines.push(...nestedLines); - } else if (token.type === "text") { - // Text content (may have inline tokens) - const text = - token.tokens && token.tokens.length > 0 - ? this.renderInlineTokens(token.tokens, styleContext) - : token.text || ""; - lines.push(text); - } else if (token.type === "paragraph") { - // Paragraph in list item - const text = this.renderInlineTokens(token.tokens || [], styleContext); - lines.push(text); - } else if (token.type === "code") { - // Code block in list item - const indent = this.theme.codeBlockIndent ?? " "; - lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); - if (this.theme.highlightCode) { - const highlightedLines = this.theme.highlightCode(token.text, token.lang); - for (const hlLine of highlightedLines) { - lines.push(`${indent}${hlLine}`); - } - } else { - const codeLines = token.text.split("\n"); - for (const codeLine of codeLines) { - lines.push(`${indent}${this.theme.codeBlock(codeLine)}`); - } - } - lines.push(this.theme.codeBlockBorder("```")); - } else { - // Other token types - try to render as inline - const text = this.renderInlineTokens([token], styleContext); - if (text) { - lines.push(text); - } + if (!renderedAnyLine) { + lines.push(firstPrefix); } } @@ -677,7 +616,7 @@ export class Markdown implements Component { * Cells that don't fit are wrapped to multiple lines. */ private renderTable( - token: Token & { header: any[]; rows: any[][]; raw?: string }, + token: Tokens.Table, availableWidth: number, nextTokenType?: string, styleContext?: InlineStyleContext, diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 029a614e5..9c90eff91 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -31,8 +31,12 @@ function getCellUnderline(terminal: VirtualTerminal, row: number, col: number): return cell.isUnderline(); } +function stripAnsi(line: string): string { + return line.replace(/\x1b\[[0-9;]*m/g, ""); +} + describe("Markdown component", () => { - describe("Nested lists", () => { + describe("Lists", () => { it("should render simple nested list", () => { const markdown = new Markdown( `- Item 1 @@ -54,8 +58,8 @@ describe("Markdown component", () => { // Check structure assert.ok(plainLines.some((line) => line.includes("- Item 1"))); - assert.ok(plainLines.some((line) => line.includes(" - Nested 1.1"))); - assert.ok(plainLines.some((line) => line.includes(" - Nested 1.2"))); + assert.ok(plainLines.some((line) => line.includes(" - Nested 1.1"))); + assert.ok(plainLines.some((line) => line.includes(" - Nested 1.2"))); assert.ok(plainLines.some((line) => line.includes("- Item 2"))); }); @@ -75,9 +79,9 @@ describe("Markdown component", () => { // Check proper indentation assert.ok(plainLines.some((line) => line.includes("- Level 1"))); - assert.ok(plainLines.some((line) => line.includes(" - Level 2"))); - assert.ok(plainLines.some((line) => line.includes(" - Level 3"))); - assert.ok(plainLines.some((line) => line.includes(" - Level 4"))); + assert.ok(plainLines.some((line) => line.includes(" - Level 2"))); + assert.ok(plainLines.some((line) => line.includes(" - Level 3"))); + assert.ok(plainLines.some((line) => line.includes(" - Level 4"))); }); it("should render ordered nested list", () => { @@ -95,8 +99,8 @@ describe("Markdown component", () => { const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); assert.ok(plainLines.some((line) => line.includes("1. First"))); - assert.ok(plainLines.some((line) => line.includes(" 1. Nested first"))); - assert.ok(plainLines.some((line) => line.includes(" 2. Nested second"))); + assert.ok(plainLines.some((line) => line.includes(" 1. Nested first"))); + assert.ok(plainLines.some((line) => line.includes(" 2. Nested second"))); assert.ok(plainLines.some((line) => line.includes("2. Second"))); }); @@ -116,7 +120,7 @@ describe("Markdown component", () => { const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); assert.ok(plainLines.some((line) => line.includes("1. Ordered item"))); - assert.ok(plainLines.some((line) => line.includes(" - Unordered nested"))); + assert.ok(plainLines.some((line) => line.includes(" - Unordered nested"))); assert.ok(plainLines.some((line) => line.includes("2. Second ordered"))); }); @@ -156,6 +160,67 @@ describe("Markdown component", () => { assert.ok(numberedLines[1].startsWith("2."), `Second item should be "2.", got: ${numberedLines[1]}`); assert.ok(numberedLines[2].startsWith("3."), `Third item should be "3.", got: ${numberedLines[2]}`); }); + + it("should indent wrapped unordered list lines", () => { + const markdown = new Markdown("- alpha beta gamma delta epsilon", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(20).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["- alpha beta gamma", " delta epsilon"]); + }); + + it("should indent wrapped ordered list lines", () => { + const markdown = new Markdown("1. alpha beta gamma delta epsilon", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(20).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["1. alpha beta gamma", " delta epsilon"]); + }); + + it("should indent wrapped ordered list lines with multi-digit markers", () => { + const markdown = new Markdown("10. alpha beta gamma delta epsilon", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(21).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["10. alpha beta gamma", " delta epsilon"]); + }); + + it("should indent wrapped nested list lines", () => { + const markdown = new Markdown(`- parent\n - alpha beta gamma delta epsilon`, 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(24).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["- parent", " - alpha beta gamma", " delta epsilon"]); + }); + + it("should indent wrapped nested list lines under ordered parents", () => { + const markdown = new Markdown(`1. parent\n - alpha beta gamma delta epsilon`, 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(24).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["1. parent", " - alpha beta gamma", " delta epsilon"]); + }); + + it("should render and wrap blockquotes inside list items", () => { + const markdown = new Markdown("- > alpha beta gamma delta epsilon zeta", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(24).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["- │ alpha beta gamma", " │ delta epsilon zeta"]); + }); + + it("should render and wrap code blocks inside list items", () => { + const markdown = new Markdown( + "- ```ts\n alpha beta gamma delta epsilon zeta\n ```", + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(24).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["- ```ts", " alpha beta gamma", " delta epsilon zeta", " ```"]); + }); }); describe("Tables", () => { @@ -507,7 +572,7 @@ describe("Markdown component", () => { assert.ok(plainLines.some((line) => line.includes("Test Document"))); // Check list assert.ok(plainLines.some((line) => line.includes("- Item 1"))); - assert.ok(plainLines.some((line) => line.includes(" - Nested item"))); + assert.ok(plainLines.some((line) => line.includes(" - Nested item"))); // Check table assert.ok(plainLines.some((line) => line.includes("Col1"))); assert.ok(plainLines.some((line) => line.includes("│")));