fix: make terminal state diagrams readable

This commit is contained in:
Shawn Koh
2026-08-11 01:00:35 +08:00
parent 45e12e5bb7
commit a42868f550
6 changed files with 100 additions and 21 deletions
+26 -5
View File
@@ -8,7 +8,7 @@
import type { Canvas, DrawingCoord, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types'
import { colorizeLine, DEFAULT_ASCII_THEME } from './ansi'
import { displayWidth, toCells, WIDE_PAD } from '../text-metrics'
import { displayWidth, toCells, LABEL_CELL_PREFIX, OPAQUE_SPACE, WIDE_PAD } from '../text-metrics'
/**
* Create a blank canvas filled with spaces.
@@ -189,7 +189,7 @@ export function isJunctionChar(c: string): boolean {
* letter/digit test misses.
*/
function isLabelChar(c: string): boolean {
return c === WIDE_PAD || displayWidth(c) === 2 || /[\p{L}\p{N}]/u.test(c)
return c === OPAQUE_SPACE || c.startsWith(LABEL_CELL_PREFIX) || c === WIDE_PAD || displayWidth(c) === 2 || /[\p{L}\p{N}]/u.test(c)
}
/**
@@ -327,8 +327,9 @@ export function canvasToString(canvas: Canvas, options?: CanvasToStringOptions):
let line = ''
for (let x = 0; x <= maxX; x++) {
const c = canvas[x]![y]!
// Skip wide-glyph continuation cells: the glyph itself spans 2 columns
if (c !== WIDE_PAD) line += c
if (c !== WIDE_PAD) {
line += c === OPAQUE_SPACE ? ' ' : c.startsWith(LABEL_CELL_PREFIX) ? c.slice(LABEL_CELL_PREFIX.length) : c
}
}
lines.push(line)
} else {
@@ -338,7 +339,7 @@ export function canvasToString(canvas: Canvas, options?: CanvasToStringOptions):
for (let x = 0; x <= maxX; x++) {
const c = canvas[x]![y]!
if (c === WIDE_PAD) continue
chars.push(c)
chars.push(c === OPAQUE_SPACE ? ' ' : c.startsWith(LABEL_CELL_PREFIX) ? c.slice(LABEL_CELL_PREFIX.length) : c)
roles.push(roleCanvas[x]?.[y] ?? null)
}
lines.push(colorizeLine(chars, roles, theme, colorMode))
@@ -443,6 +444,26 @@ export function drawText(
}
}
/**
* Draw edge-label text with ownership markers so later canvas merges preserve
* punctuation and spaces while allowing the label to clear connector strokes.
*/
export function drawLabelText(canvas: Canvas, start: DrawingCoord, text: string): void {
const cells = toCells(text)
increaseSize(canvas, start.x + cells.length, start.y)
for (let i = 0; i < cells.length; i++) {
const cell = cells[i]!
if (cell === WIDE_PAD) continue
const x = start.x + i
if (cells[i + 1] === WIDE_PAD) {
writeCell(canvas, x, start.y, cell)
writeCell(canvas, x + 1, start.y, WIDE_PAD)
} else {
writeCell(canvas, x, start.y, cell === ' ' ? OPAQUE_SPACE : LABEL_CELL_PREFIX + cell)
}
}
}
/**
* Set the canvas size to fit all grid columns and rows.
* Called after layout to ensure the canvas covers the full drawing area.
+13 -11
View File
@@ -14,7 +14,7 @@ import {
Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight, Middle,
drawingCoordEquals,
} from './types'
import { mkCanvas, copyCanvas, getCanvasSize, mergeCanvases, drawText, mkRoleCanvas, setRole, mergeRoleCanvases } from './canvas'
import { mkCanvas, copyCanvas, getCanvasSize, mergeCanvases, drawText, drawLabelText, mkRoleCanvas, setRole, mergeRoleCanvases } from './canvas'
import type { RoleCanvas, CharRole } from './types'
import { determineDirection, dirEquals } from './edge-routing'
import { gridToDrawingCoord, lineToDrawing } from './grid'
@@ -76,16 +76,18 @@ function drawBoxWithGridDimensions(node: AsciiNode, graph: AsciiGraph): Canvas {
// Get corner characters for this shape type
const corners = getCorners(node.shape, useAscii)
// State-end uses double border to differentiate from state-start
const isDoubleBox = node.shape === 'state-end'
const hChar = useAscii ? (isDoubleBox ? '=' : '-') : (isDoubleBox ? '═' : '─')
const vChar = useAscii ? (isDoubleBox ? '‖' : '|') : (isDoubleBox ? '║' : '│')
const isStateStart = node.shape === 'state-start'
const isStateEnd = node.shape === 'state-end'
const hChar = useAscii ? (isStateEnd ? '=' : '-') : (isStateEnd ? '═' : '─')
const vChar = useAscii ? (isStateEnd ? '‖' : '|') : (isStateEnd ? '║' : '│')
// Double-box corners (for state-end)
const doubleCorners = useAscii
const stateStartCorners = useAscii
? { tl: '.', tr: '.', bl: "'", br: "'" }
: { tl: '╭', tr: '╮', bl: '╰', br: '╯' }
const stateEndCorners = useAscii
? { tl: '#', tr: '#', bl: '#', br: '#' }
: { tl: '╔', tr: '╗', bl: '╚', br: '╝' }
const effectiveCorners = isDoubleBox ? doubleCorners : corners
const effectiveCorners = isStateEnd ? stateEndCorners : isStateStart ? stateStartCorners : corners
// Draw box border with shape-specific corners
for (let x = from.x + 1; x < to.x; x++) box[x]![from.y] = hChar
@@ -97,8 +99,8 @@ function drawBoxWithGridDimensions(node: AsciiNode, graph: AsciiGraph): Canvas {
box[from.x]![to.y] = effectiveCorners.bl
box[to.x]![to.y] = effectiveCorners.br
// Center the multi-line display label inside the box
const label = node.displayLabel
// Pseudostates have no source label; restore their UML marker explicitly.
const label = node.displayLabel || (isStateStart ? (useAscii ? '*' : '●') : isStateEnd ? (useAscii ? '*' : '◎') : '')
const lines = splitLines(label)
const textCenterY = from.y + Math.floor(h / 2)
const startY = textCenterY - Math.floor((lines.length - 1) / 2)
@@ -677,7 +679,7 @@ function drawTextOnLine(canvas: Canvas, line: DrawingCoord[], label: string, isU
for (let i = 0; i < lines.length; i++) {
const lineText = lines[i]!
const startX = middleX - Math.floor(displayWidth(lineText) / 2)
drawText(canvas, { x: startX, y: startY + i }, lineText)
drawLabelText(canvas, { x: startX, y: startY + i }, lineText)
}
}
+11
View File
@@ -29,6 +29,17 @@
*/
export const WIDE_PAD = '\u0000'
/**
* Space owned by a text label rather than unused canvas. Merge layers treat
* ordinary spaces as transparent, so labels use this sentinel to clear edge
* lines between words. Serialization converts it back to a regular space.
*/
export const OPAQUE_SPACE = '\u0001'
/** Prefix marking a one-column cell as label-owned through canvas merges. */
export const LABEL_CELL_PREFIX = '\u0002'
const graphemeSegmenter = new Intl.Segmenter()
/**
+25
View File
@@ -13,6 +13,31 @@ describe("renderMermaidAscii", () => {
expect(rendered).not.toContain("──A─");
});
it("renders state pseudostates with their UML markers", () => {
const rendered = renderMermaidAscii(["stateDiagram-v2", " [*] --> Created", " Created --> [*]"].join("\n"), {
colorMode: "none",
});
expect(rendered).toMatch(/│\s+●\s+│/);
expect(rendered).toMatch(/║\s+◎\s+║/);
});
it("keeps dense transition labels intact above connector lines", () => {
const rendered = renderMermaidAscii(
[
"stateDiagram-v2",
" Working --> Working: sessions die and respawn freely",
" Working --> Archived: cheap exit, branches kept",
" Archived --> Working: resume rebuilds substrate",
].join("\n"),
{ colorMode: "none" },
);
expect(rendered).toContain("sessions die and respawn freely");
expect(rendered).toContain("cheap exit, branches kept");
expect(rendered).toContain("resume rebuilds substrate");
});
it("returns a bounded fallback for declaration orders that make a clean route unreachable", () => {
const rendered = renderMermaidAsciiSafe(
[