## 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) & "\\"