diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a912f72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +tests/* +!tests/*.nim +!tests/*.nims diff --git a/README.md b/README.md new file mode 100644 index 0000000..391e642 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# hwylterm + +Adding some fun to the terminal! diff --git a/config.nims b/config.nims new file mode 100644 index 0000000..9b62011 --- /dev/null +++ b/config.nims @@ -0,0 +1,2 @@ +task test, "run tests": + selfExec "r tests/tbbansi.nim" diff --git a/hwylterm.nimble b/hwylterm.nimble new file mode 100644 index 0000000..ad01601 --- /dev/null +++ b/hwylterm.nimble @@ -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" diff --git a/src/hwylterm.nim b/src/hwylterm.nim new file mode 100644 index 0000000..4901c5f --- /dev/null +++ b/src/hwylterm.nim @@ -0,0 +1,2 @@ +import hwylterm/[spin, bbansi] +export spin, bbansi diff --git a/src/hwylterm/bbansi.nim b/src/hwylterm/bbansi.nim new file mode 100644 index 0000000..62efd4b --- /dev/null +++ b/src/hwylterm/bbansi.nim @@ -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) diff --git a/src/hwylterm/bbansi/styles.nim b/src/hwylterm/bbansi/styles.nim new file mode 100644 index 0000000..302ddc1 --- /dev/null +++ b/src/hwylterm/bbansi/styles.nim @@ -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 diff --git a/src/hwylterm/bbansi/utils.nim b/src/hwylterm/bbansi/utils.nim new file mode 100644 index 0000000..c537086 --- /dev/null +++ b/src/hwylterm/bbansi/utils.nim @@ -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" diff --git a/src/hwylterm/spin.nim b/src/hwylterm/spin.nim new file mode 100644 index 0000000..5708837 --- /dev/null +++ b/src/hwylterm/spin.nim @@ -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) diff --git a/tests/config.nims b/tests/config.nims new file mode 100644 index 0000000..80091ff --- /dev/null +++ b/tests/config.nims @@ -0,0 +1 @@ +switch("path", "$projectDir/../src") diff --git a/tests/tbbansi.nim b/tests/tbbansi.nim new file mode 100644 index 0000000..ff99c24 --- /dev/null +++ b/tests/tbbansi.nim @@ -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") + diff --git a/tests/tcli.nim b/tests/tcli.nim new file mode 100644 index 0000000..091766d --- /dev/null +++ b/tests/tcli.nim @@ -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"