mirror of
https://github.com/daylinmorgan/hwylterm.git
synced 2024-12-21 18:50:44 -06:00
start hwyl with bbansi and spinny
This commit is contained in:
parent
ec00bafad6
commit
a9ec2e738e
12 changed files with 572 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
tests/*
|
||||
!tests/*.nim
|
||||
!tests/*.nims
|
3
README.md
Normal file
3
README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# hwylterm
|
||||
|
||||
Adding some fun to the terminal!
|
2
config.nims
Normal file
2
config.nims
Normal file
|
@ -0,0 +1,2 @@
|
|||
task test, "run tests":
|
||||
selfExec "r tests/tbbansi.nim"
|
12
hwylterm.nimble
Normal file
12
hwylterm.nimble
Normal 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
2
src/hwylterm.nim
Normal file
|
@ -0,0 +1,2 @@
|
|||
import hwylterm/[spin, bbansi]
|
||||
export spin, bbansi
|
276
src/hwylterm/bbansi.nim
Normal file
276
src/hwylterm/bbansi.nim
Normal 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)
|
29
src/hwylterm/bbansi/styles.nim
Normal file
29
src/hwylterm/bbansi/styles.nim
Normal 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
|
27
src/hwylterm/bbansi/utils.nim
Normal file
27
src/hwylterm/bbansi/utils.nim
Normal 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
138
src/hwylterm/spin.nim
Normal 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
1
tests/config.nims
Normal file
|
@ -0,0 +1 @@
|
|||
switch("path", "$projectDir/../src")
|
51
tests/tbbansi.nim
Normal file
51
tests/tbbansi.nim
Normal 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
28
tests/tcli.nim
Normal 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"
|
Loading…
Reference in a new issue