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