nandi/freeqsay-nimpublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/nandi/freeqsay-nim.git
git clone ssh://git@rickub.com/nandi/freeqsay-nim.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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