| freeqsay: Nim port of the Clojure implementation fa53aa2 nandi 6h ago | 1 | ## 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 | |
| 7 | import std/[strutils, sequtils, unicode] |
| 8 | |
| 9 | const |
| 10 | ESC = '\x1b' |
| 11 | RESET = "\x1b[0m" |
| 12 | |
| 13 | type Token* = tuple[esc: bool, text: string] |
| 14 | |
| 15 | func finalByte(c: char): bool = |
| 16 | ## Last byte of a CSI sequence: @ through ~. |
| 17 | 0x40 <= int(c) and int(c) <= 0x7e |
| 18 | |
| 19 | func 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 | |
| 45 | func 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 | |
| 58 | func 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 | |
| 63 | func hasEscape(s: string): bool = |
| 64 | for t in tokens(s): |
| 65 | if t.esc: return true |
| 66 | |
| 67 | func detok(toks: openArray[Token]): string = |
| 68 | for t in toks: result.add t.text |
| 69 | |
| 70 | func visCount(toks: openArray[Token]): int = |
| 71 | for t in toks: |
| 72 | if not t.esc: inc result |
| 73 | |
| 74 | func isBlank(t: Token): bool = |
| 75 | not t.esc and t.text.strip().len == 0 |
| 76 | |
| 77 | func 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 | |
| 90 | func 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 | |
| 109 | func 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 | |
| 142 | func 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 | |
| 147 | func 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 | |
| 170 | func 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) & "\\" |