Skip to content

Commit 674da47

Browse files
return xterm compatible colors, use runewidth
1 parent 2562f88 commit 674da47

6 files changed

Lines changed: 94 additions & 83 deletions

File tree

cmd/draw.go

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"strings"
66

77
"github.com/gookit/color"
8+
"github.com/mattn/go-runewidth"
89
log "github.com/sirupsen/logrus"
910
)
1011

@@ -48,10 +49,17 @@ func (g *graph) drawEdge(e *edge) (*drawing, *drawing, *drawing, *drawing, *draw
4849

4950
func (d *drawing) drawText(start drawingCoord, text string) {
5051
// Increase dimensions if necessary.
51-
d.increaseSize(start.x+len(text), start.y)
52-
log.Debug("Drawing '", text, "' from ", start, " to ", drawingCoord{x: start.x + len(text), y: start.y})
53-
for x := 0; x < len(text); x++ {
54-
(*d)[x+start.x][start.y] = string(text[x])
52+
textWidth := runewidth.StringWidth(text)
53+
d.increaseSize(start.x+textWidth, start.y)
54+
log.Debug("Drawing '", text, "' from ", start, " to ", drawingCoord{x: start.x + textWidth, y: start.y})
55+
pos := 0
56+
for _, char := range text {
57+
(*d)[start.x+pos][start.y] = string(char)
58+
charWidth := runewidth.RuneWidth(char)
59+
for i := 1; i < charWidth; i++ {
60+
(*d)[start.x+pos+i][start.y] = ""
61+
}
62+
pos += charWidth
5563
}
5664
}
5765

@@ -228,10 +236,17 @@ func drawBox(n *node, g graph) *drawing {
228236
boxDrawing[to.x][to.y] = "+" // Bottom right corner
229237
}
230238
// Draw text
239+
nameWidth := runewidth.StringWidth(n.name)
231240
textY := from.y + h/2
232-
textX := from.x + w/2 - CeilDiv(len(n.name), 2) + 1
233-
for x := 0; x < len(n.name); x++ {
234-
boxDrawing[textX+x][textY] = wrapTextInColor(string(n.name[x]), n.styleClass.styles["color"], g.styleType)
241+
textX := from.x + w/2 - CeilDiv(nameWidth, 2) + 1
242+
pos := 0
243+
for _, char := range n.name {
244+
boxDrawing[textX+pos][textY] = wrapTextInColor(string(char), n.styleClass.styles["color"], g.styleType)
245+
charWidth := runewidth.RuneWidth(char)
246+
for i := 1; i < charWidth; i++ {
247+
boxDrawing[textX+pos+i][textY] = ""
248+
}
249+
pos += charWidth
235250
}
236251

237252
return &boxDrawing
@@ -317,15 +332,24 @@ func drawSubgraphLabel(sg *subgraph, g graph) (*drawing, drawingCoord) {
317332
labelDrawing := *(mkDrawing(width, height))
318333

319334
// Draw label centered at top
335+
nameWidth := runewidth.StringWidth(sg.name)
320336
labelY := from.y + 1
321-
labelX := from.x + width/2 - len(sg.name)/2
337+
labelX := from.x + width/2 - nameWidth/2
322338
if labelX < from.x+1 {
323339
labelX = from.x + 1
324340
}
325-
for i, char := range sg.name {
326-
if labelX+i < to.x {
327-
labelDrawing[labelX+i][labelY] = string(char)
341+
pos := 0
342+
for _, char := range sg.name {
343+
if labelX+pos < to.x {
344+
labelDrawing[labelX+pos][labelY] = string(char)
345+
charWidth := runewidth.RuneWidth(char)
346+
for i := 1; i < charWidth; i++ {
347+
if labelX+pos+i < to.x {
348+
labelDrawing[labelX+pos+i][labelY] = ""
349+
}
350+
}
328351
}
352+
pos += runewidth.RuneWidth(char)
329353
}
330354

331355
// Return label drawing and its offset position
@@ -337,12 +361,20 @@ func wrapTextInColor(text, c, styleType string) string {
337361
if c == "" {
338362
return text
339363
}
340-
if styleType == "html" {
364+
switch styleType {
365+
case "html":
341366
return fmt.Sprintf("<span style='color: %s'>%s</span>", c, text)
342-
} else if styleType == "cli" {
367+
case "xterm":
368+
var r, g, b int
369+
if _, err := fmt.Sscanf(c, "#%02x%02x%02x", &r, &g, &b); err != nil {
370+
log.Warnf("Invalid xterm color %s", c)
371+
return text
372+
}
373+
return fmt.Sprintf("\x1b[38;2;%d;%d;%dm%s\x1b[39m", r, g, b, text)
374+
case "cli":
343375
cliColor := color.HEX(c)
344376
return cliColor.Sprint(text)
345-
} else {
377+
default:
346378
log.Warnf("Unknown style type %s", styleType)
347379
return text
348380
}

cmd/graph_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,38 @@ A --> B`
113113
}
114114
}
115115

116+
func TestWrapTextInColorXterm(t *testing.T) {
117+
got := wrapTextInColor("X", "#ff00ff", "xterm")
118+
want := "\x1b[38;2;255;0;255mX\x1b[39m"
119+
if got != want {
120+
t.Fatalf("unexpected xterm color output: got %q want %q", got, want)
121+
}
122+
}
123+
124+
func TestUnicodeNodeWidth(t *testing.T) {
125+
mermaidInput := `graph TD
126+
감각 --> 사고
127+
사고 --> 행동`
128+
129+
config := &diagram.Config{
130+
UseAscii: false,
131+
BoxBorderPadding: 1,
132+
PaddingBetweenX: 5,
133+
PaddingBetweenY: 5,
134+
GraphDirection: "TD",
135+
StyleType: "cli",
136+
}
137+
138+
output, err := RenderDiagram(mermaidInput, config)
139+
if err != nil {
140+
t.Fatalf("Failed to render unicode graph: %v", err)
141+
}
142+
143+
for _, label := range []string{"감각", "사고", "행동"} {
144+
if !strings.Contains(output, label) {
145+
t.Fatalf("expected output to contain %q, got:\n%s", label, output)
146+
}
147+
}
148+
}
149+
116150
// Sequence diagram tests moved to sequence_test.go

cmd/mapping_node.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"github.com/mattn/go-runewidth"
45
log "github.com/sirupsen/logrus"
56
)
67

@@ -36,7 +37,7 @@ func (g *graph) setColumnWidth(n *node) {
3637
// - 2x padding
3738
// - 2x margin
3839
col1 := 1
39-
col2 := 2*boxBorderPadding + len(n.name)
40+
col2 := 2*boxBorderPadding + runewidth.StringWidth(n.name)
4041
col3 := 1
4142
colsToBePlaced := []int{col1, col2, col3}
4243
rowsToBePlaced := []int{1, 1 + 2*boxBorderPadding, 1} // Border, padding + line, border

pkg/diagram/config.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@ type Config struct {
2828
// GraphDirection is the direction of graph layout ("LR" or "TD")
2929
GraphDirection string
3030

31-
// StyleType determines output format for graph diagrams ("cli" or "html")
32-
// This controls whether graphs use colored output (html) or plain text (cli)
31+
// StyleType determines output format for graph diagrams ("cli", "html", or "xterm")
3332
StyleType string
3433

3534
// --- Sequence diagram-specific configuration ---
@@ -122,7 +121,7 @@ func NewWebConfig(useAscii bool, boxBorderPadding, paddingX, paddingY int) (*Con
122121
PaddingBetweenX: paddingX,
123122
PaddingBetweenY: paddingY,
124123
GraphDirection: "LR",
125-
StyleType: "html",
124+
StyleType: "xterm",
126125
SequenceParticipantSpacing: defaults.SequenceParticipantSpacing,
127126
SequenceMessageSpacing: defaults.SequenceMessageSpacing,
128127
SequenceSelfMessageWidth: defaults.SequenceSelfMessageWidth,
@@ -136,7 +135,7 @@ func NewWebConfig(useAscii bool, boxBorderPadding, paddingX, paddingY int) (*Con
136135
}
137136

138137
// NewTestConfig creates a Config for testing with sensible defaults.
139-
// The styleType parameter determines output format ("cli" or "html").
138+
// The styleType parameter determines output format ("cli", "html", or "xterm").
140139
func NewTestConfig(useAscii bool, styleType string) *Config {
141140
config := DefaultConfig()
142141
config.UseAscii = useAscii
@@ -160,8 +159,8 @@ func (c *Config) Validate() error {
160159
if c.GraphDirection != "LR" && c.GraphDirection != "TD" {
161160
return &ConfigError{Field: "GraphDirection", Value: c.GraphDirection, Message: "must be \"LR\" or \"TD\""}
162161
}
163-
if c.StyleType != "cli" && c.StyleType != "html" {
164-
return &ConfigError{Field: "StyleType", Value: c.StyleType, Message: "must be \"cli\" or \"html\""}
162+
if c.StyleType != "cli" && c.StyleType != "html" && c.StyleType != "xterm" {
163+
return &ConfigError{Field: "StyleType", Value: c.StyleType, Message: "must be \"cli\", \"html\", or \"xterm\""}
165164
}
166165

167166
// Validate sequence diagram configuration

pkg/diagram/testutil/testutil.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ func ReadTestCase(filePath string) (*TestCase, error) {
3535
if err != nil {
3636
return nil, err
3737
}
38-
defer file.Close()
38+
defer func() {
39+
_ = file.Close()
40+
}()
3941

4042
scanner := bufio.NewScanner(file)
4143
var mermaid, expected strings.Builder

static/script.js

Lines changed: 4 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -37,64 +37,8 @@ let term;
3737
let fitAddon;
3838
let lastRenderedContent = '';
3939

40-
function escapeAnsi(text) {
41-
return text.replace(/\u001b/g, '');
42-
}
43-
44-
function rgbToAnsi(color) {
45-
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
46-
if (!match) return '';
47-
return `\u001b[38;2;${match[1]};${match[2]};${match[3]}m`;
48-
}
49-
50-
function htmlToTerminalContent(content) {
51-
if (!content.includes('<')) {
52-
return {
53-
ansi: content,
54-
plainText: content,
55-
};
56-
}
57-
58-
const root = document.createElement('div');
59-
root.innerHTML = content;
60-
let ansi = '';
61-
let plainText = '';
62-
63-
function walk(node, activeColor = '') {
64-
if (node.nodeType === Node.TEXT_NODE) {
65-
const text = node.textContent || '';
66-
ansi += activeColor + text;
67-
plainText += text;
68-
return;
69-
}
70-
71-
if (node.nodeType !== Node.ELEMENT_NODE) {
72-
return;
73-
}
74-
75-
const element = node;
76-
const nextColor = element.style?.color ? rgbToAnsi(element.style.color) : activeColor;
77-
78-
for (const child of element.childNodes) {
79-
walk(child, nextColor);
80-
}
81-
82-
if (element.style?.color && nextColor) {
83-
ansi += '\u001b[39m';
84-
if (activeColor) {
85-
ansi += activeColor;
86-
}
87-
}
88-
}
89-
90-
for (const child of root.childNodes) {
91-
walk(child);
92-
}
93-
94-
return {
95-
ansi,
96-
plainText: escapeAnsi(plainText),
97-
};
40+
function stripAnsi(text) {
41+
return text.replace(/\u001b\[[0-9;]*m/g, '');
9842
}
9943

10044
// Copy terminal content
@@ -123,11 +67,10 @@ function renderTerminal(content) {
12367
if (!term) return;
12468

12569
const normalizedContent = content.replace(/\r\n/g, '\n');
126-
const rendered = htmlToTerminalContent(normalizedContent);
127-
lastRenderedContent = rendered.plainText;
70+
lastRenderedContent = stripAnsi(normalizedContent);
12871
term.reset();
12972
fitTerminal();
130-
term.write(rendered.ansi.replace(/\n/g, '\r\n'));
73+
term.write(normalizedContent.replace(/\n/g, '\r\n'));
13174
term.scrollToTop();
13275
}
13376

0 commit comments

Comments
 (0)