Merge pull request #4327 from Perlence/feat-tui-wrap-list-items-with-indent
feat(tui): wrap list items with indent
This commit is contained in:
@@ -331,6 +331,10 @@ export class Markdown implements Component {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "text":
|
||||||
|
lines.push(this.renderInlineTokens([token], styleContext));
|
||||||
|
break;
|
||||||
|
|
||||||
case "code": {
|
case "code": {
|
||||||
const indent = this.theme.codeBlockIndent ?? " ";
|
const indent = this.theme.codeBlockIndent ?? " ";
|
||||||
lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
|
lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
|
||||||
@@ -354,7 +358,7 @@ export class Markdown implements Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "list": {
|
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);
|
lines.push(...listLines);
|
||||||
// Don't add spacing after lists if a space token follows
|
// Don't add spacing after lists if a space token follows
|
||||||
// (the space token will handle it)
|
// (the space token will handle it)
|
||||||
@@ -362,7 +366,7 @@ export class Markdown implements Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "table": {
|
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);
|
lines.push(...tableLines);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -543,104 +547,39 @@ export class Markdown implements Component {
|
|||||||
/**
|
/**
|
||||||
* Render a list with proper nesting support
|
* Render a list with proper nesting support
|
||||||
*/
|
*/
|
||||||
private renderList(
|
private renderList(token: Tokens.List, depth: number, width: number, styleContext?: InlineStyleContext): string[] {
|
||||||
token: Token & { items: any[]; ordered: boolean; start?: number },
|
|
||||||
depth: number,
|
|
||||||
styleContext?: InlineStyleContext,
|
|
||||||
): string[] {
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
const indent = " ".repeat(depth);
|
const indent = " ".repeat(depth);
|
||||||
// Use the list's start property (defaults to 1 for ordered lists)
|
// 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++) {
|
for (let i = 0; i < token.items.length; i++) {
|
||||||
const item = token.items[i];
|
const item = token.items[i];
|
||||||
const bullet = token.ordered ? `${startNumber + 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
|
for (const itemToken of item.tokens) {
|
||||||
const itemLines = this.renderListItem(item.tokens || [], depth, styleContext);
|
if (itemToken.type === "list") {
|
||||||
|
lines.push(...this.renderList(itemToken as Tokens.List, depth + 1, width, styleContext));
|
||||||
if (itemLines.length > 0) {
|
renderedAnyLine = true;
|
||||||
// First line - check if it's a nested list
|
continue;
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rest of the lines
|
const itemLines = this.renderToken(itemToken, itemWidth, undefined, styleContext);
|
||||||
for (let j = 1; j < itemLines.length; j++) {
|
for (const line of itemLines) {
|
||||||
const line = itemLines[j];
|
for (const wrappedLine of wrapTextWithAnsi(line, itemWidth)) {
|
||||||
const isNestedListLine = /^\s+\x1b\[36m[-\d]/.test(line); // starts with spaces + cyan + bullet char
|
const linePrefix = renderedAnyLine ? continuationPrefix : firstPrefix;
|
||||||
|
lines.push(linePrefix + wrappedLine);
|
||||||
if (isNestedListLine) {
|
renderedAnyLine = true;
|
||||||
// Nested list line - already has full indent
|
|
||||||
lines.push(line);
|
|
||||||
} else {
|
|
||||||
// Regular content - add parent indent + 2 spaces for continuation
|
|
||||||
lines.push(`${indent} ${line}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
lines.push(indent + this.theme.listBullet(bullet));
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return lines;
|
if (!renderedAnyLine) {
|
||||||
}
|
lines.push(firstPrefix);
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,7 +616,7 @@ export class Markdown implements Component {
|
|||||||
* Cells that don't fit are wrapped to multiple lines.
|
* Cells that don't fit are wrapped to multiple lines.
|
||||||
*/
|
*/
|
||||||
private renderTable(
|
private renderTable(
|
||||||
token: Token & { header: any[]; rows: any[][]; raw?: string },
|
token: Tokens.Table,
|
||||||
availableWidth: number,
|
availableWidth: number,
|
||||||
nextTokenType?: string,
|
nextTokenType?: string,
|
||||||
styleContext?: InlineStyleContext,
|
styleContext?: InlineStyleContext,
|
||||||
|
|||||||
@@ -31,8 +31,12 @@ function getCellUnderline(terminal: VirtualTerminal, row: number, col: number):
|
|||||||
return cell.isUnderline();
|
return cell.isUnderline();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripAnsi(line: string): string {
|
||||||
|
return line.replace(/\x1b\[[0-9;]*m/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
describe("Markdown component", () => {
|
describe("Markdown component", () => {
|
||||||
describe("Nested lists", () => {
|
describe("Lists", () => {
|
||||||
it("should render simple nested list", () => {
|
it("should render simple nested list", () => {
|
||||||
const markdown = new Markdown(
|
const markdown = new Markdown(
|
||||||
`- Item 1
|
`- Item 1
|
||||||
@@ -54,8 +58,8 @@ describe("Markdown component", () => {
|
|||||||
|
|
||||||
// Check structure
|
// Check structure
|
||||||
assert.ok(plainLines.some((line) => line.includes("- Item 1")));
|
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.1")));
|
||||||
assert.ok(plainLines.some((line) => line.includes(" - Nested 1.2")));
|
assert.ok(plainLines.some((line) => line.includes(" - Nested 1.2")));
|
||||||
assert.ok(plainLines.some((line) => line.includes("- Item 2")));
|
assert.ok(plainLines.some((line) => line.includes("- Item 2")));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,9 +79,9 @@ describe("Markdown component", () => {
|
|||||||
|
|
||||||
// Check proper indentation
|
// Check proper indentation
|
||||||
assert.ok(plainLines.some((line) => line.includes("- Level 1")));
|
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 2")));
|
||||||
assert.ok(plainLines.some((line) => line.includes(" - Level 3")));
|
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 4")));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render ordered nested list", () => {
|
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, ""));
|
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. First")));
|
||||||
assert.ok(plainLines.some((line) => line.includes(" 1. Nested 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(" 2. Nested second")));
|
||||||
assert.ok(plainLines.some((line) => line.includes("2. 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, ""));
|
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("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")));
|
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[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]}`);
|
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", () => {
|
describe("Tables", () => {
|
||||||
@@ -507,7 +572,7 @@ describe("Markdown component", () => {
|
|||||||
assert.ok(plainLines.some((line) => line.includes("Test Document")));
|
assert.ok(plainLines.some((line) => line.includes("Test Document")));
|
||||||
// Check list
|
// Check list
|
||||||
assert.ok(plainLines.some((line) => line.includes("- Item 1")));
|
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
|
// Check table
|
||||||
assert.ok(plainLines.some((line) => line.includes("Col1")));
|
assert.ok(plainLines.some((line) => line.includes("Col1")));
|
||||||
assert.ok(plainLines.some((line) => line.includes("│")));
|
assert.ok(plainLines.some((line) => line.includes("│")));
|
||||||
|
|||||||
Reference in New Issue
Block a user