start hwyl with bbansi and spinny

This commit is contained in:
Daylin Morgan 2024-09-17 17:28:21 -05:00
parent ec00bafad6
commit a9ec2e738e
Signed by: daylin
GPG key ID: 950D13E9719334AD
12 changed files with 572 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
tests/*
!tests/*.nim
!tests/*.nims

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# hwylterm
Adding some fun to the terminal!

2
config.nims Normal file
View file

@ -0,0 +1,2 @@
task test, "run tests":
selfExec "r tests/tbbansi.nim"

12
hwylterm.nimble Normal file
View file

@ -0,0 +1,12 @@
# Package
version = "0.1.0"
author = "Daylin Morgan"
description = "bringing some fun (hwyl) to the terminal"
license = "MIT"
srcDir = "src"
# Dependencies
requires "nim >= 2.0.8"

2
src/hwylterm.nim Normal file
View file

@ -0,0 +1,2 @@
import hwylterm/[spin, bbansi]
export spin, bbansi

276
src/hwylterm/bbansi.nim Normal file
View file

@ -0,0 +1,276 @@
##[
## bbansi
use BB style markup to add color to strings using VT100 escape codes
]##
import std/[os, sequtils, strutils, terminal]
import bbansi/[styles, utils]
# TODO:
# - improve terminal/output detection (is output a tty?)
# - add compiletimeSwitch to disable color?
# - add env variable to force color
proc checkColorSupport(): bool =
if os.getEnv("NO_COLOR") != "":
return true
when defined(bbansiNoColor):
return true
if not isatty(stdout):
return true
let noColor = checkColorSupport()
type
BbSpan* = object
styles*: seq[string]
slice*: array[2, int]
BbString* = object
raw*: string
plain*: string
spans*: seq[BbSpan]
proc len(span: BbSpan): int =
span.slice[1] - span.slice[0]
template endSpan(bbs: var BbString) =
if bbs.spans.len == 0:
return
if bbs.plain.len >= 1:
bbs.spans[^1].slice[1] = bbs.plain.len - 1
if bbs.spans[^1].len == 0 and bbs.plain.len == 0:
bbs.spans.delete(bbs.spans.len - 1)
proc newSpan(bbs: var BbString, styles: seq[string] = @[]) =
bbs.spans.add BbSpan(styles: styles, slice: [bbs.plain.len, 0])
template resetSpan(bbs: var BbString) =
bbs.endSpan
bbs.newSpan
template closeLastStyle(bbs: var BbString) =
bbs.endSpan
let newStyle = bbs.spans[^1].styles[0 ..^ 2] # drop the latest style
bbs.newSpan newStyle
template addToSpan(bbs: var BbString, pattern: string) =
let currStyl = bbs.spans[^1].styles
bbs.endSpan
bbs.newSpan currStyl & @[pattern]
template closeStyle(bbs: var BbString, pattern: string) =
let style = pattern[1 ..^ 1].strip()
if style in bbs.spans[^1].styles:
bbs.endSpan
let newStyle = bbs.spans[^1].styles.filterIt(it != style) # use sets instead
bbs.newSpan newStyle
template closeFinalSpan(bbs: var BbString) =
if bbs.spans.len >= 1 and bbs.spans[^1].slice[1] == 0:
bbs.endSpan
proc bb*(s: string): BbString =
## convert bbcode markup to ansi escape codes
var
pattern: string
i = 0
template next() =
result.plain.add s[i]
inc i
template incPattern() =
pattern.add s[i]
inc i
template resetPattern() =
pattern = ""
inc i
result.raw = s
if not s.startswith('[') or s.startswith("[["):
result.spans.add BbSpan()
while i < s.len:
case s[i]
of '\\':
if i < s.len and s[i + 1] == '[':
inc i
next
of '[':
if i < s.len and s[i + 1] == '[':
inc i
next
continue
inc i
while i < s.len and s[i] != ']':
incPattern
pattern = pattern.strip()
if result.spans.len > 0:
if pattern == "/":
result.closeLastStyle
elif pattern == "reset":
result.resetSpan
elif pattern.startswith('/'):
result.closeStyle pattern
else:
result.addToSpan pattern
else:
result.newSpan @[pattern]
resetPattern
else:
next
result.closeFinalSpan
proc bb*(s: string, style: string): BbString =
bb("[" & style & "]" & s & "[/" & style & "]")
proc `&`*(x: BbString, y: string): BbString =
result = x
result.raw &= y
result.plain &= y
result.spans[^1].slice[1] = result.plain.len - 1
proc `&`*(x: string, y: BbString): BbString =
result.raw = x & y.raw
result.plain = x & y.plain
result.spans.add BbSpan(styles: @[], slice: [0, x.len - 1])
for span in y.spans:
let
length = x.len
styles = span.styles
slice = span.slice
result.spans.add BbSpan(
styles: styles, slice: [slice[0] + length, slice[1] + length]
)
func len*(bbs: BbString): int =
bbs.plain.len
proc `$`*(bbs: BbString): string =
if noColor:
return bbs.plain
for span in bbs.spans:
var codes = ""
if span.styles.len > 0:
codes = span.styles.join(" ").toAnsiCode
result.add codes
result.add bbs.plain[span.slice[0] .. span.slice[1]]
if codes != "":
result.add bbReset
proc `&`*(x: BbString, y: BbString): Bbstring =
# there is probably a more efficient way to do this
bb(x.raw & y.raw)
proc bbEcho*(args: varargs[string, `$`]) {.sideEffect.} =
for x in args:
stdout.write(x.bb)
stdout.write('\n')
stdout.flushFile
when isMainModule:
import std/[parseopt, strformat, sugar]
const version = staticExec "git describe --tags --always --dirty=-dev"
const longOptPad = 8
proc writeHelp() =
let help =
fmt"""
[bold]bbansi[/] \[[green]args...[/]] [[[faint]-h|-v[/]]
[italic]usage[/]:
bbansi "[[yellow] yellow text!"
|-> [yellow] yellow text![/]
bbansi "[[bold red] bold red text[[/] plain text..."
|-> [bold red] bold red text[/] plain text...
bbansi "[[red]some red[[/red] but all italic" --style:italic
|-> [italic][red]some red[/red] but all italic[/italic]
flags:
""".bb &
$(
bb(
collect(
for (s, l, d) in [
("h", "help", "show this help"),
("v", "version", "show version"),
("s", "style", "set style for string"),
]:
fmt"[yellow]-{s}[/] [green]--{l.alignLeft(longOptPad)}[/] {d}"
)
.join("\n ")
)
)
echo help
quit(QuitSuccess)
proc testCard() =
for style in [
"bold", "faint", "italic", "underline", "blink", "reverse", "conceal", "strike"
]:
echo style, " -> ", fmt"[{style}]****".bb
const colors =
["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]
for color in colors:
echo color, " -> ", fmt"[{color}]****".bb
for color in colors:
echo "on ", color, " -> ", fmt"[on {color}]****".bb
proc debug(bbs: BbString): string =
echo "bbString("
echo " raw: ", bbs.raw
echo " plain: ", bbs.plain
echo " spans: ", bbs.spans
echo " escaped: ", escape($bbs)
echo ")"
proc writeVersion() =
echo fmt"[yellow]bbansi version[/][red] ->[/] [bold]{version}[/]".bb
quit(QuitSuccess)
var
strArgs: seq[string]
style: string
showDebug: bool
var p = initOptParser()
for kind, key, val in p.getopt():
case kind
of cmdEnd:
break
of cmdShortOption, cmdLongOption:
case key
of "help", "h":
writeHelp()
of "version", "v":
writeVersion()
of "testCard":
testCard()
quit(QuitSuccess)
of "style", "s":
if val == "":
echo "[red]ERROR[/]: expected value for -s/--style".bb
quit(QuitFailure)
style = val
of "debug":
showDebug = true
else:
echo bb"[yellow]warning[/]: unexpected option/value -> ", key, ", ", val
of cmdArgument:
strArgs.add key
if strArgs.len == 0:
writeHelp()
for arg in strArgs:
let styled =
if style != "":
arg.bb(style)
else:
arg.bb
echo styled
if showDebug:
echo debug(styled)

View file

@ -0,0 +1,29 @@
import std/tables
export tables
const
bbReset* = "\e[0m"
bbStyles* = {
"bold": "1",
"b": "1",
"faint": "2",
"italic": "3",
"i": "3",
"underline": "4",
"u": "4",
"blink": "5",
"reverse": "7",
"conceal": "8",
"strike": "9",
}.toTable
bbColors* = {
"black": "0",
"red": "1",
"green": "2",
"yellow": "3",
"blue": "4",
"magenta": "5",
"cyan": "6",
"white": "7",
}.toTable

View file

@ -0,0 +1,27 @@
import std/[strutils]
import styles
proc toAnsiCode*(s: string): string =
var
codes: seq[string]
styles: seq[string]
bgStyle: string
if " on " in s or s.startswith("on"):
let fgBgSplit = s.rsplit("on", maxsplit = 1)
styles = fgBgSplit[0].toLowerAscii().splitWhitespace()
bgStyle = fgBgSplit[1].strip().toLowerAscii()
else:
styles = s.splitWhitespace()
for style in styles:
if style in bbStyles:
codes.add bbStyles[style]
elif style in bbColors:
codes.add "3" & bbColors[style]
if bgStyle in bbColors:
codes.add "4" & bbColors[bgStyle]
if codes.len > 0:
result.add "\e["
result.add codes.join ";"
result.add "m"

138
src/hwylterm/spin.nim Normal file
View file

@ -0,0 +1,138 @@
import std/[os, locks, sequtils, terminal]
import "."/bbansi
type
SpinnerKind* = enum
Dots
Spinner* = object
interval*: int
frames*: seq[string]
proc makeSpinner*(interval: int, frames: seq[string]): Spinner =
Spinner(interval: interval, frames: frames)
const Spinners*: array[SpinnerKind, Spinner] = [
# Dots
Spinner(
interval: 80,
frames: @["", "", "", "", "", "", "", "", "", ""],
)
]
type
Spinny = ref object
t: Thread[Spinny]
lock: Lock
text: string
running: bool
frames: seq[string]
frame: string
interval: int
customSymbol: bool
style: string
EventKind = enum
Stop
SymbolChange
TextChange
SpinnyEvent = object
kind: EventKind
payload: string
var spinnyChannel: Channel[SpinnyEvent]
proc newSpinny*(text: string, s: Spinner): Spinny =
let style = "bold blue"
Spinny(
text: text,
running: true,
frames: mapIt(s.frames, $bb(it, style)),
customSymbol: false,
interval: s.interval,
style: "bold blue",
)
proc newSpinny*(text: string, spinType: SpinnerKind): Spinny =
newSpinny(text, Spinners[spinType])
proc setSymbolColor*(spinny: Spinny, style: string) =
spinny.frames = mapIt(spinny.frames, $bb(it, style))
proc setSymbol*(spinny: Spinny, symbol: string) =
spinnyChannel.send(SpinnyEvent(kind: SymbolChange, payload: symbol))
proc setText*(spinny: Spinny, text: string) =
spinnyChannel.send(SpinnyEvent(kind: TextChange, payload: text))
proc handleEvent(spinny: Spinny, eventData: SpinnyEvent): bool =
result = true
case eventData.kind
of Stop:
result = false
of SymbolChange:
spinny.customSymbol = true
spinny.frame = eventData.payload
of TextChange:
spinny.text = eventData.payload
proc spinnyLoop(spinny: Spinny) {.thread.} =
var frameCounter = 0
while spinny.running:
let data = spinnyChannel.tryRecv()
if data.dataAvailable:
# If we received a Stop event
if not spinny.handleEvent(data.msg):
spinnyChannel.close()
# This is required so we can reopen the same channel more than once
# See https://github.com/nim-lang/Nim/issues/6369
spinnyChannel = default(typeof(spinnyChannel))
# TODO: Do we need spinny.running at all?
spinny.running = false
break
stdout.flushFile()
if not spinny.customSymbol:
spinny.frame = spinny.frames[frameCounter]
withLock spinny.lock:
eraseLine()
stdout.write(spinny.frame & " " & spinny.text)
stdout.flushFile()
sleep spinny.interval
if frameCounter >= spinny.frames.len - 1:
frameCounter = 0
else:
frameCounter += 1
proc start*(spinny: Spinny) =
initLock spinny.lock
spinnyChannel.open()
createThread(spinny.t, spinnyLoop, spinny)
proc stop(spinny: Spinny, kind: EventKind, payload = "") =
spinnyChannel.send(SpinnyEvent(kind: kind, payload: payload))
spinnyChannel.send(SpinnyEvent(kind: Stop))
joinThread spinny.t
eraseLine stdout
flushFile stdout
proc stop*(spinny: Spinny) =
spinny.stop(Stop)
template withSpinner*(msg: string = "", body: untyped): untyped =
var spinner {.inject.} = newSpinny(msg, Dots)
if isatty(stdout): # don't spin if it's not a tty
start spinner
body
if isatty(stdout):
stop spinner
template withSpinner*(body: untyped): untyped =
withSpinner("", body)

1
tests/config.nims Normal file
View file

@ -0,0 +1 @@
switch("path", "$projectDir/../src")

51
tests/tbbansi.nim Normal file
View file

@ -0,0 +1,51 @@
import std/[strutils,unittest]
import hwylterm/bbansi
template bbCheck(input: string, output: string): untyped =
check escape($bb(input)) == escape(output)
suite "basic":
test "simple":
bbCheck "[red]red text", "\e[31mred text\e[0m"
bbCheck "[red]Red Text", "\e[31mRed Text\e[0m"
bbCheck "[yellow]Yellow Text", "\e[33mYellow Text\e[0m"
bbCheck "[bold red]Bold Red Text", "\e[1;31mBold Red Text\e[0m"
bbCheck "[red]5[/]", "\e[31m5\e[0m"
bbCheck "[bold][red]5","\e[1;31m5\e[0m"
test "closing":
bbCheck "[bold]Bold[red] Bold Red[/red] Bold Only",
"\e[1mBold\e[0m\e[1;31m Bold Red\e[0m\e[1m Bold Only\e[0m"
test "abbreviated":
bbCheck "[b]Bold[/] Not Bold", "\e[1mBold\e[0m Not Bold"
test "noop":
bbCheck "No Style", "No Style"
bbCheck "[unknown]Unknown Style", "Unknown Style"
test "escaped":
bbCheck "[[red] ignored pattern", "[red] ignored pattern"
test "newlines":
bbCheck "[red]Red Text[/]\nNext Line", "\e[31mRed Text\e[0m\nNext Line"
test "on color":
bbCheck "[red on yellow]Red on Yellow", "\e[31;43mRed on Yellow\e[0m"
test "concat-ops":
check "[red]RED[/]".bb & " plain string" == "[red]RED[/] plain string".bb
check "[red]RED[/]".bb.len == 3
check bb("[blue]Blue[/]") & " " & bb("[red]Red[/]") ==
"[blue]Blue[/] [red]Red[/]".bb
check "a plain string" & "[blue] a blue string".bb ==
"a plain string[blue] a blue string".bb
test "case":
bbCheck "[red]no case sensitivity[/RED]", "\e[31mno case sensitivity\e[0m"
test "style full":
check "[red]Red[/red]".bb == bb("Red", "red")
check "[b][yellow]not yellow[/][/b]".bb == bb("[yellow]not yellow[/]", "b")

28
tests/tcli.nim Normal file
View file

@ -0,0 +1,28 @@
import std/[
compilesettings,
os,
osproc,
strutils,
times,
unittest
]
const pathToSrc = querySetting(SingleValueSetting.projectPath)
proc cliRun(cmd: string): string =
let (output, _) = execCmdEx(pathToSrc / "bbansi.out " & cmd)
return output.strip()
suite "cli":
setup:
let
cli = pathToSrc / "bbansi.out"
srcDir = pathToSrc / ".." / "src"
cmd = "nim c -o:" & cli & " " & (srcDir / "bbansi.nim")
if not cli.fileExists or getFileInfo(cli).lastWriteTime < getFileInfo(srcDir).lastWriteTime:
echo " -> compiling test binary"
require execCmdEx(cmd).exitCode == 0
test "simple":
check "\e[31mRed\e[0m" == cliRun "[red]Red[/]"
check "\e[1;31mRed\e[0m\e[1m Not Red but Bold\e[0m" ==
cliRun "'[red]Red[/] Not Red but Bold' " & "--style:bold"