fix: make terminal state diagrams readable
This commit is contained in:
+26
-5
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user