From 22d3966755f6348b8855d8658191fa6988c65ae3 Mon Sep 17 00:00:00 2001 From: Jonathan Bernard Date: Wed, 2 Apr 2025 06:13:27 -0500 Subject: [PATCH] WIP: Rendering/UI refactor. --- src/main/nim/wdiwtlt.nim | 350 ++++++++++++------------- src/main/nim/wdiwtlt/ansi.nim | 7 - src/main/nim/wdiwtlt/commandbuffer.nim | 119 +++++++-- src/main/nim/wdiwtlt/messagebuffer.nim | 167 ++++++++++++ src/main/nim/wdiwtlt/nonblockingio.nim | 263 +++++++++++++++++++ src/main/nim/wdiwtlt/scrolltext.nim | 28 +- src/main/nim/wdiwtlt/terminal.nim | 111 ++++++++ src/main/nim/wdiwtlt/usage.nim | 5 + src/main/nim/wdiwtlt/util.nim | 7 + 9 files changed, 838 insertions(+), 219 deletions(-) delete mode 100644 src/main/nim/wdiwtlt/ansi.nim create mode 100644 src/main/nim/wdiwtlt/messagebuffer.nim create mode 100644 src/main/nim/wdiwtlt/nonblockingio.nim create mode 100644 src/main/nim/wdiwtlt/terminal.nim create mode 100644 src/main/nim/wdiwtlt/usage.nim create mode 100644 src/main/nim/wdiwtlt/util.nim diff --git a/src/main/nim/wdiwtlt.nim b/src/main/nim/wdiwtlt.nim index 86c2925..d0976e0 100644 --- a/src/main/nim/wdiwtlt.nim +++ b/src/main/nim/wdiwtlt.nim @@ -1,49 +1,42 @@ -import std/[json, options, os, paths, strutils, times, unicode] -import cliutils, docopt, illwill, mpv +import std/[json, options, os, paths, sequtils, strutils, terminal, times, + unicode] +import cliutils, docopt, namespaced_logging, mpv -import wdiwtlt/[cliconstants, commandbuffer, logging, medialibrary, models, - scrolltext] +import wdiwtlt/[cliconstants, commandbuffer, logging, medialibrary, + messagebuffer, models, nonblockingio, scrolltext, usage] type CliMode {.pure.} = enum Direct, Command - ScrollingDisplays* = ref object - playing: ScrollText - status: ScrollText - statusDismissal: Option[DateTime] + MessageBuffers* = ref object + playing: MessageBuffer[ScrollText] + status: MessageBuffer[ScrollText] + longform: MessageBuffer[string] + input: MessageBuffer[string] History = ref object entries: seq[string] idx: int - RenderDimensions = ref object - width, height: int - playing: tuple[x, y: int] - status: tuple[x, y: int] - input: tuple[x, y: int] - logs: tuple[x, y: int] - CliContext = ref object cfg: CombinedConfig cmd: CommandBuffer - cmdVisibleIdx: int # first visible character in the cmd buffer curMediaFile: Option[MediaFile] - d: ScrollingDisplays - dims: RenderDimensions - frame: int lib: MediaLibrary log: Option[Logger] + logs: seq[LogMessage] + logThreshold: Level mode: CliMode + msgs: MessageBuffers mpv: ptr handle msgDuration: Duration stop*: bool - tb: TerminalBuffer - - CursorType {.pure.} = enum - Block, BlockBlink, BarBlink, Underline + width*: int + height*: int var ctx {.threadvar.}: CliContext +const FRAME_DUR_MS = 200 proc initMpv(): ptr handle = result = mpv.create() @@ -66,47 +59,18 @@ proc initMpv(): ptr handle = proc cleanup() = terminate_destroy(ctx.mpv) - illWillDeinit() + setNonBlockingTty(false) echo "" +#[ proc exitHandlerHook() {.noconv.} = # This is called when the user presses Ctrl+C # or when the program exits normally. - echo "Ctrl-C" terminate_destroy(ctx.mpv) - illWillDeinit() + echo "" quit(QuitSuccess) - - -proc clearLine(ctx: var CliContext, y: int) = - ctx.tb.write(0, y, ' '.repeat(ctx.tb.width)) - - -proc writeAt(ctx: var CliContext, pos: tuple[x, y: int], text: string) = - ctx.tb.write(pos.x, pos.y, text) - - -proc nextTick(ctx: var CliContext) = - if ctx.d.statusDismissal.isSome and ctx.d.statusDismissal.get < now(): - ctx.d.status.text = "" - ctx.d.statusDismissal = none[DateTime]() - ctx.clearLine(ctx.dims.status.y) - - discard ctx.d.playing.nextTick - discard ctx.d.status.nextTick - - -proc setMode(ctx: var CliContext, mode: CliMode) = - ctx.mode = mode - case mode - of CliMode.Direct: - hideCursor() - ctx.tb.clear() - of CliMode.Command: - showCursor() - ctx.cmd.mode = EditMode.Insert - +]# proc initContext(args: Table[string, Value]): CliContext = var wdiwtltCfgFilename: string @@ -149,177 +113,201 @@ proc initContext(args: Table[string, Value]): CliContext = let width = min(terminalWidth(), cfg.getVal("maxWidth", "80").parseInt) let height = min(terminalHeight(), cfg.getVal("maxHeight", "60").parseInt) + let msgs = MessageBuffers( + playing: initMessageBuffer[ScrollText]( + dims = BufferDimensions(width: width, height: 1, x: 0, y: 0), + initialMsg = "Nothing playing"), + status: initMessageBuffer[ScrollText]( + dims = BufferDimensions(width: width, height: 1, x: 0, y: 1), + initialMsg = ""), + input: initMessageBuffer[string]( + dims = BufferDimensions(width: width, height: 1, x: 0, y: 2), + initialMsg = ""), + longform: initMessageBuffer[string]( + dims = BufferDimensions(width: width, height: height - 3, x: 0, y: 4), + initialMsg = "")) + result = CliContext( cfg: cfg, - cmd: initCommandBuffer(), - cmdVisibleIdx: 0, + cmd: initCommandBuffer(msgs.input), curMediaFile: none(MediaFile), - d: ScrollingDisplays( - playing: initScrollText("Nothing playing", width), - status: initScrollText("Idle", width), - statusDismissal: none[DateTime]()), - dims: RenderDimensions( - width: width, - height: height, - playing: (0, 0), - status: (0, 1), - input: (0, 2), - logs: (0, 3)), - frame: 0, lib: initMediaLibrary( rootDir = getVal(cfg, "libraryRoot").Path, dbPath = getVal(cfg, "dbPath").Path), log: log, mode: CliMode.Direct, + msgs: msgs, mpv: initMpv(), msgDuration: initDuration(seconds = 5, milliseconds = 0), stop: false, - tb: newTerminalBuffer(width, height)) + width: width, + height: height) if logService.isSome: let customLogAppender = initCustomLogAppender(doLogMessage = - proc (msg: LogMessage): void = - ctx.writeAt(ctx.dims.logs, msg.message)) + proc (msg: LogMessage): void = ctx.logs.add(msg)) - #logService.get.clearAppenders() + logService.get.clearAppenders() logService.get.addAppender(customLogAppender) -proc setCursorType(ct: CursorType) = - case ct - of CursorType.Block: stdout.write("\x1b[2 q") - of CursorType.BlockBlink: stdout.write("\x1b[1 q") - of CursorType.BarBlink: stdout.write("\x1b[5 q") - of CursorType.Underline: stdout.write("\x1b[4 q") +proc nextTick(ctx: var CliContext) = + ctx.msgs.playing.nextTick + ctx.msgs.status.nextTick + ctx.msgs.longform.nextTick -proc setCursorPosition(x, y: int) = - stdout.write("\x1b[$#;$#H" % [$(y + 1), $(x + 1)]) +proc setMode(ctx: var CliContext, mode: CliMode) = + ctx.mode = mode + case mode + + of CliMode.Direct: + hideCursor() + + of CliMode.Command: + showCursor() + ctx.cmd.setMode(EditMode.Insert) -proc msg(ctx: var CliContext, msg: string) = - ctx.d.status.text = msg - if msg.len > ctx.dims.width: - ctx.d.statusDismissal = some(now() + ctx.msgDuration + - initDuration(milliseconds = (msg.len - ctx.dims.width) * 192)) - else: - ctx.d.statusDismissal = some(now() + ctx.msgDuration) +proc statusMsg(ctx: var CliContext, msg: string) = + ctx.msgs.status.showMsg(msg, some(ctx.msgDuration)) -proc render(ctx: var CliContext) = - # text moves at 5 characters per second (192ms per character) - if ctx.frame mod 12 == 0: nextTick(ctx) +proc longMsg[T: string or seq[string]](ctx: var CliContext, msg: T) = + ctx.msgs.longform.showMsg(msg, some(ctx.msgDuration)) - ctx.clearLine(ctx.dims.playing.y) - ctx.writeAt(ctx.dims.playing, ctx.d.playing) - ctx.clearLine(ctx.dims.status.y) - ctx.writeAt(ctx.dims.status, ctx.d.status) +proc viewLogLoops(ctx: var CliContext) = + let logLines = ctx.logs + .filterIt(it.level > ctx.logThreshold) + .mapIt("$# - [$#]: $#" % [$it.level, $it.scope, it.message]) + .join("\p") - if ctx.mode == CliMode.Command: - case ctx.cmd.mode: - of EditMode.Insert: setCursorType(CursorType.BarBlink) - of EditMode.Overwrite: setCursorType(CursorType.BlockBlink) - else: setCursorType(CursorType.Block) + ctx.msgs.longform.showMsg(logLines, none[Duration]()) - ctx.clearLine(ctx.dims.input.y) + var viewLogs = true + while viewLogs: + case getKeyAsync() + of Key.Q: viewLogs = false + of Key.K, Key.Up: ctx.msgs.longform.scrollLines(-1) + of Key.J, Key.Down: ctx.msgs.longform.scrollLines(1) + of Key.H, Key.Left: ctx.msgs.longform.scrollColumns(-1) + of Key.L, Key.Right: ctx.msgs.longform.scrollColumns(1) + of Key.PageUp: ctx.msgs.longform.scrollPages(-1) + of Key.PageDown: ctx.msgs.longform.scrollPages(1) + else: discard - var inputLine = $ctx.cmd - let spaceAvailable = ctx.dims.width - 2 - # Check to see that the line entered fits entirely within the space - # available to display it. We need two characters of space: one for the - # colon and one for the cursor. If we don't have enough space, we need to - # decide which portion of the line to display. - if len(inputLine) >= spaceAvailable: - # If the user has moved the cursor left of our visible index, follow them - # back - if ctx.cmd.idx < ctx.cmdVisibleIdx: ctx.cmdVisibleIdx = ctx.cmd.idx - - # If the user has moved right, push our index to the right to follow them - elif (ctx.cmd.idx - ctx.cmdVisibleIdx) > spaceAvailable: - ctx.cmdVisibleIdx = ctx.cmd.idx - spaceAvailable - - # If the user has started backspacing (we are showing less that we - # could), follow them back - elif (len(inputLine) - ctx.cmdVisibleIdx) < spaceAvailable: - ctx.cmdVisibleIdx = len(inputLine) - spaceAvailable - - # Show the portion of the line starting at the visible index and ending - # either with the end of the line or the end of the visible space - # (whichever comes first). We need to check both because the user may - # delete characters from the end of the line, which would make the - # portion of the line we're currently showing shorter than the visible - # space (don't try to access an index past the end of the string). - inputLine = inputLine[ - ctx.cmdVisibleIdx ..< - min(ctx.cmdVisibleIdx + spaceAvailable, len(inputLine))] - - elif ctx.cmdVisibleIdx > 0: - # We know the line fits within the space available, but we're still - # showing a subset of the line (probably because it was longer and the - # user backspaced). Let's just reset and show the whole line now that we - # can. - ctx.cmdVisibleIdx = 0 - - ctx.writeAt(ctx.dims.input, ":" & inputLine) - setCursorPosition( - ctx.dims.input.x + (ctx.cmd.idx - ctx.cmdVisibleIdx) + 1, - ctx.dims.input.y) - - else: - ctx.clearLine(ctx.dims.input.y) - ctx.writeAt(ctx.dims.input, "WDIWTLT v" & VERSION) - - ctx.tb.display() + sleep(FRAME_DUR_MS) + ctx.msgs.longform.clear() proc scanLibrary(ctx: var CliContext) = + ctx.statusMsg("Scanning media library...") let counts = ctx.lib.rescanLibrary() + var scanResults = @[ + "─".repeat(ctx.width), + "Scan complete:", + "\t$# files total." % [ $counts.total ] + ] + + if counts.new > 0: + scanResults.add("\t$# new files added." % [ $counts.new ]) + + if counts.ignored > 0: + scanResults.add("\t$# files ignored." % [ $counts.ignored ]) + + if counts.absent > 0: + scanResults.add( + "\t$# files in the database but not stored locally." % + [ $counts.absent ]) + + ctx.longMsg(scanResults.join("\p")) + +proc processLogs(ctx: var CliContext, args: seq[string]) = + case args[0] + of "view": ctx.viewLogLoops() + of "clear": ctx.logs = @[] + of "set-threshold": + if args.len < 2: + ctx.longMsg(("missing $# argument to $#" % + [ "log-threshold", "logs set-threshold" ]) & + usageOf(@["logs", "set-threshold"])) + + # TODO + else: ctx.statusMsg("Unrecognized logs command: " & args[0]) + proc processCommand(ctx: var CliContext, command: string) = - let parts = command.strip.split(' ', maxSplit=1) + let parts = command.strip.split(' ') case parts[0] of "scan": scanLibrary(ctx) of "q", "quit", "exit": ctx.stop = true - else: msg(ctx, "Unrecognized command: " & command) + of "logs": ctx.processLogs(parts[1..^1]) + else: statusMsg(ctx, "Unrecognized command: " & command) + + +proc handleKey(ctx: var CliContext, key: Key) = + if ctx.mode == CliMode.Direct: + case key + of Key.Q: ctx.stop = true + of Key.Colon, Key.I: ctx.setMode(CliMode.Command) + else: discard + + elif ctx.mode == CliMode.Command: + case key + of Key.Enter: + let command = $ctx.cmd + ctx.cmd.clear + ctx.cmd.mode = EditMode.Insert + processCommand(ctx, command) + of Key.Backspace: ctx.cmd.handleInput(Key.Backspace) + of Key.Escape: + if ctx.cmd.mode == EditMode.Command: + ctx.setMode(CliMode.Direct) + ctx.cmd.clear + else: ctx.cmd.handleInput(Key.Escape) + else: ctx.cmd.handleInput(key) + proc mainLoop(ctx: var CliContext) = hideCursor() + var frame = 0 while not ctx.stop: - let key = getKey() - if ctx.mode == CliMode.Direct: - case key - of Key.Q: ctx.stop = true - of Key.Colon, Key.I: ctx.setMode(CliMode.Command) - else: discard - - elif ctx.mode == CliMode.Command: - case key - of Key.Enter: - let command = $ctx.cmd - ctx.cmd.clear - ctx.cmdVisibleIdx = 0 - ctx.cmd.mode = EditMode.Insert - processCommand(ctx, command) - of Key.Backspace: ctx.cmd.handleInput(Key.Backspace) - of Key.Escape: - if ctx.cmd.mode == EditMode.Command: - ctx.setMode(CliMode.Direct) - ctx.cmd.clear - ctx.cmdVisibleIdx = 0 - else: ctx.cmd.handleInput(Key.Escape) - else: ctx.cmd.handleInput(key) - - render(ctx) - - # target 60 FPS - sleep(16) - ctx.frame = (ctx.frame + 1) mod 1920 + # This sleep below will be the primary driver of the frame rate of the + # application (examples below): + # + # | frames/sec | sec/frame | ms/frame | + # |------------|-----------|----------| + # | 5.00 | 0.200 | 200 | + # | 10.00 | 0.100 | 100 | + # | 12.50 | 0.080 | 80 | + # | 20.00 | 0.050 | 50 | + # | 25.00 | 0.040 | 40 | + # | 31.25 | 0.032 | 32 | + # | 50.00 | 0.020 | 20 | + # | 62.50 | 0.016 | 16 | + # + # Previously, when we were using illwill and rendering every frame we + # targeted a faster FPS to allow for a responsive feeling to the command + # input, etc. In this case, we still had a "logic update" period that was + # lower than our rendering FPS. Specifically, we were targeting 60fps + # rendering speed, but 5fps for logic updates (scrolling text, expiring + # messages, etc.). + # + # Now that we are directly rendering (components of the UI render updates + # themselves immediately) we don't need to render at 60fps to have a + # responsive UI. Now we really only care about the frequency of logic + # updates, so we're targeting 5fps and triggering our logic updates every + # frame. + handleKey(ctx, getKeyAsync()) + sleep(FRAME_DUR_MS) + nextTick(ctx) # tick on every frame + frame = (frame + 1) mod 1000 # use a max frame count to prevent overflow when isMainModule: @@ -328,8 +316,8 @@ when isMainModule: try: let args = docopt(USAGE, version=VERSION) - illWillInit(fullscreen=true) ctx = initContext(args) + setNonBlockingTty(true) mainLoop(ctx) diff --git a/src/main/nim/wdiwtlt/ansi.nim b/src/main/nim/wdiwtlt/ansi.nim deleted file mode 100644 index b6d78ba..0000000 --- a/src/main/nim/wdiwtlt/ansi.nim +++ /dev/null @@ -1,7 +0,0 @@ -import std/nre - -const CSI* = "\x1b[" -const ANSI_REGEX_PATTERN* = "\x1b\\[([0-9;]*)([a-zA-Z])" - -func stripAnsi*(text: string): string = - text.replace(re(ANSI_REGEX_PATTERN), "") diff --git a/src/main/nim/wdiwtlt/commandbuffer.nim b/src/main/nim/wdiwtlt/commandbuffer.nim index 519f168..9ed6fe9 100644 --- a/src/main/nim/wdiwtlt/commandbuffer.nim +++ b/src/main/nim/wdiwtlt/commandbuffer.nim @@ -1,6 +1,6 @@ -import std/[options] +import std/[options, strutils] -import illwill +import ./[messagebuffer, nonblockingio, terminal, util] type EditMode* {.pure.} = enum Command, Insert, Overwrite, Visual @@ -12,24 +12,19 @@ type CommandBuffer* = ref object history: seq[Command] + mb: MessageBuffer[string] idx: int + visibleIdx: int # first visible character in the cmd buffer mode*: EditMode -func clamp(value, min, max: int): int = - if value < min: - return min - elif value > max: - return max - else: - return value - -func initCommandBuffer*(): CommandBuffer = +func initCommandBuffer*(mb: MessageBuffer[string]): CommandBuffer = result = CommandBuffer( history: @[Command( buffer: "", idx: 0, selectionStartIdx: none(int))], idx: 0, + mb: mb, mode: Insert) proc cur(cb: CommandBuffer): Command = cb.history[cb.idx] @@ -72,6 +67,7 @@ proc clear*(cb: var CommandBuffer) = idx: 0, selectionStartIdx: none(int))) cb.idx = cb.history.len - 1 + cb.visibleIdx = 0 proc left*(cb: var CommandBuffer) = cb.cur.idx = clamp(cb.cur.idx - 1, 0, cb.cur.buffer.len) @@ -102,11 +98,87 @@ proc endOfWord*(cb: var CommandBuffer) = func toChar(k: Key): char = cast[char](ord(k)) + +proc setMode*(cb: var CommandBuffer, mode: EditMode) = + cb.mode = mode + case mode + of EditMode.Insert: setCursorType(ctBarBlink) + of EditMode.Overwrite: setCursorType(ctBlock) + else: setCursorType(ctBlockBlink) + + +func `$`*(cb: CommandBuffer): string = + if cb.cur.selectionStartIdx.isNone: + return cb.cur.buffer + else: + let selIdx = cb.cur.selectionStartIdx.get + let curIdx = cb.cur.idx + + if selIdx < curIdx: + return cb.cur.buffer[0..= spaceAvailable: + # If the user has moved the cursor left of our visible index, follow them + # back + if cmd.idx < cb.visibleIdx: cb.visibleIdx = cmd.idx + + # If the user has moved right, push our index to the right to follow them + elif (cmd.idx - cb.visibleIdx) > spaceAvailable: + cb.visibleIdx = cmd.idx - spaceAvailable + + # If the user has started backspacing (we are showing less that we + # could), follow them back + elif (len(cmd.buffer) - cb.visibleIdx) < spaceAvailable: + cb.visibleIdx = len(cmd.buffer) - spaceAvailable + + # Show the portion of the line starting at the visible index and ending + # either with the end of the line or the end of the visible space + # (whichever comes first). We need to check both because the user may + # delete characters from the end of the line, which would make the + # portion of the line we're currently showing shorter than the visible + # space (don't try to access an index past the end of the string). + inputLine = cmd.buffer[ + cb.visibleIdx ..< + min(cb.visibleIdx + spaceAvailable, len(cmd.buffer))] + + elif cb.visibleIdx > 0: + # We know the line fits within the space available, but we're still + # showing a subset of the line (probably because it was longer and the + # user backspaced). Let's just reset and show the whole line now that we + # can. + cb.visibleIdx = 0 + + # TODO: implement VISUAL mode selection highlighting + cb.mb.showMsg(":" & inputLine) + setCursorPosition( + cb.mb.dims.x + (cmd.idx - cb.visibleIdx) + 1, + cb.mb.dims.y) + proc handleInput*(cb: var CommandBuffer, key: Key) = case cb.mode of EditMode.Insert, EditMode.Overwrite: case key - of Key.Escape: cb.mode = EditMode.Command + of Key.Escape: cb.setMode(EditMode.Command) of Key.Backspace: cb.delete() of Key.Delete: cb.delete(backspace = false) of Key.Left: cb.left() @@ -133,26 +205,27 @@ proc handleInput*(cb: var CommandBuffer, key: Key) = of Key.B: cb.backWord() of Key.W: cb.forwardWord() of Key.E: cb.endOfWord() - of Key.I: cb.mode = EditMode.Insert + of Key.I: cb.setMode(EditMode.Insert) of Key.A: cb.cur.idx = clamp(cb.cur.idx + 1, 0, cb.cur.buffer.len) - cb.mode = EditMode.Insert + cb.setMode(EditMode.Insert) of Key.ShiftA: cb.cur.idx = cb.cur.buffer.len - cb.mode = EditMode.Insert + cb.setMode(EditMode.Insert) of Key.ShiftI: cb.cur.idx = 0 - cb.mode = EditMode.Insert - of Key.ShiftR: cb.mode = EditMode.Overwrite + cb.setMode(EditMode.Insert) + of Key.ShiftR: + cb.setMode(EditMode.Overwrite) of Key.V: - cb.mode = EditMode.Visual + cb.setMode(EditMode.Visual) cb.cur.selectionStartIdx = some(cb.cur.idx) else: discard of EditMode.Visual: case key of Key.Escape: - cb.mode = EditMode.Command + cb.setMode(EditMode.Command) cb.cur.selectionStartIdx = none(int) of Key.Backspace: cb.left() of Key.Left, Key.H: cb.left() @@ -162,17 +235,13 @@ proc handleInput*(cb: var CommandBuffer, key: Key) = cb.prev() of Key.Down, Key.K: cb.cur.selectionStartIdx = none[int]() - cb.next() of Key.Home, Key.Zero: cb.toHome() of Key.End, Key.Dollar: cb.toEnd() of Key.B: cb.backWord() of Key.W: cb.forwardWord() of Key.V: - cb.mode = EditMode.Command + cb.setMode(EditMode.Command) cb.cur.selectionStartIdx = none(int) else: discard - -func `$`*(cb: CommandBuffer): string = cb.cur.buffer - -func idx*(cb: CommandBuffer): int = cb.cur.idx + render(cb) diff --git a/src/main/nim/wdiwtlt/messagebuffer.nim b/src/main/nim/wdiwtlt/messagebuffer.nim new file mode 100644 index 0000000..d65f569 --- /dev/null +++ b/src/main/nim/wdiwtlt/messagebuffer.nim @@ -0,0 +1,167 @@ +import std/[options, sequtils, strutils, times, wordwrap] + +import ./[scrolltext, terminal, util] + +type + BufferDimensions* = object + width*, height*, x*, y*: int + + MessageBuffer*[T: ScrollText or string] = ref object + content: T + dims*: BufferDimensions + dismissAfter: Option[DateTime] + firstColumnIdx: int + firstLineIdx: int + + +proc getLinePortion(line: string, start: int, width: int): string = + let cleanLine = stripFormatting(line) + + if start == 0 and len(cleanLine) <= width: + result = line & ' '.repeat(width - len(cleanLine)) + else: + result = ansiAwareSubstring(line, start, width) + + +proc render[T: ScrollText or string](mb: var MessageBuffer[T]) = + hideCursor() + when T is ScrollText: write(mb.dims.x, mb.dims.y, $mb.content) + when T is string: + let renderedContent = splitLines(mb.content) + .mapIt(wrapWords( + s = it, + maxLineWidth = mb.dims.width, + newLine = "\p").alignLeft(mb.dims.width)) + .mapIt(getLinePortion(it, mb.firstColumnIdx, mb.dims.width)) + .join("\p") + let lastLineIdx = min(mb.firstLineIdx + mb.dims.height, len(renderedContent)) + write(mb.dims.x, mb.dims.y, renderedContent[mb.firstLineIdx.. mb.dismissAfter.get: + mb.clear() + + when T is ScrollText: + if mb.content.isScrolling: + discard mb.content.nextTick() + render(mb) + + +func hasContent*[T: ScrollText or string]( + mb: MessageBuffer[T]): bool = + when T is ScrollText: return len($mb.content) > 0 + when T is string: return len(mb.content) > 0 + + +proc scrollLines*[T](mb: var MessageBuffer[T], lines: int) = + mb.firstLineIdx = clamp( + mb.firstLineIdx + lines, + 0, + max(0, len(mb.content) - mb.dims.height)) + render(mb) + +proc scrollPages*[T](mb: var MessageBuffer[T], pages: int) = + scrollLines(mb, pages * mb.dims.height) + + +proc scrollColumns*(mb: var MessageBuffer[string], chars: int) = + mb.firstColumnIdx = clamp( + mb.firstColumnIdx + chars, + 0, + max(0, len(mb.content) - mb.dims.width)) + render(mb) + +proc resetScroll*( + mb: var MessageBuffer[ScrollText]) = + mb.firstColumnIdx = 0 + mb.firstLineIdx = 0 + render(mb) diff --git a/src/main/nim/wdiwtlt/nonblockingio.nim b/src/main/nim/wdiwtlt/nonblockingio.nim new file mode 100644 index 0000000..081d5a3 --- /dev/null +++ b/src/main/nim/wdiwtlt/nonblockingio.nim @@ -0,0 +1,263 @@ +# Adapted from [illwill](https://github.com/johnnovak/illwill) by John Novak +import std/[posix, termios] + +type + Key* {.pure.} = enum ## Supported single key presses and key combinations + None = (-1, "None"), + + # Special ASCII characters + CtrlA = (1, "CtrlA"), + CtrlB = (2, "CtrlB"), + CtrlC = (3, "CtrlC"), + CtrlD = (4, "CtrlD"), + CtrlE = (5, "CtrlE"), + CtrlF = (6, "CtrlF"), + CtrlG = (7, "CtrlG"), + CtrlH = (8, "CtrlH"), + Tab = (9, "Tab"), # Ctrl-I + CtrlJ = (10, "CtrlJ"), + CtrlK = (11, "CtrlK"), + CtrlL = (12, "CtrlL"), + Enter = (13, "Enter"), # Ctrl-M + CtrlN = (14, "CtrlN"), + CtrlO = (15, "CtrlO"), + CtrlP = (16, "CtrlP"), + CtrlQ = (17, "CtrlQ"), + CtrlR = (18, "CtrlR"), + CtrlS = (19, "CtrlS"), + CtrlT = (20, "CtrlT"), + CtrlU = (21, "CtrlU"), + CtrlV = (22, "CtrlV"), + CtrlW = (23, "CtrlW"), + CtrlX = (24, "CtrlX"), + CtrlY = (25, "CtrlY"), + CtrlZ = (26, "CtrlZ"), + Escape = (27, "Escape"), + + CtrlBackslash = (28, "CtrlBackslash"), + CtrlRightBracket = (29, "CtrlRightBracket"), + + # Printable ASCII characters + Space = (32, "Space"), + ExclamationMark = (33, "ExclamationMark"), + DoubleQuote = (34, "DoubleQuote"), + Hash = (35, "Hash"), + Dollar = (36, "Dollar"), + Percent = (37, "Percent"), + Ampersand = (38, "Ampersand"), + SingleQuote = (39, "SingleQuote"), + LeftParen = (40, "LeftParen"), + RightParen = (41, "RightParen"), + Asterisk = (42, "Asterisk"), + Plus = (43, "Plus"), + Comma = (44, "Comma"), + Minus = (45, "Minus"), + Dot = (46, "Dot"), + Slash = (47, "Slash"), + + Zero = (48, "Zero"), + One = (49, "One"), + Two = (50, "Two"), + Three = (51, "Three"), + Four = (52, "Four"), + Five = (53, "Five"), + Six = (54, "Six"), + Seven = (55, "Seven"), + Eight = (56, "Eight"), + Nine = (57, "Nine"), + + Colon = (58, "Colon"), + Semicolon = (59, "Semicolon"), + LessThan = (60, "LessThan"), + Equals = (61, "Equals"), + GreaterThan = (62, "GreaterThan"), + QuestionMark = (63, "QuestionMark"), + At = (64, "At"), + + ShiftA = (65, "ShiftA"), + ShiftB = (66, "ShiftB"), + ShiftC = (67, "ShiftC"), + ShiftD = (68, "ShiftD"), + ShiftE = (69, "ShiftE"), + ShiftF = (70, "ShiftF"), + ShiftG = (71, "ShiftG"), + ShiftH = (72, "ShiftH"), + ShiftI = (73, "ShiftI"), + ShiftJ = (74, "ShiftJ"), + ShiftK = (75, "ShiftK"), + ShiftL = (76, "ShiftL"), + ShiftM = (77, "ShiftM"), + ShiftN = (78, "ShiftN"), + ShiftO = (79, "ShiftO"), + ShiftP = (80, "ShiftP"), + ShiftQ = (81, "ShiftQ"), + ShiftR = (82, "ShiftR"), + ShiftS = (83, "ShiftS"), + ShiftT = (84, "ShiftT"), + ShiftU = (85, "ShiftU"), + ShiftV = (86, "ShiftV"), + ShiftW = (87, "ShiftW"), + ShiftX = (88, "ShiftX"), + ShiftY = (89, "ShiftY"), + ShiftZ = (90, "ShiftZ"), + + LeftBracket = (91, "LeftBracket"), + Backslash = (92, "Backslash"), + RightBracket = (93, "RightBracket"), + Caret = (94, "Caret"), + Underscore = (95, "Underscore"), + GraveAccent = (96, "GraveAccent"), + + A = (97, "A"), + B = (98, "B"), + C = (99, "C"), + D = (100, "D"), + E = (101, "E"), + F = (102, "F"), + G = (103, "G"), + H = (104, "H"), + I = (105, "I"), + J = (106, "J"), + K = (107, "K"), + L = (108, "L"), + M = (109, "M"), + N = (110, "N"), + O = (111, "O"), + P = (112, "P"), + Q = (113, "Q"), + R = (114, "R"), + S = (115, "S"), + T = (116, "T"), + U = (117, "U"), + V = (118, "V"), + W = (119, "W"), + X = (120, "X"), + Y = (121, "Y"), + Z = (122, "Z"), + + LeftBrace = (123, "LeftBrace"), + Pipe = (124, "Pipe"), + RightBrace = (125, "RightBrace"), + Tilde = (126, "Tilde"), + Backspace = (127, "Backspace"), + + # Special characters with virtual keycodes + Up = (1001, "Up"), + Down = (1002, "Down"), + Right = (1003, "Right"), + Left = (1004, "Left"), + Home = (1005, "Home"), + Insert = (1006, "Insert"), + Delete = (1007, "Delete"), + End = (1008, "End"), + PageUp = (1009, "PageUp"), + PageDown = (1010, "PageDown"), + + F1 = (1011, "F1"), + F2 = (1012, "F2"), + F3 = (1013, "F3"), + F4 = (1014, "F4"), + F5 = (1015, "F5"), + F6 = (1016, "F6"), + F7 = (1017, "F7"), + F8 = (1018, "F8"), + F9 = (1019, "F9"), + F10 = (1020, "F10"), + F11 = (1021, "F11"), + F12 = (1022, "F12"), + + Mouse = (5000, "Mouse") + +const + KEYS_D = [Key.Up, Key.Down, Key.Right, Key.Left, Key.None, Key.End, Key.None, Key.Home] + KEYS_E = [Key.Delete, Key.End, Key.PageUp, Key.PageDown, Key.Home, Key.End] + KEYS_F = [Key.F1, Key.F2, Key.F3, Key.F4, Key.F5, Key.None, Key.F6, Key.F7, Key.F8] + KEYS_G = [Key.F9, Key.F10, Key.None, Key.F11, Key.F12] + +{.push warning[HoleEnumConv]:off.} + +func toKey(c: int): Key = + try: + result = Key(c) + except RangeDefect: # ignore unknown keycodes + result = Key.None + +{.pop} + +proc setNonBlockingTty*(enabled: bool) = + var ttyState: Termios + + # get the terminal state + discard tcGetAttr(STDIN_FILENO, ttyState.addr) + + if enabled: + # turn off canonical mode & echo + ttyState.c_lflag = ttyState.c_lflag and not Cflag(ICANON or ECHO) + + # minimum of number input read + ttyState.c_cc[VMIN] = 0.char + + else: + # turn on canonical mode & echo + ttyState.c_lflag = ttyState.c_lflag or ICANON or ECHO + + # set the terminal attributes. + discard tcSetAttr(STDIN_FILENO, TCSANOW, ttyState.addr) + +proc parseStdin[T](input: T): Key = + var ch1, ch2, ch3, ch4, ch5: char + result = Key.None + if read(input, ch1.addr, 1) > 0: + case ch1 + of '\e': + if read(input, ch2.addr, 1) > 0: + if ch2 == 'O' and read(input, ch3.addr, 1) > 0: + if ch3 in "ABCDFH": + result = KEYS_D[int(ch3) - int('A')] + elif ch3 in "PQRS": + result = KEYS_F[int(ch3) - int('P')] + elif ch2 == '[' and read(input, ch3.addr, 1) > 0: + if ch3 in "ABCDFH": + result = KEYS_D[int(ch3) - int('A')] + elif ch3 in "PQRS": + result = KEYS_F[int(ch3) - int('P')] + elif ch3 == '1' and read(input, ch4.addr, 1) > 0: + if ch4 == '~': + result = Key.Home + elif ch4 in "12345789" and read(input, ch5.addr, 1) > 0 and ch5 == '~': + result = KEYS_F[int(ch4) - int('1')] + elif ch3 == '2' and read(input, ch4.addr, 1) > 0: + if ch4 == '~': + result = Key.Insert + elif ch4 in "0134" and read(input, ch5.addr, 1) > 0 and ch5 == '~': + result = KEYS_G[int(ch4) - int('0')] + elif ch3 in "345678" and read(input, ch4.addr, 1) > 0 and ch4 == '~': + result = KEYS_E[int(ch3) - int('3')] + else: + discard # if cannot parse full seq it is discarded + else: + discard # if cannot parse full seq it is discarded + else: + result = Key.Escape + of '\n': + result = Key.Enter + of '\b': + result = Key.Backspace + else: + result = toKey(int(ch1)) + +proc kbhit(ms: int): cint = + var tv: Timeval + tv.tv_sec = Time(ms div 1000) + tv.tv_usec = 1000 * (int32(ms) mod 1000) # int32 because of macos + + var fds: TFdSet + FD_ZERO(fds) + FD_SET(STDIN_FILENO, fds) + discard select(STDIN_FILENO+1, fds.addr, nil, nil, tv.addr) + return FD_ISSET(STDIN_FILENO, fds) + +proc getKeyAsync*(ms: int = 0): Key = + result = Key.None + if kbhit(ms) > 0: + result = parseStdin(cint(STDIN_FILENO)) diff --git a/src/main/nim/wdiwtlt/scrolltext.nim b/src/main/nim/wdiwtlt/scrolltext.nim index f28095b..908e4ab 100644 --- a/src/main/nim/wdiwtlt/scrolltext.nim +++ b/src/main/nim/wdiwtlt/scrolltext.nim @@ -1,48 +1,64 @@ +import std/strutils + type ScrollText* = ref object text: string maxWidth: int scrollIdx: int # Current index of the first character to show. lastRender: string +const endBufLen = 4 + + proc render(st: ScrollText): string = if st.text.len <= st.maxWidth: return st.text if st.scrollIdx == 0: return st.text[0..= st.text.len: + return ' '.repeat(endBufLen - (st.scrollIdx - st.text.len)) & + st.text[0.. st.maxWidth + + func `$`*(st: ScrollText): string = st.lastRender + converter toString*(st: ScrollText): string = st.lastRender diff --git a/src/main/nim/wdiwtlt/terminal.nim b/src/main/nim/wdiwtlt/terminal.nim new file mode 100644 index 0000000..d5f11cc --- /dev/null +++ b/src/main/nim/wdiwtlt/terminal.nim @@ -0,0 +1,111 @@ +import std/[nre, sequtils] + +const CSI = "\x1b[" +const RESET_FORMATTING* = "\x1b[0m" +const ANSI_ESCAPE_CODE_ENDINGS*: seq[char] = toSeq('A'..'Z') & toSeq('a'..'z') +let FORMATTING_REGEX* = re("\x1b\\[([0-9;]*)([a-zA-Z])") + +type + CursorType* = enum + ctBlockBlink = 1, ctBlock, ctUnderlineBlink, ctUnderline, ctBarBlink, ctBar + + EraseMode* = enum + emToEnd, emToStart, emAll + + TerminalColors* = enum + cBlack, cRed, cGreen, cYellow, cBlue, cMagenta, cCyan, cWhite + + +proc stripFormatting*(text: string): string = + text.replace(FORMATTING_REGEX, "") + + +func ansiAwareSubstring*(s: string, start, length: int): string = + result = "" + var curAnsiEscCode = "" + var i = 0 + var visibleLen = 0 + + while i < len(s) and visibleLen < length: + + if len(s) > i + len(CSI) and + # We need to notice ANSI escape codes... + s[i.. max: + return max + else: + return value