1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
|
## Cowsay-style speech / thought balloons.
##
## The balloon measures in visible columns, not bytes: escape sequences cost
## nothing and are never split, so ANSI art fed through a balloon keeps its
## shape — `freeqsay a … | freeqsay b` puts one character in the other's mouth.
import std/[strutils, sequtils, unicode]
const
ESC = '\x1b'
RESET = "\x1b[0m"
type Token* = tuple[esc: bool, text: string]
func finalByte(c: char): bool =
## Last byte of a CSI sequence: @ through ~.
0x40 <= int(c) and int(c) <= 0x7e
func escapeEnd*(s: string, i: int): int =
## Index just past the escape sequence starting at `i`, or -1 if `i` is not one.
if i >= s.len or s[i] != ESC:
return -1
let n = s.len
let c = if i + 1 < n: s[i + 1] else: '\0'
case c
of '[':
# CSI: ESC [ params… final
var j = i + 2
while j < n:
if finalByte(s[j]): return j + 1
inc j
n
of ']':
# OSC: ESC ] … BEL or ESC \
var j = i + 2
while j < n:
if s[j] == '\a': return j + 1
if s[j] == ESC and j + 1 < n and s[j + 1] == '\\': return j + 2
inc j
n
else:
# two-character escape
min(n, i + 2)
func tokens*(s: string): seq[Token] =
## Split a string into printable characters and whole escape sequences.
var i = 0
while i < s.len:
let e = escapeEnd(s, i)
if e >= 0:
result.add (true, s[i ..< e])
i = e
else:
let r = runeLenAt(s, i)
result.add (false, s[i ..< i + r])
i += r
func visibleWidth*(s: string): int =
## Width of `s` in terminal columns — escape sequences are free.
for t in tokens(s):
if not t.esc: inc result
func hasEscape(s: string): bool =
for t in tokens(s):
if t.esc: return true
func detok(toks: openArray[Token]): string =
for t in toks: result.add t.text
func visCount(toks: openArray[Token]): int =
for t in toks:
if not t.esc: inc result
func isBlank(t: Token): bool =
not t.esc and t.text.strip().len == 0
func splitWords(toks: seq[Token]): seq[seq[Token]] =
## Split a token seq on visible whitespace, dropping the whitespace.
var cur: seq[Token]
for t in toks:
if isBlank(t):
if cur.len > 0:
result.add cur
cur = @[]
else:
cur.add t
if cur.len > 0:
result.add cur
func chunks(toks: seq[Token], width: int): seq[seq[Token]] =
## Split a word into token runs of at most `width` visible columns.
var i = 0
while i < toks.len:
var
taken: seq[Token]
n = 0
while i < toks.len:
if toks[i].esc:
taken.add toks[i]
inc i
elif n == width:
break
else:
taken.add toks[i]
inc i
inc n
result.add taken
func wrapText*(text: string, width: int): seq[string] =
## Wrap text to `width` visible columns.
##
## A line that already fits is kept exactly as it is — leading indentation,
## runs of spaces and escape sequences included — so pixel art survives.
## Longer lines are reflowed on word boundaries, hard-breaking overlong tokens.
let normalized = text.replace("\r\n", "\n").replace("\t", " ")
for para in normalized.split('\n'):
if visibleWidth(para) <= width:
result.add para
else:
var line = ""
for word in splitWords(tokens(para)):
if visCount(word) > width:
if line.len > 0:
result.add line
for c in chunks(word, width):
result.add detok(c)
line = ""
else:
let w = detok(word)
if line.len == 0:
line = w
elif visibleWidth(line) + 1 + visCount(word) <= width:
line = line & " " & w
else:
result.add line
line = w
if line.len > 0:
result.add line
if result.len == 0:
result = @[""]
func fit(s: string, n: int): string =
## Pad `s` out to `n` visible columns, resetting color first so the balloon's
## own border never inherits the content's background.
s & (if hasEscape(s): RESET else: "") & " ".repeat(max(0, n - visibleWidth(s)))
func makeBalloon*(text: string, width = 40, think = false): string =
## Classic cowsay balloon around `text`.
let
width = max(1, width)
lines = wrapText(text, width)
mx = max(1, lines.mapIt(visibleWidth(it)).max)
top = " " & "_".repeat(mx + 2)
bot = " " & "-".repeat(mx + 2)
n = lines.len
var body: seq[string]
if n == 1:
let p = fit(lines[0], mx)
body.add (if think: "( " & p & " )" else: "< " & p & " >")
else:
for i, l in lines:
let p = fit(l, mx)
body.add:
if think: "( " & p & " )"
elif i == 0: "/ " & p & " \\"
elif i == n - 1: "\\ " & p & " /"
else: "| " & p & " |"
(@[top] & body & @[bot]).join("\n")
func makeTail*(think = false, indent = 8): string =
## Speech/thought tail that sits under the balloon and points toward the face.
## Indent is chosen so the tail sits near the left of a typical sprite.
let pad = " ".repeat(indent)
if think:
pad & "o\n" & pad & " o"
else:
pad & "\\\n" & " ".repeat(indent + 1) & "\\"
|